fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 02:11:09 +08:00
parent fa4d5d6d97
commit df97c5e042
72 changed files with 8436 additions and 1192 deletions

View file

@ -0,0 +1,193 @@
/**
* Acceptance batch-2/3 regressions (R06–R14 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { TaskCapabilityStore } from '../lib/task-capability.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES, ACCOUNT_STATUS, LEGACY_DEFAULT_SHA256, hashPasswordScrypt, verifyPasswordHash } from '../lib/roles.js'
import { loadOrCreateSecretKey, sealSecret } from '../lib/utils/secret-box.js'
import { checkRequestOrigin } from '../lib/utils/origin-guard.js'
import { SessionAclStore } from '../lib/session-acl.js'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('R06: task capability authorizes outbound without browser session', async () => {
const caps = new TaskCapabilityStore({ defaultTtlSeconds: 600 })
const minted = caps.mint({
empNo: 'A001',
dshSessionId: 'sess-a',
scopes: ['outbound', 'credentials'],
})
const handlers = createAgentAuthHandlers({
taskCapabilities: caps,
allowRawTokenResponse: () => false,
outboundHosts: () => ['example.com'],
getSessionOwner: () => 'A001',
resolveCredentialsForSession: async () => ({ empNo: 'A001', token: 'tok' }),
resolveCallerSession: async () => null,
})
const chunks = []
const res = { statusCode: 200, setHeader() {}, end(s) { chunks.push(String(s)) } }
// credentials still blocked when raw disabled
await handlers.handleAgentCredentials({
method: 'POST',
headers: {
'x-uds-task-capability': minted.token,
'x-dsh-session-id': 'sess-a',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify({ sessionId: 'sess-a', taskCapability: minted.token }))
},
}, res)
assert.equal(res.statusCode, 403)
assert.match(chunks.join(''), /raw_token_disabled/)
// verify() binds empNo
const v = caps.verify(minted.token, { audience: 'uds-auth-agent', dshSessionId: 'sess-a' })
assert.equal(v?.empNo, 'A001')
assert.equal(caps.verify('deadbeefdeadbeefdeadbeefdeadbeef', {}), null)
})
test('R07: user-info response includes bo whitelist (source)', () => {
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(src, /bo: \[safeRow\]/)
assert.match(src, /profile:\s*\{/)
})
test('R08: nested sessionIds and workspace remove projection (source)', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /projectWorkspaceItem/)
assert.match(src, /frame\.type === 'remove'/)
assert.match(src, /projectMutationResult/)
})
test('R09: concurrent last-admin demotion keeps >=1 super_admin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-roles-'))
const file = join(dir, 'roles.json')
const store = new RolesStore({ rolesFile: file })
await store.init()
store._roles.set('a', ROLES.SUPER_ADMIN)
store._roles.set('b', ROLES.SUPER_ADMIN)
store._dirty = true
await store.flush()
const results = await Promise.allSettled([
store.setRole('a', ROLES.USER, ROLES.SUPER_ADMIN),
store.setRole('b', ROLES.USER, ROLES.SUPER_ADMIN),
])
const fulfilled = results.filter((r) => r.status === 'fulfilled').length
const rejected = results.filter((r) => r.status === 'rejected').length
assert.equal(fulfilled + rejected, 2)
assert.ok(rejected >= 1, 'at least one demote must fail')
const supers = await store.countByRole(ROLES.SUPER_ADMIN)
assert.ok(supers >= 1, `remaining super_admins=${supers}`)
})
test('R09: corrupt session owners fail closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-own-'))
const file = join(dir, 'owners.json')
await writeFile(file, '{not-json', 'utf8')
const store = new SessionAclStore({ ownersFile: file })
await store.init()
assert.equal(store.loadFailed, true)
})
test('R10: scrypt hash roundtrip; legacy default refused', () => {
const h = hashPasswordScrypt('CorrectHorseBattery')
assert.ok(h.startsWith('scrypt$'))
assert.equal(verifyPasswordHash('CorrectHorseBattery', h), true)
assert.equal(verifyPasswordHash('wrong', h), false)
assert.equal(verifyPasswordHash('Admin@123', LEGACY_DEFAULT_SHA256), false)
})
test('R10: setFallbackPassword rejects short and known default', async () => {
const store = new RolesStore({})
store._roles.set('admin', ROLES.SUPER_ADMIN)
await assert.rejects(() => store.setFallbackPassword('short', ROLES.SUPER_ADMIN), /password_too_short/)
await assert.rejects(() => store.setFallbackPassword('Admin@123', ROLES.SUPER_ADMIN), /password_too_common/)
await store.setFallbackPassword('LongEnoughPass1', ROLES.SUPER_ADMIN)
assert.equal(store.verifyFallback('LongEnoughPass1', '127.0.0.1'), true)
})
test('R11: invalid env key fails closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key-'))
const prev = process.env.UDS_AUTH_SECRET_KEY
process.env.UDS_AUTH_SECRET_KEY = 'not-hex'
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'invalid_secret_key_env')
} finally {
if (prev === undefined) delete process.env.UDS_AUTH_SECRET_KEY
else process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R11: corrupt key file is not replaced', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key2-'))
await writeFile(join(dir, '.uds-auth-secret-key'), 'garbage\n', 'utf8')
const prev = process.env.UDS_AUTH_SECRET_KEY
delete process.env.UDS_AUTH_SECRET_KEY
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'secret_key_corrupt')
const still = await readFile(join(dir, '.uds-auth-secret-key'), 'utf8')
assert.match(still, /garbage/)
} finally {
if (prev !== undefined) process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R12: disabled account cannot be ensureUser / open-register revived', async () => {
const store = new RolesStore({})
store._roles.set('u1', ROLES.USER)
store._roles.set('admin', ROLES.SUPER_ADMIN)
await store.removeUser('u1', ROLES.SUPER_ADMIN)
assert.equal(store.isDisabled('u1'), true)
assert.equal(store.getAccountStatus('u1'), ACCOUNT_STATUS.DISABLED)
await assert.rejects(() => store.ensureUser('u1', ROLES.SUPER_ADMIN), /account_disabled/)
})
test('R13: trustedHosts rejects unlisted Host', () => {
const req = {
method: 'POST',
headers: {
host: 'evil.example:8787',
origin: 'https://evil.example:8787',
},
socket: { remoteAddress: '1.2.3.4' },
}
const denied = checkRequestOrigin(req, { trustedHosts: ['good.example:8787'] })
assert.equal(denied.ok, false)
assert.equal(denied.reason, 'host_not_trusted')
const ok = checkRequestOrigin({
method: 'POST',
headers: { host: 'good.example:8787', origin: 'https://good.example:8787' },
socket: { remoteAddress: '1.2.3.4' },
}, { trustedHosts: ['good.example:8787'] })
assert.equal(ok.ok, true)
})
test('R13/R14: QR challenge store + live identity patch + docs (source)', () => {
const index = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /consumeQrChallenge/)
assert.match(index, /MAX_QR_UPSTREAM_BYTES/)
assert.match(index, /mintTaskCapability/)
const acl = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(acl, /__udsAuthIdentityLive/)
const readme = readFileSync(join(root, 'README.zh.md'), 'utf8')
assert.doesNotMatch(readme, /Admin@123.*首次启动自动启用/)
assert.match(readme, /mintTaskCapability|UDS_TASK_CAPABILITY/)
})
test('R06 python helper mentions task capability', () => {
const py = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8')
assert.match(py, /UDS_TASK_CAPABILITY/)
assert.match(py, /X-UDS-Task-Capability/)
})