mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 06:50:48 +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>
108 lines
6.1 KiB
JavaScript
108 lines
6.1 KiB
JavaScript
import { installGatewayEvents } from './gateway-events.js'
|
|
import { isDesktopBootstrapEndpoint } from './desktop-bootstrap.js'
|
|
// Endpoints whose resource boundaries/projections are owned by dsh-acl wrappers.
|
|
const delegated = new Set([
|
|
'session/list', 'session/search', 'session/create', 'session/selectModel', 'session/rename',
|
|
'session/fork', 'session/prompt', 'session/attachment', 'session/updateQueue', 'session/cancel',
|
|
'session/page', 'session/follow', 'session/projections', 'session/control', 'session/openWorkspacePath',
|
|
'workspace/follow', 'workspace/insertSessionBefore', 'workspace/archiveSession', 'workspace/unarchiveSession',
|
|
'workspace/pinSession', 'workspace/unpinSession', 'job/list', 'job/follow', 'job/kill',
|
|
'terminal/list', 'terminal/create', 'terminal/follow', 'terminal/retain', 'terminal/write',
|
|
'terminal/resize', 'terminal/close',
|
|
])
|
|
const catalogs = new Set(['session/modelCatalog', 'session/canOpenWorkspacePath', 'permissionPresets/catalog'])
|
|
// Anonymous mux must get dsh-acl's empty baseline, not a gateway error: a failed
|
|
// opening snapshot kills Desktop WorkspaceStateStream for good (sidebar never repaints
|
|
// after the authenticated reconnect).
|
|
const anonymousProjected = new Set(['workspace/follow'])
|
|
// That empty baseline makes Desktop try to create a default Workspace. Answer the
|
|
// anonymous attempt with "nothing created" (a legal result) instead of a denial,
|
|
// which surfaces as「无法创建默认工作区」; the real controller is never invoked.
|
|
const anonymousNoop = new Set(['workspace/initializeDefault'])
|
|
|
|
export function gatewayPolicy(descriptor) {
|
|
const endpoint = `${descriptor.namespace}/${descriptor.method}`
|
|
if (isDesktopBootstrapEndpoint(descriptor)) return 'desktop-bootstrap-projection'
|
|
if (catalogs.has(endpoint)) return 'authenticated-catalog'
|
|
if (delegated.has(endpoint)) return 'delegated-resource-acl'
|
|
if (descriptor.invocation?.kind === 'context' && descriptor.invocation.context === 'agent') return 'session-bound'
|
|
if ((descriptor.parameters || []).some(p => p.name === 'sessionId'
|
|
|| (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name)))) return 'session-bound'
|
|
return 'admin-required'
|
|
}
|
|
|
|
/** Guard every unary/stream dispatch, including new or previously unwrapped Remote namespaces. */
|
|
export function installGatewayPolicy(gateway, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap }) {
|
|
assertGatewayContract(gateway)
|
|
const live = gateway.__udsGatewayPolicy || (gateway.__udsGatewayPolicy = {})
|
|
Object.assign(live, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap })
|
|
if (live.installed) return
|
|
installGatewayEvents(gateway, live)
|
|
live.installed = true
|
|
const original = gateway.prepareInvocation.bind(gateway)
|
|
const check = (request, descriptor) => {
|
|
const principal = live.identity()
|
|
if (principal === undefined) return
|
|
const anonymous = !(principal?.empNo || principal?.userContext?.empNo)
|
|
const endpoint = `${descriptor.namespace}/${descriptor.method}`
|
|
if (anonymous && anonymousProjected.has(endpoint)) return
|
|
if (anonymous && anonymousNoop.has(endpoint) && descriptor.mode === undefined) return 'noop'
|
|
// Desktop opens its streams (account/watch, session lists, …) before the mux is
|
|
// re-authenticated. A RemoteError is terminal for the client's $stream, so the
|
|
// surface stays failed forever (e.g. account →「登录失败」). Park anonymous
|
|
// streams with no data until the authenticated reconnect closes this carrier;
|
|
// the client then reopens them under the real principal.
|
|
if (anonymous && descriptor.mode !== undefined) return 'hold'
|
|
if (isDesktopBootstrapEndpoint(descriptor) && typeof live.desktopBootstrap === 'function') {
|
|
if (principal !== null) live.assertPrincipal(principal)
|
|
if (!principal || !live.permissions(principal).canAccessSettings) return 'bootstrap'
|
|
}
|
|
live.assertPrincipal(principal)
|
|
const policy = gatewayPolicy(descriptor)
|
|
if (['admin-required', 'desktop-bootstrap-projection'].includes(policy)
|
|
&& !live.permissions(principal).canAccessSettings) live.deny('forbidden_settings')
|
|
if (policy === 'session-bound') {
|
|
const wires = []
|
|
if (descriptor.invocation?.kind === 'context') wires.push(descriptor.invocation.wire)
|
|
for (const p of descriptor.parameters || []) {
|
|
if (p.name === 'sessionId' || (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name))) wires.push(p.wire)
|
|
}
|
|
for (const wire of wires) {
|
|
const sid = request.args?.[wire]
|
|
if (typeof sid !== 'string' || !sid) live.deny('session_forbidden')
|
|
live.assertSession(sid)
|
|
}
|
|
}
|
|
}
|
|
gateway.prepareInvocation = async function(request, ...rest) {
|
|
const descriptor = gateway.resolveDescriptor(request.namespace, request.method, `${request.namespace}/${request.method}`)
|
|
check(request, descriptor)
|
|
const prepared = await original(request, ...rest)
|
|
const method = prepared.method
|
|
prepared.method = function(...args) {
|
|
const verdict = check(request, descriptor)
|
|
if (verdict === 'noop') return undefined
|
|
if (verdict === 'hold') return holdUntilAbort(prepared.invocation?.signal)
|
|
if (verdict === 'bootstrap') return live.desktopBootstrap(request)
|
|
const value = method.apply(this, args)
|
|
if (descriptor.mode === undefined) return value
|
|
return (async function* () {
|
|
const stream = await value
|
|
for await (const frame of stream) { check(request, descriptor); yield frame }
|
|
})()
|
|
}
|
|
return prepared
|
|
}
|
|
}
|
|
|
|
/** Empty stream that ends only when its carrier is aborted (no frames, no error). */
|
|
async function* holdUntilAbort(signal) {
|
|
if (!signal || signal.aborted) return
|
|
await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
|
|
}
|
|
|
|
export function assertGatewayContract(gateway) {
|
|
for (const method of ['prepareInvocation', 'resolveDescriptor', 'openRemoteEvents', 'receiveRemoteEventResult']) {
|
|
if (typeof gateway?.[method] !== 'function') throw new Error('unsupported_gateway_acl_contract')
|
|
}
|
|
}
|