mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 15:23:27 +08:00
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>
193 lines
7.8 KiB
JavaScript
193 lines
7.8 KiB
JavaScript
/**
|
||
* 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/)
|
||
})
|