From f0b4b6cd74e5ef5e6b6d95ccc1ec3df4b357ad6e Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 12 Oct 2026 18:52:31 +0800 Subject: [PATCH] fix(uds-auth): explain why skills get no credentials for a session (0.3.18) Skill credentials follow the DSH session owner, so a chat created under the emergency account stays owned by administrator after a QR scan and skills cannot get a UAC token there. The generic "scan QR first" error hid this. - Return session_owner_no_uac when the owner is administrator / a local account, telling the user to start a new session after QR sign-in. - Name the owner in no_skill_credentials for UAC owners. - Audit denied outbound calls with session id and owner. Co-Authored-By: Claude Sonnet 5.5 --- uds-auth/lib/agent-auth.js | 25 +++++++++--- uds-auth/lib/client.js | 6 ++- uds-auth/lib/i18n.js | 6 ++- uds-auth/package.json | 2 +- uds-auth/test/agent-auth-owner.test.js | 53 ++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 uds-auth/test/agent-auth-owner.test.js diff --git a/uds-auth/lib/agent-auth.js b/uds-auth/lib/agent-auth.js index eb7e1193..f0ed6083 100644 --- a/uds-auth/lib/agent-auth.js +++ b/uds-auth/lib/agent-auth.js @@ -158,7 +158,16 @@ export function createAgentAuthHandlers(deps) { const creds = await deps.resolveCredentialsForSession(String(sessionId)) if (!creds || String(creds.empNo) !== String(caller.empNo)) { - return { error: 'no_skill_credentials', creds: null, sessionId: String(sessionId) } + // Credentials follow the session OWNER (stamped at creation), not whoever scanned last. + // Emergency / local-admin owners never hold UAC credentials, so name the owner. + const owner = String(caller.empNo) + const noUac = owner === 'administrator' || owner.startsWith('__') + return { + error: noUac ? 'session_owner_no_uac' : 'no_skill_credentials', + vars: { owner }, + creds: null, + sessionId: String(sessionId), + } } return { error: null, creds, sessionId: String(sessionId), caller } } @@ -185,7 +194,7 @@ export function createAgentAuthHandlers(deps) { } catch (err) { return sendErr(req, res, err.statusCode || 400, err.code || 'invalid_json') } - const { error, creds, sessionId } = await resolveAuthorizedCreds(req, body, 'credentials') + const { error, creds, sessionId, vars } = await resolveAuthorizedCreds(req, body, 'credentials') if (error || !creds) { audit({ action: 'agent_credentials', @@ -194,7 +203,7 @@ export function createAgentAuthHandlers(deps) { meta: { sessionId }, }) return sendJSON(res, 401, { - ...apiError(error || 'no_skill_credentials', resolveLocale(req)), + ...apiError(error || 'no_skill_credentials', resolveLocale(req), vars), sessionId: sessionId || null, }) } @@ -220,9 +229,15 @@ export function createAgentAuthHandlers(deps) { return sendErr(req, res, 405, 'method_not_allowed') } const body = await readJsonBody(req) - const { error, creds } = await resolveAuthorizedCreds(req, body, 'outbound') + const { error, creds, vars, sessionId } = await resolveAuthorizedCreds(req, body, 'outbound') if (error || !creds) { - return sendErr(req, res, 401, error || 'no_skill_credentials') + audit({ + action: 'agent_outbound', + decision: 'deny', + reasonCode: error || 'no_skill_credentials', + meta: { sessionId, owner: vars?.owner }, + }) + return sendErr(req, res, 401, error || 'no_skill_credentials', vars) } const targetUrl = body.url diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index d916a010..a5740c92 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -156,7 +156,8 @@ window.__ModuleLoader__.load({ "err.session_workspace_only": "只能在自己的工作区创建会话", "err.workspace_path_only": "只能打开自己的工作区路径", "err.workspace_create_forbidden": "只有管理员可以创建工作区", - "err.no_skill_credentials": "请先完成 UAC 扫码登录", + "err.session_owner_no_uac": "此会话归属 {owner}(应急 / 本机账号),没有 UAC 凭证;扫码登录不会改变已有会话的归属。请在扫码登录后新建会话再使用", + "err.no_skill_credentials": "会话归属 {owner} 的 UAC 登录已失效或未保存凭证,请该账号重新扫码登录后重试", "err.loopback_only_credentials": "agent-credentials 仅允许本机访问", "err.loopback_only_outbound": "outbound 仅允许本机访问", "err.url_required": "缺少 url", @@ -313,7 +314,8 @@ window.__ModuleLoader__.load({ "err.session_workspace_only": "Sessions can only be created in your own workspace", "err.workspace_path_only": "You can only open your own workspace path", "err.workspace_create_forbidden": "Only admins can create workspaces", - "err.no_skill_credentials": "Complete UAC QR sign-in first", + "err.session_owner_no_uac": "This session is owned by {owner} (emergency / local account) which has no UAC credentials; scanning does not change who owns an existing session. Start a new session after QR sign-in", + "err.no_skill_credentials": "UAC sign-in for session owner {owner} is missing or expired; scan the QR code again as that account and retry", "err.loopback_only_credentials": "agent-credentials is loopback-only", "err.loopback_only_outbound": "outbound is loopback-only", "err.url_required": "url required", diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index 899ec8b5..5821c4dd 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -180,7 +180,8 @@ export const MESSAGES = { 'err.session_workspace_only': '只能在自己的工作区创建会话', 'err.workspace_path_only': '只能打开自己的工作区路径', 'err.workspace_create_forbidden': '只有管理员可以创建工作区', - 'err.no_skill_credentials': '请先完成 UAC 扫码登录', + 'err.session_owner_no_uac': '此会话归属 {owner}(应急 / 本机账号),没有 UAC 凭证;扫码登录不会改变已有会话的归属。请在扫码登录后新建会话再使用', + 'err.no_skill_credentials': '会话归属 {owner} 的 UAC 登录已失效或未保存凭证,请该账号重新扫码登录后重试', 'err.loopback_only_credentials': 'agent-credentials 仅允许本机访问', 'err.loopback_only_outbound': 'outbound 仅允许本机访问', 'err.url_required': '缺少 url', @@ -372,7 +373,8 @@ export const MESSAGES = { 'err.session_workspace_only': 'Sessions can only be created in your own workspace', 'err.workspace_path_only': 'You can only open your own workspace path', 'err.workspace_create_forbidden': 'Only admins can create workspaces', - 'err.no_skill_credentials': 'Complete UAC QR sign-in first', + 'err.session_owner_no_uac': 'This session is owned by {owner} (emergency / local account) which has no UAC credentials; scanning does not change who owns an existing session. Start a new session after QR sign-in', + 'err.no_skill_credentials': 'UAC sign-in for session owner {owner} is missing or expired; scan the QR code again as that account and retry', 'err.loopback_only_credentials': 'agent-credentials is loopback-only', 'err.loopback_only_outbound': 'outbound is loopback-only', 'err.url_required': 'url required', diff --git a/uds-auth/package.json b/uds-auth/package.json index 892eb67d..2d91a0e5 100644 --- a/uds-auth/package.json +++ b/uds-auth/package.json @@ -1,6 +1,6 @@ { "name": "uds-auth", - "version": "0.3.17", + "version": "0.3.18", "description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)", "type": "module", "main": "lib/index.js", diff --git a/uds-auth/test/agent-auth-owner.test.js b/uds-auth/test/agent-auth-owner.test.js new file mode 100644 index 00000000..739a2f33 --- /dev/null +++ b/uds-auth/test/agent-auth-owner.test.js @@ -0,0 +1,53 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createAgentAuthHandlers } from '../lib/agent-auth.js' + +const makeRes = () => ({ + statusCode: 0, headers: {}, body: '', + setHeader(k, v) { this.headers[k] = v }, + end(s) { this.body = s }, +}) +const loopbackReq = (payload) => ({ + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'content-type': 'application/json', 'x-dsh-session-id': 's1' }, + async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify(payload)) }, +}) + +function handlersFor(owner, creds) { + return createAgentAuthHandlers({ + resolveCredentialsForSession: async () => creds, + getSessionOwner: () => owner, + resolveCallerSession: async () => ({ empNo: owner }), + outboundHosts: ['icenterapi.zte.com.cn'], + }) +} + +test('skill outbound on an emergency-owned session names the owner and the way out', async () => { + const res = makeRes() + await handlersFor('administrator', null).handleOutbound( + loopbackReq({ url: 'https://icenterapi.zte.com.cn/x' }), res) + assert.equal(res.statusCode, 401) + const body = JSON.parse(res.body) + assert.equal(body.error, 'session_owner_no_uac') + assert.match(body.message, /administrator/) + assert.doesNotMatch(body.message, /\{owner\}/) +}) + +test('skill outbound for a UAC owner with no cached token keeps no_skill_credentials, with owner', async () => { + const res = makeRes() + await handlersFor('10329667', null).handleOutbound( + loopbackReq({ url: 'https://icenterapi.zte.com.cn/x' }), res) + assert.equal(res.statusCode, 401) + const body = JSON.parse(res.body) + assert.equal(body.error, 'no_skill_credentials') + assert.match(body.message, /10329667/) +}) + +test('credentials never cross owners: another account\'s token is refused', async () => { + const res = makeRes() + await handlersFor('10329667', { empNo: '99999999', token: 't' }).handleOutbound( + loopbackReq({ url: 'https://icenterapi.zte.com.cn/x' }), res) + assert.equal(res.statusCode, 401) + assert.equal(JSON.parse(res.body).error, 'no_skill_credentials') +})