Fix empty Bearer on netx__* by waiting for credentials.

Inject credentials before publish, read the live token per HTTP request, and surface netx_token_missing instead of a silent 401.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 02:11:20 +08:00
parent 02cc68bee3
commit 027f83dd00
7 changed files with 77 additions and 37 deletions

View file

@ -57,12 +57,6 @@ function encodeQuery(params) {
} }
function createNetxClient(connection) { function createNetxClient(connection) {
const base = connection.apiUrl.replace(/\/$/, ""); const base = connection.apiUrl.replace(/\/$/, "");
const headers = {
accept: "application/json"
};
if (connection.token.trim().length > 0) {
headers.authorization = `Bearer ${connection.token.trim()}`;
}
const langParams = () => { const langParams = () => {
const lang = connection.lang.trim().toLowerCase(); const lang = connection.lang.trim().toLowerCase();
if (lang.startsWith("en")) if (lang.startsWith("en"))
@ -70,6 +64,14 @@ function createNetxClient(connection) {
return {}; return {};
}; };
async function request(method, path, options = {}) { async function request(method, path, options = {}) {
const token = connection.getToken().trim();
if (token.length === 0) {
return {
ok: false,
error: "netx_token_missing",
detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)."
};
}
const merged = { ...langParams(), ...options.params }; const merged = { ...langParams(), ...options.params };
const url = `${base}${path}${encodeQuery(merged)}`; const url = `${base}${path}${encodeQuery(merged)}`;
const timeoutMs = options.timeoutMs ?? connection.timeoutMs; const timeoutMs = options.timeoutMs ?? connection.timeoutMs;
@ -82,9 +84,15 @@ function createNetxClient(connection) {
}; };
options.signal?.addEventListener("abort", onOuterAbort, { once: true }); options.signal?.addEventListener("abort", onOuterAbort, { once: true });
try { try {
const headers = {
accept: "application/json",
authorization: `Bearer ${token}`
};
if (options.body !== undefined)
headers["content-type"] = "application/json";
const init = { const init = {
method, method,
headers: options.body === undefined ? headers : { ...headers, "content-type": "application/json" }, headers,
signal: controller.signal signal: controller.signal
}; };
if (options.body !== undefined) if (options.body !== undefined)
@ -515,9 +523,9 @@ function tool(name, description, parameters, handler, getClient, timeoutMs) {
function registerNetxTools(ctx, connection) { function registerNetxTools(ctx, connection) {
const client = createNetxClient({ const client = createNetxClient({
apiUrl: connection.apiUrl, apiUrl: connection.apiUrl,
token: connection.token,
lang: connection.lang, lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000) timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000),
getToken: () => getNetxConnection()?.token ?? ""
}); });
const getClient = () => client; const getClient = () => client;
const t = connection.toolCallTimeoutMs; const t = connection.toolCallTimeoutMs;
@ -662,7 +670,8 @@ function apply(ctx) {
return; return;
} }
unregister = registerNetxTools(ctx, connection); unregister = registerNetxTools(ctx, connection);
ctx.logger.info("netxops-tools: registered netx__* for Ops preset → %s", connection.apiUrl); const tokenConfigured = connection.token.trim().length > 0;
ctx.logger.info("netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s", connection.apiUrl, tokenConfigured);
}; };
remount(); remount();
const stopWatch = watchNetxConnection(() => { const stopWatch = watchNetxConnection(() => {

View file

@ -27,6 +27,7 @@ function publishNetxConnection(next) {
// src/index.ts // src/index.ts
var name = "netxops"; var name = "netxops";
var inject = ["credentials"];
var NETXOPS_SETTINGS_NAMESPACE = "netxops"; var NETXOPS_SETTINGS_NAMESPACE = "netxops";
var NETXOPS_PRESET_ID = "netxops"; var NETXOPS_PRESET_ID = "netxops";
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
@ -69,10 +70,7 @@ function ensureAgentPresetInstalled(logger) {
} }
} }
async function resolveToken(ctx, refName) { async function resolveToken(ctx, refName) {
const credentials = ctx.get("credentials"); const hit = await ctx.credentials.resolve(credentialRef(refName));
if (credentials === undefined)
return "";
const hit = await credentials.resolve(credentialRef(refName));
return hit?.value ?? ""; return hit?.value ?? "";
} }
function installNetxopsSettings(ctx, entry, hooks) { function installNetxopsSettings(ctx, entry, hooks) {
@ -99,13 +97,19 @@ function apply(ctx, config = Config({})) {
const token = await resolveToken(ctx, current.tokenCredentialRef); const token = await resolveToken(ctx, current.tokenCredentialRef);
if (gen !== generation) if (gen !== generation)
return; return;
const apiUrl = current.apiUrl.replace(/\/$/, "");
const tokenConfigured = token.trim().length > 0;
publishNetxConnection({ publishNetxConnection({
apiUrl: current.apiUrl.replace(/\/$/, ""), apiUrl,
token, token,
lang: current.lang, lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs toolCallTimeoutMs: current.toolCallTimeoutMs
}); });
ctx.logger.info("netxops: published connection → %s", current.apiUrl.replace(/\/$/, "")); if (!tokenConfigured) {
ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef);
} else {
ctx.logger.info("netxops: published connection → %s tokenConfigured=true", apiUrl);
}
}).catch((error) => { }).catch((error) => {
ctx.logger.error("netxops: connection publish error: %s", error); ctx.logger.error("netxops: connection publish error: %s", error);
}); });
@ -129,6 +133,7 @@ function apply(ctx, config = Config({})) {
} }
export { export {
name, name,
inject,
ensureAgentPresetInstalled, ensureAgentPresetInstalled,
apply, apply,
NETXOPS_SETTINGS_NAMESPACE, NETXOPS_SETTINGS_NAMESPACE,

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-netxops", "name": "dsh-netxops",
"version": "0.1.13", "version": "0.1.14",
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + Plugins settings card + agent preset", "description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + Plugins settings card + agent preset",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -31,7 +31,12 @@ export function apply(ctx: Context): void {
return return
} }
unregister = registerNetxTools(ctx, connection) unregister = registerNetxTools(ctx, connection)
ctx.logger.info('netxops-tools: registered netx__* for Ops preset → %s', connection.apiUrl) const tokenConfigured = connection.token.trim().length > 0
ctx.logger.info(
'netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s',
connection.apiUrl,
tokenConfigured,
)
} }
remount() remount()

View file

@ -25,6 +25,9 @@ import { publishNetxConnection } from './netx/runtime.ts'
/** Cordis plugin name. */ /** Cordis plugin name. */
export const name = 'netxops' export const name = 'netxops'
/** Wait for the credentials store before publishing a Bearer snapshot. */
export const inject = ['credentials']
/** Settings / composition namespace (Plugins page join key). */ /** Settings / composition namespace (Plugins page join key). */
export const NETXOPS_SETTINGS_NAMESPACE = 'netxops' export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
@ -101,11 +104,10 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
/** /**
* Resolve bearer token from the credentials seam (or empty when unset). * Resolve bearer token from the credentials seam (or empty when unset).
* Requires `inject: ['credentials']` so the store is live before apply.
*/ */
async function resolveToken(ctx: Context, refName: string): Promise<string> { async function resolveToken(ctx: Context, refName: string): Promise<string> {
const credentials = ctx.get('credentials') const hit = await ctx.credentials.resolve(credentialRef(refName))
if (credentials === undefined) return ''
const hit = await credentials.resolve(credentialRef(refName))
return hit?.value ?? '' return hit?.value ?? ''
} }
@ -160,13 +162,23 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const token = await resolveToken(ctx, current.tokenCredentialRef) const token = await resolveToken(ctx, current.tokenCredentialRef)
if (gen !== generation) return if (gen !== generation) return
const apiUrl = current.apiUrl.replace(/\/$/, '')
const tokenConfigured = token.trim().length > 0
publishNetxConnection({ publishNetxConnection({
apiUrl: current.apiUrl.replace(/\/$/, ''), apiUrl,
token, token,
lang: current.lang, lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs, toolCallTimeoutMs: current.toolCallTimeoutMs,
}) })
ctx.logger.info('netxops: published connection → %s', current.apiUrl.replace(/\/$/, '')) if (!tokenConfigured) {
ctx.logger.warn(
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
apiUrl,
current.tokenCredentialRef,
)
} else {
ctx.logger.info('netxops: published connection → %s tokenConfigured=true', apiUrl)
}
}).catch((error) => { }).catch((error) => {
ctx.logger.error('netxops: connection publish error: %s', error) ctx.logger.error('netxops: connection publish error: %s', error)
}) })

View file

@ -4,9 +4,10 @@
export interface NetxConnection { export interface NetxConnection {
apiUrl: string apiUrl: string
token: string
lang: string lang: string
timeoutMs: number timeoutMs: number
/** Read the latest bearer on each request (do not freeze at client creation). */
getToken: () => string
} }
export type NetxJson = Record<string, unknown> export type NetxJson = Record<string, unknown>
@ -43,18 +44,12 @@ function encodeQuery(params: Record<string, string | number | boolean>): string
} }
/** /**
* Build a client bound to the current settings/credentials snapshot. * Build a client bound to apiUrl / lang / timeout; Bearer is resolved per request.
* @param connection - apiUrl / token / lang / default timeout. * @param connection - apiUrl / lang / timeout / getToken.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`. * @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/ */
export function createNetxClient(connection: NetxConnection) { export function createNetxClient(connection: NetxConnection) {
const base = connection.apiUrl.replace(/\/$/, '') const base = connection.apiUrl.replace(/\/$/, '')
const headers: Record<string, string> = {
accept: 'application/json',
}
if (connection.token.trim().length > 0) {
headers.authorization = `Bearer ${connection.token.trim()}`
}
const langParams = (): Record<string, string> => { const langParams = (): Record<string, string> => {
const lang = connection.lang.trim().toLowerCase() const lang = connection.lang.trim().toLowerCase()
@ -72,6 +67,15 @@ export function createNetxClient(connection: NetxConnection) {
signal?: AbortSignal signal?: AbortSignal
} = {}, } = {},
): Promise<NetxJson> { ): Promise<NetxJson> {
const token = connection.getToken().trim()
if (token.length === 0) {
return {
ok: false,
error: 'netx_token_missing',
detail: 'Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token).',
}
}
const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params } const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params }
const url = `${base}${path}${encodeQuery(merged)}` const url = `${base}${path}${encodeQuery(merged)}`
const timeoutMs = options.timeoutMs ?? connection.timeoutMs const timeoutMs = options.timeoutMs ?? connection.timeoutMs
@ -80,11 +84,14 @@ export function createNetxClient(connection: NetxConnection) {
const onOuterAbort = () => { controller.abort() } const onOuterAbort = () => { controller.abort() }
options.signal?.addEventListener('abort', onOuterAbort, { once: true }) options.signal?.addEventListener('abort', onOuterAbort, { once: true })
try { try {
const headers: Record<string, string> = {
accept: 'application/json',
authorization: `Bearer ${token}`,
}
if (options.body !== undefined) headers['content-type'] = 'application/json'
const init: RequestInit = { const init: RequestInit = {
method, method,
headers: options.body === undefined headers,
? headers
: { ...headers, 'content-type': 'application/json' },
signal: controller.signal, signal: controller.signal,
} }
if (options.body !== undefined) init.body = JSON.stringify(options.body) if (options.body !== undefined) init.body = JSON.stringify(options.body)

View file

@ -5,6 +5,7 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools' import { defineTool } from '@deepseek-ai/dsh-tools'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts' import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { getNetxConnection } from './runtime.ts'
import * as H from './handlers.ts' import * as H from './handlers.ts'
export interface NetxToolConnection { export interface NetxToolConnection {
@ -61,16 +62,17 @@ function tool(
/** /**
* Register all Netx Ops tools against the current connection snapshot. * Register all Netx Ops tools against the current connection snapshot.
* Bearer is read from the live process store on each request (not frozen here).
* @param ctx - host context with `tools`. * @param ctx - host context with `tools`.
* @param connection - apiUrl / token / lang / timeout. * @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base).
* @returns disposer that unregisters every tool. * @returns disposer that unregisters every tool.
*/ */
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void { export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
const client = createNetxClient({ const client = createNetxClient({
apiUrl: connection.apiUrl, apiUrl: connection.apiUrl,
token: connection.token,
lang: connection.lang, lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000), timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
getToken: () => getNetxConnection()?.token ?? '',
}) })
const getClient = () => client const getClient = () => client
const t = connection.toolCallTimeoutMs const t = connection.toolCallTimeoutMs