Expose raw token access setting for skills

This commit is contained in:
oliver 2026-10-12 10:36:33 +08:00
parent b744483fcd
commit 70bde46eff
6 changed files with 54 additions and 11 deletions

View file

@ -16,7 +16,7 @@ Desktop 只把 bridge 附到 `dsh-app://app` 或 Host 启动时提供的 `__DSH_
## Skill、cron 和后台 shell
Host 加载 `shellEnv` 时自动注册 uds-auth contributor。每次 shell 执行根据真实 `execution.agent.session.header.id` 和服务端 owner 签发仅有 `outbound` scope 的 capability,默认 10 分钟,注入受保护的 `DSH_UDS_TASK_CAPABILITY` / `DSH_UDS_AUTH_BASE`。Python helper 优先读取这两个变量;兼容的手工 `UDS_TASK_CAPABILITY` / `UDS_AUTH_BASE` 仅在没有 Host 管理变量时使用。所有 base,包括旧 `DSH_WEB_URL`,都必须是 loopback HTTP(S)。不再猜测 8787 端口,也不允许 remote override 携带凭据。
Host 加载 `shellEnv` 时自动注册 uds-auth contributor。每次 shell 执行根据真实 `execution.agent.session.header.id` 和服务端 owner 签发 capability,默认只有 `outbound` scope;仅当管理员显式开启 `allowRawAgentToken` 时增加 `credentials` scope,以供受信 skill 调用 `/agent-credentials`。默认 10 分钟,注入受保护的 `DSH_UDS_TASK_CAPABILITY` / `DSH_UDS_AUTH_BASE`。Python helper 优先读取这两个变量;兼容的手工 `UDS_TASK_CAPABILITY` / `UDS_AUTH_BASE` 仅在没有 Host 管理变量时使用。所有 base,包括旧 `DSH_WEB_URL`,都必须是 loopback HTTP(S)。不再猜测 8787 端口,也不允许 remote override 携带凭据。
普通执行结束或取消即撤销。官方 shell 交给 jobs 的 background/promoted 执行绑定该 job,停止、结算、移除或 TTL 到期撤销;不跨任务复用。账号停用、owner 改变和 scope 不符均拒绝代理调用。长于 10 分钟的后台进程不能自动续期;应拆为新的受控调用。前台/后台都不会向 shell 注入 UAC 原始 token。需要 raw credentials 的受信 Host 调用必须显式申请 scope,且仍受 `allowRawAgentToken` 开关控制。

View file

@ -37,6 +37,8 @@ window.__ModuleLoader__.load({
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
"ui.allowRawAgentToken": "允许 Skill 获取原始 UDS token",
"ui.allowRawAgentTokenHint": "开启后,具备当前任务 credentials scope 的本机 Skill 可获取工号和 token。只在业务确实需要原始 token 时开启。",
"ui.saveConfig": "保存配置",
"ui.saving": "保存中...",
"ui.configSaved": "配置已保存",
@ -182,6 +184,8 @@ window.__ModuleLoader__.load({
"ui.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
"ui.allowRawAgentToken": "Allow Skills to read the raw UDS token",
"ui.allowRawAgentTokenHint": "Enables local Skills with the current task credentials scope to read the employee number and token. Turn this on only when a workflow needs the raw token.",
"ui.saveConfig": "Save config",
"ui.saving": "Saving...",
"ui.configSaved": "Config saved",
@ -1290,6 +1294,7 @@ function reloadAfterLogin() {
loginSystemCode: '',
originSystemCode: '',
workspaceRoot: '',
allowRawAgentToken: false,
})
const [fallbackEnabled, setFallbackEnabled] = useState(false)
const [fallbackPwd, setFallbackPwd] = useState('')
@ -1412,6 +1417,16 @@ function reloadAfterLogin() {
field('loginSystemCode', 'loginSystemCode'),
field('originSystemCode', 'originSystemCode'),
field('workspaceRoot', t('ui.workspaceRoot')),
h('label', { className: 'uds-auth-settings-toggle' },
h('input', {
type: 'checkbox',
checked: form.allowRawAgentToken === true,
disabled: !canSettings || busy,
onChange: (e) => setForm((prev) => ({ ...prev, allowRawAgentToken: e.target.checked })),
}),
h('span', null, t('ui.allowRawAgentToken')),
),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.allowRawAgentTokenHint')),
h('div', { className: 'uds-auth-settings-actions' },
h('button', {
type: 'button',

View file

@ -30,6 +30,8 @@ export const MESSAGES = {
'ui.deployConfig': '部署配置',
'ui.userSearchUrl': '用户搜索 URL(token 校验)',
'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)',
'ui.allowRawAgentToken': '允许 Skill 获取原始 UDS token',
'ui.allowRawAgentTokenHint': '开启后,具备当前任务 credentials scope 的本机 Skill 可获取工号和 token。只在业务确实需要原始 token 时开启。',
'ui.saveConfig': '保存配置',
'ui.saving': '保存中...',
'ui.configSaved': '配置已保存',
@ -215,6 +217,8 @@ export const MESSAGES = {
'ui.deployConfig': 'Deploy config',
'ui.userSearchUrl': 'User search URL (token verify)',
'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)',
'ui.allowRawAgentToken': 'Allow Skills to read the raw UDS token',
'ui.allowRawAgentTokenHint': 'Enables local Skills with the current task credentials scope to read the employee number and token. Turn this on only when a workflow needs the raw token.',
'ui.saveConfig': 'Save config',
'ui.saving': 'Saving...',
'ui.configSaved': 'Config saved',

View file

@ -436,6 +436,9 @@ async function handleQRProxy(req, res) {
lastActiveAt: now, sessionCreatedAt: now }
await ensureUserWorkspace(id)
const { bearer, bridge } = await mintLoginSession(id, userContext, 'uds', credentials.token)
// The QR flow creates its session directly instead of passing through
// authMiddleware.mintSession(), so explicitly refresh the skill token cache.
try { _skillCredentials?.set(id, credentials.token) } catch { /* cache is best-effort */ }
appendSessionCookie(req, res, bearer)
sendJSON(res, 200, { code: { code: '0000' }, bo: { code: '0000' },
auth: { success: true, empNo: id, role, bridge,
@ -531,6 +534,7 @@ async function handleConfigGet(req, res) {
retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false,
skillCredentialTtlSeconds: c.skillCredentialTtlSeconds,
outboundAllowedHosts: c.outboundAllowedHosts,
allowRawAgentToken: c.allowRawAgentToken === true,
},
}))
}
@ -1676,6 +1680,12 @@ async function initServices(ctx, config) {
const rememberSkillCreds = (empNo, token) => {
try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ }
}
const revokeInvalidUdsAccount = async (empNo) => {
await _sessionStore.revokeEmpNo(empNo)
try { _sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ }
try { _skillCredentials?.delete(empNo) } catch { /* ignore */ }
try { _taskCapabilities?.revokeEmpNo(empNo) } catch { /* ignore */ }
}
_authMiddleware = createAuthMiddleware({
get userSearchUrl() { return _currentConfig?.userSearchUrl },
@ -1705,14 +1715,15 @@ async function initServices(ctx, config) {
return
}
for (const session of sessions) {
if (!await _sessionStore.getBySessionId(session.sessionId)) continue
try {
if (!session.token || !await _authMiddleware.verifyEmpNoAndToken(session.empNo, session.token)) {
await _sessionStore.revokeSession(session.sessionId)
ctx.logger?.info?.('[uds-auth] UDS session revoked after profile validation failed for %s', session.empNo)
await revokeInvalidUdsAccount(session.empNo)
ctx.logger?.info?.('[uds-auth] UDS sessions revoked after profile validation failed for %s', session.empNo)
}
} catch (err) {
await _sessionStore.revokeSession(session.sessionId).catch(() => {})
ctx.logger?.info?.('[uds-auth] UDS session revoked after profile validation error for %s: %s', session.empNo, err?.message || err)
await revokeInvalidUdsAccount(session.empNo)
ctx.logger?.info?.('[uds-auth] UDS sessions revoked after profile validation error for %s: %s', session.empNo, err?.message || err)
}
}
}, 6 * 60 * 60 * 1000)
@ -1748,9 +1759,17 @@ async function initServices(ctx, config) {
async function resolveCredentialsForEmpNo(empNo) {
if (!empNo) return null
const row = _skillCredentials?.get(empNo)
if (!row?.token) return null
return { empNo: row.empNo, token: row.token, updatedAt: row.updatedAt }
const cached = _skillCredentials?.get(empNo)
if (cached?.token) return { empNo: cached.empNo, token: cached.token, updatedAt: cached.updatedAt }
// Older QR sessions may predate skill-cache population. Recover only from
// a currently live UDS session owned by this exact employee number.
const sessions = await _sessionStore?.listUdsSessionsForValidation?.() || []
const session = sessions
.filter(row => row.empNo === String(empNo) && row.token)
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))[0]
if (!session) return null
try { _skillCredentials?.set(session.empNo, session.token) } catch { /* cache is best-effort */ }
return { empNo: session.empNo, token: session.token, updatedAt: null }
}
async function resolveCredentialsForSession(sessionId) {
@ -1971,6 +1990,7 @@ async function initServices(ctx, config) {
const { createTaskEnvironment } = await import('./task-environment.js')
ctx.inject(['shellEnv', 'webServer'], (sctx) => {
const env = createTaskEnvironment({ getOwner: sid => _sessionAcl?.getOwner(sid),
allowRawCredentials: () => _currentConfig?.allowRawAgentToken === true,
rolesStore: _rolesStore, capabilities: _taskCapabilities,
getWebServer: () => tryGet(sctx, 'webServer') })
sctx.shellEnv.register(env.contributor)

View file

@ -289,6 +289,7 @@ export class MemoryStore extends SessionStore {
sessionId: row.sessionId,
empNo: row.empNo,
token: typeof row.userData?.token === 'string' ? row.userData.token : '',
createdAt: row.createdAt,
})
}
return sessions

View file

@ -1,5 +1,5 @@
/** Host shellEnv adapter. Values are rebuilt after ordinary environment entries. */
export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getWebServer }) {
export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getWebServer, allowRawCredentials }) {
const issued = new Map()
const release = execution => {
const key = execution?.token || execution
@ -14,7 +14,7 @@ export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getW
const contributor = {
name: 'uds-auth',
variables: {
DSH_UDS_TASK_CAPABILITY: { description: 'Short-lived session-bound UDS outbound capability; never print or persist.' },
DSH_UDS_TASK_CAPABILITY: { description: 'Short-lived session-bound UDS capability; never print or persist.' },
DSH_UDS_AUTH_BASE: { description: 'Owned loopback uds-auth endpoint of this Host.' },
},
resolve(execution) {
@ -26,9 +26,12 @@ export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getW
const port = Number(server?.port)
if (!owner || rolesStore.isDisabled(owner) || !Number.isInteger(port) || port <= 0 || port > 65535
|| execution?.signal?.aborted) return empty
const scopes = typeof allowRawCredentials === 'function' && allowRawCredentials()
? ['outbound', 'credentials']
: ['outbound']
const cap = capabilities.mint({
empNo: owner, dshSessionId: String(sid), audience: 'uds-auth-agent',
scopes: ['outbound'], ttlSeconds: 600, mintedBy: 'host:shellEnv',
scopes, ttlSeconds: 600, mintedBy: 'host:shellEnv',
})
const row = { id: cap.id, sessionId: String(sid), signal: execution.signal, abort: () => release(execution) }
issued.set(execution.token || execution, row)