From 55f8975c3d93f27ef242a6c9d859c7b0d68986eb Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:25:15 +0800 Subject: [PATCH] Bind SDK authentication feedback to the actual resource profile Fix #2398 on the delivery branch. Preserve typed SDK authentication failures and existing authRequired semantics, bind readiness and setup to stable profiles, and use the resource SDK CLI for login without copying credentials or changing Task-role context. --- PROJECT.md | 6 + README.md | 3 + README.zh.md | 3 + ROADMAP.md | 5 + changelog.d/2398.fixed.md | 1 + core/src/host/project-chief-control.js | 2 +- core/src/host/project-harness-auth.js | 32 +++ core/src/host/project-harness-turn.js | 10 +- core/src/host/project-harness-worker.mjs | 2 +- core/src/host/project-harness.js | 31 ++- core/src/host/project-resources.js | 15 +- core/src/host/project-runtime-setup.js | 57 +++-- core/src/host/project-service-views.js | 2 +- core/test/project-delivery.test.js | 23 +- core/test/project-harness-auth-sdk.test.js | 37 +++ core/test/project-harness-auth.test.js | 27 +++ core/test/project-harness-turn.test.js | 14 ++ core/test/project-harness.test.js | 34 ++- core/test/project-runtime-setup.test.js | 36 +++ .../2026-11-04-resource-sdk-authentication.md | 16 ++ docs/backend-operations.md | 28 +++ docs/openapi/flybot.openapi.json | 222 ++++++++++++++++-- docs/subsystems/backend-service.md | 7 + docs/subsystems/core-host.md | 7 + docs/subsystems/project-agents.md | 7 + docs/subsystems/project-harness.md | 12 + docs/subsystems/project-resources.md | 8 + docs/subsystems/project-runtime-setup.md | 10 + docs/subsystems/project-service-contracts.md | 7 + .../files/src/host/project-chief-control.js | 2 +- .../files/src/host/project-harness-auth.js | 32 +++ .../files/src/host/project-harness-turn.js | 10 +- .../files/src/host/project-harness-worker.mjs | 2 +- .../ds-bot/files/src/host/project-harness.js | 31 ++- .../files/src/host/project-resources.js | 15 +- .../files/src/host/project-runtime-setup.js | 57 +++-- .../files/src/host/project-service-views.js | 2 +- .../files/test/project-delivery.test.js | 23 +- .../test/project-harness-auth-sdk.test.js | 37 +++ .../files/test/project-harness-auth.test.js | 27 +++ .../files/test/project-harness-turn.test.js | 14 ++ .../ds-bot/files/test/project-harness.test.js | 34 ++- .../files/test/project-runtime-setup.test.js | 36 +++ scripts/api-contracts.mjs | 14 ++ 44 files changed, 877 insertions(+), 123 deletions(-) create mode 100644 changelog.d/2398.fixed.md create mode 100644 core/src/host/project-harness-auth.js create mode 100644 core/test/project-harness-auth-sdk.test.js create mode 100644 core/test/project-harness-auth.test.js create mode 100644 decisions/2026-11-04-resource-sdk-authentication.md create mode 100644 patches/ds-bot/files/src/host/project-harness-auth.js create mode 100644 patches/ds-bot/files/test/project-harness-auth-sdk.test.js create mode 100644 patches/ds-bot/files/test/project-harness-auth.test.js diff --git a/PROJECT.md b/PROJECT.md index 24ddbcd53..566a7f810 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -8,6 +8,12 @@ architecture lives in [docs/architecture.md](docs/architecture.md) and Current stage and priorities: [ROADMAP.md](ROADMAP.md). How decisions are made: [docs/governance.md](docs/governance.md). Dated decisions: [decisions/](decisions/README.md). +## SDK authentication — 2026-11-04 + +#2398 binds readiness and setup to the existing resource profile, preserving its +paths and Task-role context. Known SDK authentication failures reach authRequired +without becoming code verdicts. No OAuth broker, credential copying or live login. + ## Role App delivery — 2026-11-04 #2365 opts Project roles into explicit App connections and dedicated short-lived diff --git a/README.md b/README.md index 0e2c768a9..d22e828df 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,6 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, 项目角色可选择以独立 GitHub App 直接提交、开 Draft PR 和 Review;复用现有连接与资源配置。 见 [角色 App 配置、认证与恢复](docs/role-github-apps.md)。默认不启用,不需要全局 gh 登录。 + +SDK 鉴权失效请使用该 Resource 的配置终端处理;宿主登录不会自动同步到容器。 +查看[登录态与资源 profile](docs/backend-operations.md#sdk登录态与资源profile),无需为每个角色/Task重新登录。 diff --git a/README.zh.md b/README.zh.md index f9be76e1d..09c31f593 100644 --- a/README.zh.md +++ b/README.zh.md @@ -95,3 +95,6 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, 项目角色可选择以独立 GitHub App 直接提交、开 Draft PR 和 Review;复用现有连接与资源配置。 见 [角色 App 配置、认证与恢复](docs/role-github-apps.md)。默认不启用,不需要全局 gh 登录。 + +SDK 鉴权失效请使用该 Resource 的配置终端处理;宿主登录不会自动同步到容器。 +查看[登录态与资源 profile](docs/backend-operations.md#sdk登录态与资源profile),无需为每个角色/Task重新登录。 diff --git a/ROADMAP.md b/ROADMAP.md index fce097266..24ca1c21e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # FlyBot — Roadmap +## Resource authentication reliability — 2026-11-04 + +#2398: existing profile binding, correct SDK auth feedback and same-profile setup; +source/fixture acceptance does not prove a live OAuth cache has been repaired. + ## Role App execution — 2026-11-04 #2365 targets delivery with trusted role binding, local/Docker credential helpers, diff --git a/changelog.d/2398.fixed.md b/changelog.d/2398.fixed.md new file mode 100644 index 000000000..9ec70b176 --- /dev/null +++ b/changelog.d/2398.fixed.md @@ -0,0 +1 @@ +Bind SDK readiness and configuration terminals to the actual persistent resource profile, expose safe authentication failures such as reused refresh tokens, and make the SDK-resolved Codex CLI available for login. Preserve Task-role context and delivery evidence; do not copy credentials or automatically choose a recovery route. diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index a74109db9..012acaf5d 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -136,7 +136,7 @@ export function createProjectChiefControl(rt) { const claim = task.collaboration?.claim, worker = rt.state.projectHarnessWorkers?.[claim?.workerId] return { taskId: task.id, chiefActionIssue: task.chiefActionIssue ?? null, chiefDecisionIssue: task.chiefDecisionIssue ?? null, title: task.title, criteria: task.criteria ?? '', projectTaskKind: task.projectTaskKind ?? 'code', status: task.status, taskContract: task.projectTask ? { version: task.projectTask.version, admission: { status: task.projectTask.admission.status, ...(task.projectTask.admission.failure ? { failure: structuredClone(task.projectTask.admission.failure) } : {}) }, requirements: structuredClone(task.projectTask.requirements), wait: structuredClone(task.projectTask.wait ?? null), dependencies: task.projectTask.dependencies.map(row => ({ ...structuredClone(row), current: dependencyFact(rt.state, task, row.taskId) })) } : null, allocationProof: allocationProof(rt.state, task), - execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, + execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, profile: worker?.profile ?? null, authenticationFailure: worker?.authenticationFailure ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, handoff: task.collaboration?.history.at(-1) ? { id: task.collaboration.history.at(-1).id, purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, block: task.collaboration?.block ?? null, ...inspectionResources(rt.state, task), environment: { preparations: Object.values(attempt?.workspaces ?? {}).filter(row => row.environmentPreparation).slice(-6).map(row => ({ id: row.environmentPreparation.id, role: row.role, executor: row.environmentPreparation.executor, status: row.environmentPreparation.status, sha256: row.environmentPreparation.sha256 })), observations: Object.values(rt.state.projectHarnessWorkers ?? {}).filter(row => row.attemptId === attempt?.id && row.projectId === task.projectId && row.taskId === task.id).flatMap(row => (row.environmentObservations ?? []).map(value => ({ ...value, workerId: row.id, role: row.role, resourceId: row.resourceId }))).slice(-8) }, diff --git a/core/src/host/project-harness-auth.js b/core/src/host/project-harness-auth.js new file mode 100644 index 000000000..adfe2f302 --- /dev/null +++ b/core/src/host/project-harness-auth.js @@ -0,0 +1,32 @@ +// Model login belongs to an explicit resource profile, not a Task-role Session. +import { createHash } from 'node:crypto' +import { isAbsolute, join } from 'node:path' +const ownEntry = (object, key) => Object.hasOwn(object, key) ? object[key] : undefined +const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const legacyProfileHash = value => createHash('sha256').update(value).digest('hex') +export function harnessProfile(resource, project, resourceId, harness) { + const principalId = project.ownerUserId + const configured = ownEntry(ownEntry(resource.profiles ?? {}, principalId) ?? {}, harness) + const generated = harness !== 'flybot' && isAbsolute(resource.profileRoot ?? '') ? { home: join(resource.profileRoot, legacyProfileHash(`${project.id}\n${principalId}`).slice(0, 24), harness), configDir: join(resource.profileRoot, legacyProfileHash(`${project.id}\n${principalId}`).slice(0, 24), harness, 'config') } : null + const selected = configured ?? generated + if (!selected || !isAbsolute(selected.home ?? '') || !isAbsolute(selected.configDir ?? '')) throw Object.assign(Error('Resource Harness needs an explicitly authorized principal/profile and model'), { code: 'bot/harness' }) + const profile = { id: hash([resourceId, resource.type, resource.container ?? null, resource.user ?? null, resource.runtimeDir ?? null, principalId, harness, selected.home, selected.configDir, selected.baseUrl ?? null, selected.envRefs ?? {}]), source: configured ? 'configured' : 'generated', resourceId, principalId, harness } + return { selected, principalId, profile } +} +const codes = new Set(['refresh_token_reused', 'refresh_token_expired', 'refresh_token_invalidated', 'invalid_refresh_token', 'invalid_grant', 'invalid_api_key', 'authentication_required', 'authentication_error']) +export function sdkAuthenticationFailure(value, harness) { + const error = value?.error && typeof value.error === 'object' ? value.error : value + let code = error?.code + const message = typeof error?.message === 'string' ? error.message.slice(0, 16000).toLowerCase() : '' + if (!codes.has(code)) { + code = [...codes].find(item => message.includes(item)) + if (!code && /refresh token.{0,160}(?:already.{0,20}used|reused)/s.test(message)) code = 'refresh_token_reused' + if (!code && /refresh token.{0,80}(?:expired|invalidated|revoked)/s.test(message)) code = 'invalid_refresh_token' + if (!code && /(?:not logged in|please (?:log|sign) in|invalid (?:api key|x-api-key)|incorrect api key)/.test(message)) code = 'authentication_required' + if (!code && (error?.status === 401 || error?.statusCode === 401)) code = 'authentication_required' + } + return codes.has(code) ? { harness, code, reauthenticationRequired: true } : null +} +export function authenticationError(failure) { + return Object.assign(Error(`${failure.harness} authentication requires configuration on the selected resource profile (${failure.code})`), { code: 'bot/harness-auth', authenticationFailure: failure }) +} diff --git a/core/src/host/project-harness-turn.js b/core/src/host/project-harness-turn.js index b37743e92..256d344f0 100644 --- a/core/src/host/project-harness-turn.js +++ b/core/src/host/project-harness-turn.js @@ -1,7 +1,8 @@ +import { sdkAuthenticationFailure, authenticationError } from './project-harness-auth.js' import { fileURLToPath } from 'node:url' import { validateCodexConfiguration } from './project-codex-config.js' // One real SDK turn. The host owns Workflow completion and acceptance, never this driver. -export async function runHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { +async function executeHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { const toolsDisabled = request.noTools || request.protocolCorrection === true let text = '', completed = false, sessionId = request.sessionId ?? null, usage = null if (request.harness === 'codex') { @@ -33,6 +34,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const { events } = await thread.runStreamed(request.prompt, { signal }) for await (const event of events) { if (event.type === 'thread.started' && (request.sessionId && event.thread_id !== request.sessionId || request.forbiddenSessionId && event.thread_id === request.forbiddenSessionId)) throw Object.assign(new Error('Codex resumed a different Task-role thread'), { code: 'bot/context-resume' }) + if (['turn.failed', 'error'].includes(event.type)) { const auth = sdkAuthenticationFailure(event, request.harness); if (auth) throw authenticationError(auth) } await emit({ type: 'sdk-event', harness: request.harness, event }) if (event.type === 'thread.started') sessionId = event.thread_id if (event.type === 'item.completed' && event.item?.type === 'agent_message') text = event.item.text @@ -60,6 +62,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida try { for await (const event of stream) { if (event.session_id && (request.sessionId && event.session_id !== request.sessionId || request.forbiddenSessionId && event.session_id === request.forbiddenSessionId)) throw Object.assign(new Error('Claude returned an incompatible Task-role Session'), { code: 'bot/context-resume' }) + if (event.type === 'result' && event.is_error) { const auth = sdkAuthenticationFailure({ message: event.result, code: event.error }, request.harness); if (auth) throw authenticationError(auth) } await emit({ type: 'sdk-event', harness: request.harness, event }) if (event.session_id) sessionId = event.session_id if (event.type === 'result') { @@ -72,3 +75,8 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida if (!completed || !sessionId || signal?.aborted) throw new Error('SDK stream ended without a completed turn and bound Session') return { text, sessionId, usage } } + +export async function runHarnessTurn(request, ports) { + try { return await executeHarnessTurn(request, ports) } + catch (error) { const auth = sdkAuthenticationFailure({ code: error?.authenticationFailure?.code }, request.harness) ?? sdkAuthenticationFailure(error, request.harness); if (auth) throw authenticationError(auth); throw error } +} diff --git a/core/src/host/project-harness-worker.mjs b/core/src/host/project-harness-worker.mjs index e446407c5..de837d66b 100644 --- a/core/src/host/project-harness-worker.mjs +++ b/core/src/host/project-harness-worker.mjs @@ -37,7 +37,7 @@ async function turn(message) { busy = false write(response) if (settings.probe) void shutdown() - } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } + } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), ...(error?.code === 'bot/harness-auth' ? { authenticationFailure: error.authenticationFailure } : {}), reason: ['bot/harness-capability', 'bot/context-resume', 'bot/harness-auth'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } finally { busy = false } } input.on('line', line => { diff --git a/core/src/host/project-harness.js b/core/src/host/project-harness.js index fe54539cc..4094c622c 100644 --- a/core/src/host/project-harness.js +++ b/core/src/host/project-harness.js @@ -1,10 +1,11 @@ +import { harnessProfile, sdkAuthenticationFailure, authenticationError } from './project-harness-auth.js' import { createRoleGithub } from './project-role-github.js' import { roleGithubRuntime } from './project-role-github-runtime.mjs' // Harness worker lifecycle plugs into the existing Workflow. It never accepts a Task. import { randomUUID } from 'node:crypto' import { createHash } from 'node:crypto' import { mkdir, appendFile, readFile, copyFile, writeFile } from 'node:fs/promises' -import { isAbsolute, join, resolve, relative, dirname } from 'node:path' +import { join, resolve, relative, dirname } from 'node:path' import { createInterface } from 'node:readline' import { verifyRolePolicy, roleExecutionPrompt } from './project-role-policy.js' import { sdkContinuationArchive } from './project-session-continuation.js' @@ -46,11 +47,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { rt.projectRoleGithub = github async function profile(projectId, resourceId, harness, { forSetup = false } = {}) { const resource = rt.projectResources.setting(projectId, resourceId) - const principalId = rt.readProject(projectId).ownerUserId - const configured = ownEntry(ownEntry(resource.profiles ?? {}, principalId) ?? {}, harness) - const generated = isAbsolute(resource.profileRoot ?? '') ? { home: join(resource.profileRoot, sha(`${projectId}\n${principalId}`).slice(0, 24), harness), configDir: join(resource.profileRoot, sha(`${projectId}\n${principalId}`).slice(0, 24), harness, 'config') } : null - const selected = configured ?? (harness === 'flybot' ? null : generated) - if (!selected || !isAbsolute(selected.home ?? '') || !isAbsolute(selected.configDir ?? '')) throw refuse('Resource Harness needs an explicitly authorized principal/profile and model') + const { selected, principalId, profile } = harnessProfile(resource, rt.readProject(projectId), resourceId, harness) const env = { PATH: `${resource.nodePath || resource.type === 'local' ? dirname(resource.nodePath ?? process.execPath) + ':' : ''}/usr/local/bin:/usr/bin:/bin`, LANG: 'C.UTF-8', HOME: selected.home, ...(harness === 'flybot' ? { DSH_HOME: selected.configDir } : harness === 'codex' ? { CODEX_HOME: selected.configDir } : { CLAUDE_CONFIG_DIR: selected.configDir, API_TIMEOUT_MS: '30000', CLAUDE_CODE_MAX_RETRIES: '1', CLAUDE_AGENT_SDK_CLIENT_APP: 'flybot' }) } if (resource.type === 'local' && !forSetup) { @@ -70,7 +67,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { if (!/^(?:OPENAI_API_KEY|CODEX_API_KEY|ANTHROPIC_API_KEY|ANTHROPIC_BASE_URL|ANTHROPIC_AUTH_TOKEN)$/.test(name) || typeof reference !== 'string' || !process.env[reference]) throw refuse('Explicit Harness credential reference is unavailable') env[name] = process.env[reference] } - return { resource, principalId, selected, env } + return { resource, principalId, selected, profile, env } } function redact(value, env) { let text = JSON.stringify(value) @@ -152,7 +149,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { const conversation = chief ? ownEntry(rt.state.projectAgents ?? {}, chief.agentId) : null const row = { id, ...(conversation?.roomId ? { roomId: conversation.roomId } : {}), model: session.execution.model ?? selected.selected.model ?? null, chiefRunId: chief?.runId ?? null, chiefInvocation: chief?.runId ? rt.state.projectExecutions?.[chief.runId]?.invocation ?? null : null, visibility: conversation?.visibility ?? 'project', ...(conversation?.visibility === 'private' ? { memberId: conversation.memberId } : {}), projectId: session.projectId, taskId: session.taskId, attemptId: session.attemptId, role: session.role, policyRole: session.policyRole ?? session.definition?.policyRole ?? session.role, sessionKey: session.id, resourceId: session.resourceLease.resourceId, leaseId: session.resourceLease.id, generation: session.resourceLease.generation, - harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, + harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, profile: selected.profile, cwd: workspace.path, scratchDirectory, dependencyPreparationId: session.environmentPreparation?.id ?? null, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } if (logical) Object.assign(row, { logicalSessionId: logical.id, bindingKey, bindingVersion: 'task-role-binding/v3', contextIdentityKey: backendContextKey({ ...identity, definition: definitionKey }), definitionKey, ...(previous ? { ...(previous.restoration?.mode === 'reconstructed' ? {} : { backendSessionId: previous.sessionId }), ...(previous.restoration ? { restoration: previous.restoration } : {}), continuedFrom: previous.workerId, ...(previous.key !== bindingKey ? { migratedBindingKey: previous.key } : {}) } : { rotationReason: 'First Task-role execution' }) }) else if (chief) Object.assign(row, { logicalSessionId: chief.agentId, bindingKey, ...(chief.resumeSessionId ? { backendSessionId: chief.resumeSessionId, continuedFrom: chief.previousWorkerId } : {}) }) @@ -219,7 +216,9 @@ export function createProjectHarness(rt, { roleGithub } = {}) { const currentLease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) if (active.get(id) !== control || row.status === 'released' || currentLease?.generation !== row.generation || currentLease.status === 'released') return if (line.length > 1024 * 1024) throw refuse('SDK event exceeds maximum size') - const event = github.redact(redact(JSON.parse(line), control.env)) + let event = github.redact(redact(JSON.parse(line), control.env)) + const authentication = event.type === 'failure' && sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, row.harness) + if (authentication) event = { type: 'failure', ...(event.turnId ? { turnId: event.turnId } : {}), authenticationFailure: authentication, reason: authenticationError(authentication).message } if (event.type === 'github-auth-request') { if (!control.githubScope || control.correctionTurnId || !/^[a-f0-9-]{36}$/.test(event.id ?? '')) throw refuse('Unexpected role credential request') row.githubAccess.requests = (row.githubAccess.requests ?? 0) + 1 @@ -321,11 +320,21 @@ export function createProjectHarness(rt, { roleGithub } = {}) { if (event.type === 'configuration-checked' && event.harness === 'codex' && event.capability?.kind === 'codex-cli-config/v1' && event.capability.version === VERSIONS.codex && event.capability.modelInvoked === false) row.configurationCheck = { kind: event.capability.kind, version: event.capability.version, profile: event.capability.profile === 'chief-read-only' ? 'chief-read-only' : 'flybot-role', at: Date.now(), modelInvoked: false } if (event.type === 'failure') { row.status = 'failed' + const auth = sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, row.harness) + if (auth) { + row.authenticationFailure = auth + let currentProfile + try { currentProfile = harnessProfile(rt.projectResources.setting(row.projectId, row.resourceId), rt.readProject(row.projectId), row.resourceId, row.harness).profile } catch { /* Retain the worker failure without overwriting a removed/revoked profile's health. */ } + if (currentProfile?.id === row.profile.id) { + rt.state.projectHarnessHealth ??= {} + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'authRequired', reason: `${row.harness} authentication requires configuration on the selected resource profile (${auth.code})`, authenticationFailure: auth, profile: row.profile, principalId: row.principalId, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) + } + } if (event.contextFailure?.code === 'bot/context-resume' && event.contextFailure.kind === 'backend-session-mismatch') row.contextFailure = { code: 'bot/context-resume', kind: 'backend-session-mismatch', contextPreserved: true } if (event.capabilityFailure?.code === 'bot/harness-capability' && event.capabilityFailure.stage === 'configuration' && event.capabilityFailure.modelInvoked === false && ['missing-cli', 'unsupported-cli-version', 'unsupported-platform', 'cli-unavailable', 'cli-configuration', 'legacy-sandbox-conflict', 'permission-profile-mismatch'].includes(event.capabilityFailure.kind)) { row.capabilityFailure = event.capabilityFailure rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', reason: event.reason, capabilityFailure: event.capabilityFailure, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', profile: row.profile, reason: event.reason, capabilityFailure: event.capabilityFailure, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) } await report(row, false, event.reason) } @@ -340,7 +349,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { catch (error) { row.failure = { reason: error.message, at: Date.now() } rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', reason: error.message, sdkVersion: control.observedSdkVersion ?? null, expectedSdkVersion: VERSIONS[row.harness], principalId: row.principalId, at: Date.now(), resourceId: row.resourceId, harness: row.harness }) + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', profile: row.profile, reason: error.message, sdkVersion: control.observedSdkVersion ?? null, expectedSdkVersion: VERSIONS[row.harness], principalId: row.principalId, at: Date.now(), resourceId: row.resourceId, harness: row.harness }) await release(id); throw error } finally { clearTimeout(handshakeTimer) } diff --git a/core/src/host/project-resources.js b/core/src/host/project-resources.js index 196380026..823b45063 100644 --- a/core/src/host/project-resources.js +++ b/core/src/host/project-resources.js @@ -1,3 +1,4 @@ +import { harnessProfile } from './project-harness-auth.js' // Operator-provisioned execution capabilities; Project configuration only references IDs. import { nativeExitConfirmed } from './project-native-sessions.js' import { captureProcessScope, priorScopeExited, supervisorProofMatches } from './project-process-proof.js' @@ -40,13 +41,21 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (lease.bindingHash && bindingHash(item) !== lease.bindingHash) throw failure('Execution resource binding changed; migration is not permitted') return item } + function harnessHealth(projectId, resourceId, harness) { + const saved = ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${resourceId}:${harness}`) + if (harness === 'flybot') return saved ?? { status: 'native-host-provider' } + let profile + try { profile = harnessProfile(setting(projectId, resourceId), rt.readProject(projectId), resourceId, harness).profile } + catch { return { status: 'unconfigured', reason: 'Explicit SDK profile is unavailable', at: null } } + return saved?.profile?.id === profile.id ? saved : { status: 'unconfigured', reason: 'Selected SDK profile has not been checked; open its resource configuration terminal', profile, at: null } + } function list(projectId, actor) { return Object.keys(resources()).flatMap(id => { try { const item = setting(projectId, id) const observation = ownEntry(state.projectResourceHealth ?? {}, id) return [{ id, type: item.type, canManage: Boolean(actor && item.adminMemberIds?.includes(actor.memberId)), capacity: { configured: item.capacity, occupied: occupied(id) }, harnesses: item.harnesses ?? [], - harnessStatus: (item.harnesses ?? []).map(harness => ({ harness, ...(ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${id}:${harness}`) ?? { status: harness === 'flybot' ? 'native-host-provider' : 'unconfigured' }) })), + harnessStatus: (item.harnesses ?? []).map(harness => ({ harness, ...harnessHealth(projectId, id, harness) })), health: observation ?? { status: 'unconfigured', reason: 'Environment has not been probed', at: null }, metrics: { cpu: null, memory: null, basis: 'unavailable' }, platformInstanceId: item.platformInstanceId ?? null }] } catch { return [] /* Unauthorized/malformed resources are not enumerable. */ } @@ -64,7 +73,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (!candidates.length) throw failure('No compatible execution resource in the selected pool') const available = candidates.filter(({ id, item }) => { const health = ownEntry(state.projectResourceHealth ?? {}, id) - const auth = ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${id}:${harness}`) + const auth = harnessHealth(projectId, id, harness) return health?.status === 'ready' && (harness === 'flybot' || request.purpose === 'probe' || auth?.status === 'ready') && usage(id) < item.capacity }) if (!available.length) throw failure('Execution resource capacity or readiness is unavailable') @@ -352,7 +361,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} state.projectResourceHealth ??= {}; setEntry(state.projectResourceHealth, resourceId, row); await rt.save() return structuredClone(row) } - return { setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, + return { harnessHealth, setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, preparationUncertain: reference => serial(() => preparationUncertain(reference)), beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), reserve: request => serial(() => reserve(request)), markStarted: (...args) => serial(() => markStarted(...args)), release: (...args) => serial(() => release(...args)) } } diff --git a/core/src/host/project-runtime-setup.js b/core/src/host/project-runtime-setup.js index ccbd9c1e5..22bb4bf87 100644 --- a/core/src/host/project-runtime-setup.js +++ b/core/src/host/project-runtime-setup.js @@ -1,7 +1,9 @@ +import { harnessProfile, sdkAuthenticationFailure } from './project-harness-auth.js' // Owner setup: bounded connectivity probes and private target-resource PTYs. import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' -import { join, resolve } from 'node:path' +import { join, resolve, dirname } from 'node:path' +import { pathToFileURL } from 'node:url' import { ownEntry, setEntry } from './store.js' import { createPluginModel } from './plugin-model.js' const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) @@ -25,7 +27,7 @@ export function createProjectRuntimeSetup(rt) { if (!model) return { status: 'unconfigured', reason: 'Set an explicit SDK model on this role/profile' } const id = `probe-${randomUUID()}`, cwd = join(resource.root, 'connection-probes', id) const lease = await rt.projectResources.reserve({ projectId: project.id, attemptId: id, sessionKey: id, role: 'probe', harness: body.harness, resourceIds: [body.resourceId], purpose: 'probe' }) - let child, exited = false, stopped = false, completion = false, authError = false, observedSdkVersion = null, readyObserved = false, startupFailed = false + let child, exited = false, stopped = false, completion = false, authError = false, authenticationFailure = null, observedSdkVersion = null, readyObserved = false, startupFailed = false try { await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['mkdir', '-p', cwd] }) await rt.projectResources.command(project.id, body.resourceId, { cwd, argv: ['git', 'init', '-q'] }) @@ -46,7 +48,7 @@ export function createProjectRuntimeSetup(rt) { if (event.type === 'ready') { readyObserved = true; const version = event.sdk?.[body.harness]; observedSdkVersion = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/.test(version ?? '') ? version : null; handshake() } if (event.type === 'stopped') stopped = event.released === true if (event.type === 'completed') { completion = true; finish() } - if (event.type === 'failure') finish() + if (event.type === 'failure') { authenticationFailure = sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, body.harness); if (authenticationFailure) authError = true; finish() } if (/401|403|api.key|authentication|unauthorized|login required/i.test(line)) authError = true } catch { /* Malformed probe output is not successful authentication. */ } }) @@ -59,21 +61,24 @@ export function createProjectRuntimeSetup(rt) { ...(selected.selected.baseUrl ? { baseUrl: selected.selected.baseUrl } : {}) }, prompt: 'Reply ready. Do not use tools.' }) + '\n') await Promise.race([finished, closed, delay(10_000)]) if (!exited) { child.stdin.end(JSON.stringify({ type: 'stop' }) + '\n'); await Promise.race([closed, delay(6000)]) } - return { status: completion && exited && stopped ? 'ready' : authError ? 'authRequired' : 'environmentError', reason: completion && exited && stopped ? 'Minimal SDK inference completed; this is not task execution' : authError ? 'SDK authentication is required or was rejected' : 'SDK/runtime probe did not complete within its bound', model, sdkVersion: observedSdkVersion } + return { profile: selected.profile, ...(authenticationFailure ? { authenticationFailure } : {}), status: completion && exited && stopped ? 'ready' : authError ? 'authRequired' : 'environmentError', reason: completion && exited && stopped ? 'Minimal SDK inference completed; this is not task execution' : authError ? `SDK authentication requires configuration on the selected resource profile${authenticationFailure ? ' (' + authenticationFailure.code + ')' : ''}` : 'SDK/runtime probe did not complete within its bound', model, sdkVersion: observedSdkVersion } } finally { await rt.projectResources.release(lease.id, lease.generation, ...(child ? [{ handle: id, exited: exited && (stopped || startupFailed) }] : [])) if (exited && (stopped || startupFailed) || !child) await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['rm', '-rf', '--', cwd] }).catch(() => {}) // Owned probe directory only; failed cleanup is not auth success. } } - async function check(body, actor) { + async function check(body, actor, { fresh = false } = {}) { const { project, resource } = await authorized(body, actor) const key = `${project.id}:${body.resourceId}:${body.harness}` - if (checks.has(key)) return checks.get(key) - const previous = ownEntry(rt.state.projectHarnessHealth ?? {}, key) - if (previous && Date.now() - previous.at < 3000 && previous.status !== 'checking') return structuredClone(previous) + let profile = null + try { if (body.harness !== 'flybot') profile = harnessProfile(resource, project, body.resourceId, body.harness).profile } catch { /* The existing SDK configuration error result below owns incomplete profiles. */ } + const checkKey = key + ':' + (profile?.id ?? 'native-or-unconfigured') + if (checks.has(checkKey)) return checks.get(checkKey) + const previous = rt.projectResources.harnessHealth(project.id, body.resourceId, body.harness) + if (!fresh && previous && Date.now() - previous.at < 3000 && previous.status !== 'checking') return structuredClone(previous) const promise = (async () => { rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, key, { status: 'checking', at: Date.now(), principalId: project.ownerUserId }) + setEntry(rt.state.projectHarnessHealth, key, { status: 'checking', profile, at: Date.now(), principalId: project.ownerUserId }) await rt.save() let result const environment = await rt.projectResources.probe(project.id, body.resourceId) @@ -89,10 +94,14 @@ export function createProjectRuntimeSetup(rt) { try { result = await probeSDK(body, project, resource) } catch (failure) { result = { status: failure.code === 'bot/resource' ? 'unreachable' : failure.code === 'bot/harness' ? 'unconfigured' : 'environmentError', reason: failure.code === 'bot/resource' ? 'Resource capacity or environment is unavailable' : 'Explicit SDK profile, model or runtime configuration is incomplete' } } } - const row = { ...result, at: Date.now(), principalId: project.ownerUserId, sdkVersion: result.sdkVersion ?? null, expectedSdkVersion: rt.projectHarness.versions[body.harness] ?? null, resourceId: body.resourceId, harness: body.harness } + await authorized(body, actor) + let currentProfile = null + try { if (body.harness !== 'flybot') currentProfile = harnessProfile(rt.projectResources.setting(project.id, body.resourceId), rt.readProject(project.id), body.resourceId, body.harness).profile } catch { /* A changed/removed profile cannot inherit the old probe outcome. */ } + if (profile?.id !== currentProfile?.id) return { status: 'unconfigured', reason: 'SDK profile changed during the check; inspect the current resource profile', profile: currentProfile, resourceId: body.resourceId, harness: body.harness, at: Date.now() } + const row = { ...result, profile, at: Date.now(), principalId: project.ownerUserId, sdkVersion: result.sdkVersion ?? null, expectedSdkVersion: rt.projectHarness.versions[body.harness] ?? null, resourceId: body.resourceId, harness: body.harness } setEntry(rt.state.projectHarnessHealth, key, row); await rt.save(); rt.projectScheduler?.wake(); return structuredClone(row) - })().finally(() => checks.delete(key)) - checks.set(key, promise); return promise + })().finally(() => checks.delete(checkKey)) + checks.set(checkKey, promise); return promise } async function close(terminal, reason) { if (terminal.closed) return @@ -102,7 +111,7 @@ export function createProjectRuntimeSetup(rt) { terminal.closed = true; terminal.output = [] Object.assign(ownEntry(audits(), terminal.id), { closedAt: Date.now(), reason, released: terminal.released === true }) await rt.save() - if (reason === 'owner-close') void check(terminal.target, terminal.actor).catch(() => {}) // Current grants are rechecked; a failed recheck does not reopen the terminal. + if (reason === 'owner-close') void check(terminal.target, terminal.actor, { fresh: true }).catch(() => {}) // Current grants are rechecked; a failed recheck does not reopen the terminal. } async function open(body, actor) { const { project, resource } = await authorized(body, actor, true) @@ -111,8 +120,20 @@ export function createProjectRuntimeSetup(rt) { const selected = await rt.projectHarness.profile(project.id, body.resourceId, body.harness, { forSetup: true }) const runtime = resource.runtimeDir ?? resolve(import.meta.dirname, '../..') await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['mkdir', '-p', selected.selected.home, selected.selected.configDir] }) + let codexLoginAvailable = false + if (body.harness === 'codex') { + try { + const module = pathToFileURL(join(runtime, 'src/host/project-codex-config.js')).href + const result = await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['node', '--input-type=module', '-e', `import {codexExecutable} from ${JSON.stringify(module)}; process.stdout.write(JSON.stringify(codexExecutable()))`] }) + const executable = JSON.parse(result.stdout).executable + if (!executable || !executable.startsWith('/')) throw Error('Invalid resource CLI path') + selected.env.PATH = dirname(executable) + ':' + selected.env.PATH + codexLoginAvailable = true + } catch { /* A missing SDK must not block its own setup terminal; never substitute an ambient CLI. */ } + } + const process = rt.projectResources.startProcess(project.id, body.resourceId, { cwd: resource.root, argv: ['python3', '-u', join(runtime, 'src/host/project-terminal-worker.py')] }) - const terminal = { id: `terminal-${randomUUID()}`, actor: { ...actor }, target: { projectId: project.id, resourceId: body.resourceId, harness: body.harness, role: body.role }, process, + const terminal = { id: `terminal-${randomUUID()}`, profile: selected.profile, actor: { ...actor }, target: { projectId: project.id, resourceId: body.resourceId, harness: body.harness, role: body.role }, process, output: [], seq: 0, createdAt: Date.now(), lastSeen: Date.now(), expiresAt: Date.now() + 10 * 60_000, released: false } terminal.exited = new Promise(resolve => process.once('close', () => { terminal.processExited = true; resolve() })) process.stdin.on('error', () => { terminal.processInputEnded = true }) @@ -129,17 +150,19 @@ export function createProjectRuntimeSetup(rt) { } catch { /* PTY protocol failures do not become screen/log output. */ } }) terminals.set(terminal.id, terminal) - setEntry(audits(), terminal.id, { id: terminal.id, projectId: project.id, resourceId: body.resourceId, harness: body.harness, principalId: selected.principalId, initiatedBy: actor.memberId, createdAt: terminal.createdAt }) + setEntry(audits(), terminal.id, { id: terminal.id, projectId: project.id, resourceId: body.resourceId, harness: body.harness, principalId: selected.principalId, profile: selected.profile, initiatedBy: actor.memberId, createdAt: terminal.createdAt }) process.stdin.write(JSON.stringify({ cwd: selected.selected.home, env: { ...selected.env, TERM: 'xterm-256color', FLYBOT_PROFILE_DIR: selected.selected.configDir, FLYBOT_SETUP_HARNESS: body.harness } }) + '\n') await rt.save() - return { terminalId: terminal.id, expiresAt: terminal.expiresAt, resourceId: body.resourceId, harness: body.harness, - instructions: body.harness === 'flybot' ? 'Configure the explicitly registered native DSH profile using its provider setup commands. Close to recheck.' : `Configure this private profile. To save an API key with hidden input: node ${JSON.stringify(join(runtime, 'src/host/project-profile-auth.mjs'))}. Codex may also use its supported login/device flow in this same profile. Claude Agent SDK uses API/cloud credentials.` } + return { terminalId: terminal.id, expiresAt: terminal.expiresAt, resourceId: body.resourceId, harness: body.harness, profile: { ...selected.profile, home: selected.selected.home, configDir: selected.selected.configDir }, ...(body.harness === 'codex' ? { codexLoginAvailable } : {}), + instructions: body.harness === 'flybot' ? 'Configure the explicitly registered native DSH profile using its provider setup commands. Close to recheck.' : `Configure this private profile. To save an API key with hidden input: node ${JSON.stringify(join(runtime, 'src/host/project-profile-auth.mjs'))}. ${body.harness === 'codex' ? codexLoginAvailable ? 'The SDK-resolved Codex CLI is on PATH; use codex login --device-auth here.' : 'The pinned Codex CLI is unavailable; provision the selected runtime before Codex login.' : ''} Codex login/device flow must run in this terminal's CODEX_HOME; this is the same profile used by the SDK worker. A cached login is not proof that token refresh succeeds. Do not copy old auth caches into new Attempts. Claude Agent SDK uses API/cloud credentials.` } } async function access(body, actor, heartbeat = true) { const terminal = terminals.get(body.terminalId) if (!terminal || terminal.actor.memberId !== actor.memberId || terminal.actor.sessionId !== actor.sessionId) throw refuse('This terminal belongs to another authenticated Session') try { await authorized(terminal.target, actor, true) + const resource = rt.projectResources.setting(terminal.target.projectId, terminal.target.resourceId) + if (harnessProfile(resource, rt.readProject(terminal.target.projectId), terminal.target.resourceId, terminal.target.harness).profile.id !== terminal.profile.id) { await close(terminal, 'profile-changed'); throw refuse('Resource profile changed; reopen its configuration terminal') } const session = ownEntry(rt.state.authSessions ?? {}, actor.sessionId) if (!session || session.revokedAt || Date.now() > terminal.expiresAt || rt.platformIdentityEnabled && !await (rt.validateHumanActor ? rt.validateHumanActor(actor) : rt.validatePlatformActor(actor))) throw refuse('Configuration terminal expired or access was revoked') } catch (error) { await close(terminal, 'authorization-ended'); throw error } diff --git a/core/src/host/project-service-views.js b/core/src/host/project-service-views.js index a79b97d85..16604c021 100644 --- a/core/src/host/project-service-views.js +++ b/core/src/host/project-service-views.js @@ -89,7 +89,7 @@ export function executionMetadataView(state, row) { const exit = native ? nativeExitConfirmed(state, row) : confirmedWorkerExit(state, row) return { ...pick(row, ['id', 'role', 'resourceId', 'generation', 'sessionKey', 'logicalSessionId', 'createdAt', 'executionStartedAt', 'releasedAt']), githubAccess: row.githubAccess ? pick(row.githubAccess, ['version', 'role', 'policyRole', 'connectionId', 'revision', 'identity', 'sha256', 'status', 'expiresAt', 'lastIssuedAt', 'renewing']) : null, - contextFailure: row.contextFailure ?? null, environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, + profile: row.profile ? pick(row.profile, ['id', 'source', 'resourceId', 'principalId', 'harness']) : null, authenticationFailure: row.authenticationFailure ? pick(row.authenticationFailure, ['harness', 'code', 'reauthenticationRequired']) : null, contextFailure: row.contextFailure ?? null, environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, contextRestoration: row.restoration ? pick(row.restoration, ['version', 'mode', 'logicalSessionId', 'contextIdentityKey', 'sourceWorkerId', 'sourceSessionId', 'physicalSessionId', 'sourceAttemptId', 'attemptId', 'workspaceId', 'checkout', 'archiveSha256', 'observedCheckout', 'at']) : null, harness: native ? 'flybot' : row.harness ?? null, status: row.status, startupPhase: native ? null : row.spawnPhase ?? null, policyRole: session?.policyRole ?? row.policyRole ?? row.role ?? null, diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index 86d3354c7..6b4ae3552 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -1,3 +1,4 @@ +import { harnessProfile } from '../src/host/project-harness-auth.js' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' @@ -386,7 +387,7 @@ write({type:'completed',sessionId:'contract-'+process.pid,text,usage:{input_toke rt.config.projectResources = { execution: { type: 'local', root: directory, runtimeDir, capacity: 3, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex', 'claude-agent'], profiles: { 'owner-1': { codex: { home: join(directory, 'codex-home'), configDir: join(directory, 'codex-config'), model: 'contract-model' }, 'claude-agent': { home: join(directory, 'claude-home'), configDir: join(directory, 'claude-config'), model: 'contract-model' } } } } } rt.state.projectResourceHealth = { execution: { status: 'ready', at: Date.now() } } - rt.state.projectHarnessHealth = { 'p1:execution:codex': { status: 'ready' }, 'p1:execution:claude-agent': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p1:execution:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['execution'], rt.readProject('p1'), 'execution', 'codex').profile }, 'p1:execution:claude-agent': { status: 'ready', profile: harnessProfile(rt.config.projectResources['execution'], rt.readProject('p1'), 'execution', 'claude-agent').profile } } for (const role of ['developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: role === 'developer' ? 'codex' : 'claude-agent', resourceIds: ['execution'] } }, alice) const started = await fixture.start('full-sdk-contract') assert.equal(started.attempt.status, 'running', started.attempt.failure?.message) @@ -498,7 +499,7 @@ write({type:'sdk-event',event:{type:'thread.started',thread_id:m.settings.sessio });`) rt.config.projectResources = { one: { type: 'local', root: directory, runtimeDir, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(directory, 'sdk-home'), configDir: join(directory, 'sdk-config'), model: 'contract-model' } } } } } rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['one'], rt.readProject('p1'), 'one', 'codex').profile } } for (const role of ['developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: 'codex', resourceIds: ['one'] } }, alice) const started = await fixture.start('dynamic-pool') assert.ok(started.attempt, `One free resource must admit only the receiving role, instead of reserving the full DAG: ${JSON.stringify(started.queue)}`) @@ -553,7 +554,7 @@ else text='OUTPUT handoff: '+JSON.stringify({action:'handoff',to:'p1:reviewer',r write({type:'sdk-event',event:{type:'thread.started',thread_id:m.settings.sessionId??('chief-contract-'+process.pid)}});write({type:'completed',sessionId:m.settings.sessionId??('chief-contract-'+process.pid),text,usage:{input_tokens:1}}); });`) rt.config.projectResources = { one: { type: 'local', root: directory, runtimeDir, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(directory, 'chief-sdk-home'), configDir: join(directory, 'chief-sdk-config'), model: 'contract-model' } } } } } - rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } }; rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } }; rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['one'], rt.readProject('p1'), 'one', 'codex').profile } } for (const role of ['chief', 'developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: 'codex', resourceIds: ['one'] } }, alice) rt.state.projectAgents ??= {} rt.state.projectAgents['public-p1'] = { id: 'public-p1', projectId: 'p1', visibility: 'public', memberId: null, botId: 'a', sessionId: 'a', carrierGeneration: 1 } @@ -819,7 +820,7 @@ test('failed context admission preserves an existing unsealed developer workspac test('context failure releases an actual unbound reservation before registering any SDK Session',async t=>{ const f=await setup(t,undefined,{pool:true});f.rt.projectPools.connect({validate:()=>({ok:true})}) f.rt.config.projectResources={one:{type:'local',root:f.directory,capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex'],profiles:{'owner-1':{codex:{home:join(f.directory,'sdk-home'),configDir:join(f.directory,'sdk-config'),model:'fixture/model'}}}}} - f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex':{status:'ready'}} + f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile }} await f.rt.projectEndpoint('project-agent-definition',{projectId:'p1',role:'developer',execution:{harness:'codex',resourceIds:['one']}},alice) await f.start('unbound-context-failure') const a=f.attempt(),task=f.rt.state.tasks[f.task.id],dev=f.rt.projectAgents.deliveryDefinitions('p1').roles.find(r=>r.role==='developer') @@ -891,7 +892,7 @@ test('workspace allocation failure releases an actual reservation before any ext f.rt.projectScheduler.wake = () => {} f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } f.rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) await f.start('allocation-failure') const a = f.attempt(), task = f.rt.state.tasks[f.task.id] @@ -1044,7 +1045,7 @@ test('bound-resource preparation executes before SDK registration and failure re f.rt.projectScheduler.wake = () => {} f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } f.rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) const settings = f.rt.config.projectRepositories['example/demo'] settings.workspacePrepareCommand = [process.execPath, '-e', "if(!require('fs').existsSync('code.mjs'))process.exit(31)"] @@ -1196,7 +1197,7 @@ test('SDK Task rework preserves an unsealed same-base draft and old cleanup cann const f=await setup(t,undefined,{pool:true}) f.rt.projectScheduler.wake=()=>{};f.rt.projectPools.connect({validate:()=>({ok:true})}) f.rt.config.projectResources={one:{type:'local',root:f.directory,capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex'],profiles:{'owner-1':{codex:{home:join(f.directory,'sdk-home'),configDir:join(f.directory,'sdk-config'),model:'fixture/model'}}}}} - f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex':{status:'ready'}} + f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile }} await f.rt.projectEndpoint('project-agent-definition',{projectId:'p1',role:'developer',execution:{harness:'codex',resourceIds:['one']}},alice) await f.start('retained-draft') const previous=f.attempt(),task=f.rt.state.tasks[f.task.id],dev=f.rt.projectAgents.deliveryDefinitions('p1').roles.find(r=>r.role==='developer') @@ -1222,8 +1223,9 @@ test('a returned SDK draft reuses its Task workspace after exact supervisor exit const f = await setup(t, undefined, { pool: true }) f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'] } } + f.rt.config.projectResources.one.profileRoot = join(f.directory, 'proof-profile') f.rt.state.projectResourceHealth = { one: { status: 'ready' } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) await f.start('supervised-draft') const previous = f.attempt(), task = f.rt.state.tasks[f.task.id] @@ -1400,7 +1402,8 @@ test('an approved operator object reaches a resource clone through the existing git(f.repository,['update-ref',ref,sha]);git(f.repository,['reset','--hard',base]) const remote=join(f.directory,'emulated-resource');mkdirSync(remote) f.rt.config.projectResources={remote:{type:'docker',root:remote,container:'fixture',user:'1000:1000',capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex']}} - f.rt.state.projectResourceHealth={remote:{status:'ready'}};f.rt.state.projectHarnessHealth={'p1:remote:codex':{status:'ready'}} + f.rt.config.projectResources.remote.profileRoot = join(f.directory, 'proof-profile') + f.rt.state.projectResourceHealth={remote:{status:'ready'}};f.rt.state.projectHarnessHealth={'p1:remote:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['remote'], f.rt.readProject('p1'), 'remote', 'codex').profile }} // Local transport emulation exercises real Git/bundle hashes, not Docker isolation. f.rt.projectResources.command=async(_p,_r,{argv,cwd})=>({stdout:execFileSync(argv[0],argv.slice(1),{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']})}) f.rt.projectResources.uploadFile=async(_p,_r,source,target)=>copyFileSync(source,target) @@ -1806,7 +1809,7 @@ async function baselineResourceFixture(t, kind = 'local') { f.rt.projectScheduler.wake = () => Promise.resolve(); f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) const resourceRoot = join(f.directory, 'resource'); mkdirSync(resourceRoot) f.rt.config.projectResources = { baseline: { type: kind, root: resourceRoot, ...(kind === 'docker' ? { container: 'isolated-fixture', user: '1000:1000' } : {}), capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } - f.rt.state.projectResourceHealth = { baseline: { status: 'ready' } }; f.rt.state.projectHarnessHealth = { 'p1:baseline:codex': { status: 'ready' } } + f.rt.state.projectResourceHealth = { baseline: { status: 'ready' } }; f.rt.state.projectHarnessHealth = { 'p1:baseline:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['baseline'], f.rt.readProject('p1'), 'baseline', 'codex').profile } } if (kind === 'docker') { // Real Git and bundle verification, emulated transport; actual Docker acceptance is separate. f.rt.projectResources.command = async (_p, _r, { argv, cwd }) => ({ stdout: execFileSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }) diff --git a/core/test/project-harness-auth-sdk.test.js b/core/test/project-harness-auth-sdk.test.js new file mode 100644 index 000000000..27def6845 --- /dev/null +++ b/core/test/project-harness-auth-sdk.test.js @@ -0,0 +1,37 @@ +// Opt-in actual pinned Codex SDK/CLI + worker; provider response is an isolated fixture. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createServer } from 'node:http' +import { spawn, execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createInterface } from 'node:readline' +if (process.env.FLYBOT_TEST_AUTH_SDK === '1') test('actual Codex SDK worker preserves refresh-token failure without provider payload or successful completion', { timeout: 45000 }, async () => { + const root = await mkdtemp(join(tmpdir(),'flybot-auth-sdk-')), home=join(root,'home'), cwd=join(root,'cwd') + await mkdir(home); await mkdir(cwd); execFileSync('git',['init','-q',cwd]) + const secret='fixture-private-provider-auth-details', server=createServer(async (request,response)=>{ + await new Promise(resolve => { request.on('end', resolve); request.resume() }) + response.writeHead(401,{'content-type':'application/json'}) + response.end(JSON.stringify({error:{type:'authentication_error',code:'refresh_token_reused',message:'Your refresh token has already been used. Please log in again. '+secret}})) + }) + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) + const child=spawn(process.execPath,[resolve(import.meta.dirname,'../src/host/project-harness-worker.mjs')],{env:{PATH:process.env.PATH},stdio:['pipe','pipe','pipe']}) + const frames=[],lines=createInterface({input:child.stdout}); child.stderr.resume() + let finish, reject, timer + const result=new Promise((resolve,rejected)=>{finish=resolve;reject=rejected;timer=setTimeout(()=>rejected(Error('Actual worker auth fixture timed out')),30000)}) + const exit=new Promise(resolve=>child.once('close',resolve)) + lines.on('line',line=>{ + const frame=JSON.parse(line);frames.push(frame) + if(frame.type==='ready')child.stdin.write(JSON.stringify({type:'start',settings:{harness:'codex',cwd,model:'fixture-model',noTools:true,env:{HOME:home,CODEX_HOME:home,PATH:process.env.PATH},config:{model_provider:'fixture',model_providers:{fixture:{name:'fixture',base_url:`http://127.0.0.1:${server.address().port}`,wire_api:'responses',requires_openai_auth:false}}}},prompt:'Isolated auth failure probe'})+'\n') + if(frame.type==='failure'){finish(frame);child.stdin.end(JSON.stringify({type:'stop'})+'\n')} + if(frame.type==='completed')reject(Error('Authentication failure must not become a completed turn')) + }) + try{ + const failure=await result + assert.equal(failure.authenticationFailure?.code,'refresh_token_reused',JSON.stringify(frames)) + assert.ok(!JSON.stringify(frames).includes(secret)) + await exit + assert.ok(frames.some(frame=>frame.type==='stopped'&&frame.released===true)) + }finally{clearTimeout(timer);child.kill('SIGTERM');lines.close();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));await rm(root,{recursive:true,force:true})} +}) diff --git a/core/test/project-harness-auth.test.js b/core/test/project-harness-auth.test.js new file mode 100644 index 000000000..159f6f482 --- /dev/null +++ b/core/test/project-harness-auth.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createHash } from 'node:crypto' +import { harnessProfile, sdkAuthenticationFailure, authenticationError } from '../src/host/project-harness-auth.js' +test('profile identity preserves generated paths and explicit existing login reuse without Task/role copies', () => { + const project = { id: 'p', ownerUserId: 'owner' }, resource = { type: 'docker', container: 'one', user: '1000', profileRoot: '/profiles' } + const first = harnessProfile(resource, project, 'r', 'codex') + assert.equal(first.selected.configDir, '/profiles/' + createHash('sha256').update('p\nowner').digest('hex').slice(0,24) + '/codex/config') + assert.deepEqual(harnessProfile(resource, { ...project, taskId: 'another', role: 'reviewer' }, 'r', 'codex').profile, first.profile) + resource.profiles = { owner: { codex: { home: '/existing', configDir: '/existing/codex' } } } + const configured = harnessProfile(resource, project, 'r', 'codex') + assert.equal(configured.profile.source, 'configured'); assert.equal(configured.selected.configDir, '/existing/codex') + assert.notEqual(configured.profile.id, first.profile.id) + assert.ok(!JSON.stringify(configured.profile).includes('/existing')) + resource.profiles.owner.codex.configDir = '/other' + assert.notEqual(harnessProfile(resource, project, 'r', 'codex').profile.id, configured.profile.id) + assert.throws(() => harnessProfile({}, project, 'r', 'codex'), /explicitly authorized/) +}) +test('known SDK auth failure shapes retain safe codes without provider text or treating network errors as login failures', () => { + for (const value of [{ error: { code: 'refresh_token_reused', message: 'private details' } }, { message: 'Your refresh token has already been used to generate a new access token.' }, { message: 'refresh_token_reused' }]) { + const failure = sdkAuthenticationFailure(value, 'codex') + assert.equal(failure.code, 'refresh_token_reused'); assert.equal(failure.reauthenticationRequired, true) + assert.equal(authenticationError(failure).code, 'bot/harness-auth'); assert.ok(!authenticationError(failure).message.includes('private details')) + } + for (const value of [{ code: 'refresh_token_expired' }, { code: 'invalid_grant' }, { message: 'Refresh token has expired' }, { message: 'Invalid API key' }, { status: 401 }]) assert.ok(sdkAuthenticationFailure(value, 'codex')) + for (const value of [null, {}, { message: 'Network timeout' }, { status: 503 }, { code: 'ECONNREFUSED' }]) assert.equal(sdkAuthenticationFailure(value, 'codex'), null) +}) diff --git a/core/test/project-harness-turn.test.js b/core/test/project-harness-turn.test.js index aee311d79..2ab387aae 100644 --- a/core/test/project-harness-turn.test.js +++ b/core/test/project-harness-turn.test.js @@ -136,3 +136,17 @@ test('receipt correction resumes the original SDK Session without tools or chang } }) }) assert.equal(asked, false) }) + +test('Codex refresh-token reuse is a safe authentication failure before provider error text reaches evidence', async () => { + const emitted = [], secret = 'fixture-private-provider-material' + for (const event of [{ type: 'turn.failed', error: { message: 'Your refresh token has already been used. Please log in again. ' + secret } }, { type: 'error', message: 'refresh_token_reused ' + secret }]) { + await assert.rejects(runHarnessTurn(request, { emit: value => emitted.push(value), loadCodex: codex([event]) }), error => error.code === 'bot/harness-auth' && error.authenticationFailure.code === 'refresh_token_reused' && !error.message.includes(secret)) + } + assert.equal(emitted.length, 0) + await assert.rejects(runHarnessTurn(request, { emit() {}, loadCodex: codex([{ type: 'turn.failed', error: { message: 'Network connection timed out' } }]) }), error => error.code !== 'bot/harness-auth') +}) + +test('a tool command reporting its own authentication failure does not invalidate the model login', async () => { + const result = await runHarnessTurn(request,{emit(){},loadCodex:codex([{type:'thread.started',thread_id:'tool-scope'},{type:'item.completed',item:{type:'command_execution',exit_code:1,aggregated_output:'refresh_token_reused in a tool integration'}},{type:'turn.completed'}])}) + assert.equal(result.sessionId,'tool-scope') +}) diff --git a/core/test/project-harness.test.js b/core/test/project-harness.test.js index 4c7e26f17..b553a18ab 100644 --- a/core/test/project-harness.test.js +++ b/core/test/project-harness.test.js @@ -1,3 +1,4 @@ +import { harnessProfile } from '../src/host/project-harness-auth.js' import assert from 'node:assert/strict' import { test } from 'node:test' import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises' @@ -194,7 +195,7 @@ if(m.type==='input'){if(!m.prompt.includes('Verified pnpm decision'))process.exi if(data.memoryUpdates?.length)memoryWrites+=data.memoryUpdates.length return {reads:data.contextRequests?.length?readContextArchive(snapshot,data.contextRequests):[],writes:[]} }} - rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l + rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l const agent={id:'public-p',projectId:'p',lastRequest:{}} const id=await harness.chiefTurn({agent,delegation:{id:'d',definitionRevision:1},definition:{execution:{harness:'codex',resourceIds:['r'],model:'test-model'}},context,text:'Read the saved decision',onResult:finish}) const result=await done @@ -210,7 +211,7 @@ if(m.type==='input'){if(!m.prompt.includes('Verified pnpm decision'))process.exi test('conversation carriers for non-Chief roles cannot gain development shell or write capabilities', async t => { const {rt,harness}=await fixture(t) - rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l + rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l rt.projectAgents={isDelegationActive:()=>true} for(const role of ['developer','reviewer']) { const agent={id:'room-'+role,projectId:'p',role,roomId:'room',lastRequest:{}} @@ -415,7 +416,7 @@ test('changed caller prompt resumes the Task-role SDK thread when its role defin session.id = 'returned-role-session' session.attemptId = 'second-attempt' rt.state.projectResourceHealth = { r: { status: 'ready' } } - rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile } } session.resourceLease = await rt.projectResources.reserve({ projectId: 'p', attemptId: session.attemptId, role: 'developer', sessionKey: session.id, harness: 'codex', resourceIds: ['r'] }) const next = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Tester returned the Task with new feedback and changed candidate facts' }) assert.equal(rt.state.projectHarnessWorkers[next].continuedFrom, first) @@ -450,7 +451,7 @@ test('every SDK delivery role resumes the original thread across an admitted cwd rt.state.projectAttempts.a2 = { id: 'a2', projectId: 'p', taskId: 't', status: 'running', roleSessions: { [role]: nextSession }, workspaces: { [role]: workspace } } rt.state.projectWorkspaces = { [workspace.id]: workspace } rt.state.tasks.t.deliveryAttemptId = 'a2' - rt.state.projectResourceHealth = { r: { status: 'ready' } }; rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready' } } + rt.state.projectResourceHealth = { r: { status: 'ready' } }; rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile } } nextSession.resourceLease = await rt.projectResources.reserve({ projectId: 'p', attemptId: 'a2', role, sessionKey: nextSession.id, harness: 'codex', resourceIds: ['r'] }) rt.projectDelivery.sessionFor = () => nextSession rt.projectDelivery.ownsWorkspace = (_projectId, selected) => selected === path ? workspace : null @@ -654,7 +655,7 @@ test('an actual SDK protocol subprocess must exit before its Chief input wait fr test('Chief reservation save failure must release its unstarted resource',async t=>{ const {rt,harness}=await fixture(t); -rt.projectAgents={isDelegationActive:()=>true}; rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l; +rt.projectAgents={isDelegationActive:()=>true}; rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l; rt.state.projectExecutions={inspection:{invocation:1}};let saves=0;rt.save=async()=>{if(++saves===2)throw new Error('correlation save failed')}; await assert.rejects(harness.chiefTurn({agent:{id:'public-p',projectId:'p',lastRequest:{}},delegation:{id:'d',chiefJob:{runId:'inspection',invocation:1}},definition:{execution:{harness:'codex',resourceIds:['r']}},context:{text:'Inspect'},text:'Inspect',onResult:async()=>{}}),/correlation save failed/); assert.equal(rt.projectResources.occupied('r'),0); @@ -745,3 +746,26 @@ test('role App worker capability is checked before admission and auth frames nev await harness.release(id) assert.equal(row.githubAccess.renewing, false) }) + +test('refresh-token reuse reaches the original worker/profile health without becoming a code verdict or leaking provider text', async t => { + const { rt, harness, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + let source = await readFile(file, 'utf8') + source = source.replace("write({type:'completed',sessionId:'bound-thread',text:'OUTPUT implementation: actual contract worker output',usage:{input_tokens:1}});", "write({type:'failure',authenticationFailure:{code:'refresh_token_reused'},reason:'fixture-private-auth-response'});") + await writeFile(file, source) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Review the current candidate' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + const failed = await result, row = rt.state.projectHarnessWorkers[id], health = rt.projectResources.harnessHealth('p','r','codex') + assert.equal(failed.ok, false); assert.match(failed.text, /refresh_token_reused/) + assert.equal(health.status, 'authRequired'); assert.equal(health.profile.id, row.profile.id) + assert.equal(row.authenticationFailure.code, 'refresh_token_reused') + assert.ok(!(await readFile(row.eventLog,'utf8')).includes('fixture-private-auth-response')) + assert.ok(!JSON.stringify(rt.state).includes('fixture-private-auth-response')) + assert.equal(rt.state.projectAttempts, undefined, 'Authentication does not manufacture a candidate/test/Review verdict') + const before = row.profile.id + rt.config.projectResources.r.profiles.owner.codex.configDir += '-changed' + const current = rt.projectResources.harnessHealth('p','r','codex') + assert.equal(current.status, 'unconfigured'); assert.notEqual(current.profile.id, before) + await harness.release(id) +}) diff --git a/core/test/project-runtime-setup.test.js b/core/test/project-runtime-setup.test.js index d81f7aa6f..0cfe0a5b4 100644 --- a/core/test/project-runtime-setup.test.js +++ b/core/test/project-runtime-setup.test.js @@ -125,3 +125,39 @@ test('missing SDK bridge cannot consume probe capacity or imply authentication r assert.equal(result.sdkVersion, null) assert.equal(rt.projectResources.occupied('local'), 0) }) + +test('configuration PTY reports and actually uses the same persistent profile as SDK roles, and refuses changed profile input', async t => { + const { setup, rt, body, root } = await fixture(t) + const opened = await setup.endpoint('project-terminal-open', body, owner), selected = await rt.projectHarness.profile('p', 'local', 'codex') + assert.equal(opened.profile.id, selected.profile.id) + assert.equal(opened.codexLoginAvailable, true) + assert.equal(opened.profile.configDir, selected.env.CODEX_HOME) + await setup.endpoint('project-terminal-input', {terminalId:opened.terminalId,text:"printf 'ACTUAL_CODEX_PROFILE=%s\\n' \"$CODEX_HOME\"\n"}, owner) + assert.ok((await screen(setup, opened.terminalId, /ACTUAL_CODEX_PROFILE=\//)).includes(selected.env.CODEX_HOME)) + await setup.endpoint('project-terminal-input', { terminalId: opened.terminalId, text: "printf 'PINNED_CODEX=%s\\n' \"$(command -v codex)\"\n" }, owner) + assert.match(await screen(setup, opened.terminalId, /PINNED_CODEX=\//), /vendor.*codex/) + const next = await rt.projectHarness.profile('p','local','codex') + assert.equal(next.profile.id, opened.profile.id) + rt.config.projectResources.local.profileRoot = join(root,'new-profiles') + await assert.rejects(setup.endpoint('project-terminal-input',{terminalId:opened.terminalId,text:'must not run\n'},owner),/profile changed/) + assert.equal(rt.state.projectTerminalAudit[opened.terminalId].released,true) +}) + +test('SDK probe preserves typed refresh-token failure and does not reuse readiness from another profile', async t => { + const { setup, rt, body, root } = await fixture(t) + const { mkdir, writeFile } = await import('node:fs/promises') + const runtime = join(root,'runtime'); await mkdir(join(runtime,'src/host'),{recursive:true}) + rt.config.projectResources.local.runtimeDir = runtime + rt.projectAgents.deliveryDefinitions = () => ({ roles:[{role:'reviewer',execution:{harness:'codex',model:'fixture-model'}}] }) + await writeFile(join(runtime,'src/host/project-harness-worker.mjs'), `import{createInterface}from'node:readline';const lines=createInterface({input:process.stdin});const send=value=>process.stdout.write(JSON.stringify(value)+'\\n');send({type:'ready',sdk:{codex:'0.166.0'}});lines.on('line',line=>{const m=JSON.parse(line);if(m.type==='start')send({type:'failure',authenticationFailure:{code:'refresh_token_reused'},reason:'safe auth failure'});if(m.type==='stop'){send({type:'stopped',released:true});process.exit(0)}});`) + const result = await setup.check(body,owner) + assert.equal(result.status,'authRequired'); assert.equal(result.authenticationFailure.code,'refresh_token_reused') + const selected = await rt.projectHarness.profile('p','local','codex') + assert.equal(result.profile.id, selected.profile.id); assert.equal(rt.projectResources.occupied('local'),0) + rt.state.projectHarnessHealth['p:local:codex'] = {...result,status:'ready'} + assert.equal(rt.projectResources.harnessHealth('p','local','codex').status,'ready') + rt.config.projectResources.local.profileRoot = join(root,'other-profile') + assert.equal(rt.projectResources.harnessHealth('p','local','codex').status,'unconfigured') + const again = await setup.check(body,owner) + assert.equal(again.status,'authRequired'); assert.notEqual(again.profile.id,result.profile.id) +}) diff --git a/decisions/2026-11-04-resource-sdk-authentication.md b/decisions/2026-11-04-resource-sdk-authentication.md new file mode 100644 index 000000000..71c75040b --- /dev/null +++ b/decisions/2026-11-04-resource-sdk-authentication.md @@ -0,0 +1,16 @@ +# Resource SDK authentication feedback + +- **Date:** 2026-11-04. +- **Decision maker:** fork Owner zy95-12, explicit instruction to fix and merge. +- **Class:** major documentation update, accepted for the delivery branch. +- **Decision:** bind existing readiness/setup/worker metadata to the actual resource + profile; preserve known safe SDK authentication errors and existing authRequired. + Keep existing generated directories and Task-role Session binding semantics. +- **Reason:** host and container can use independent login caches, while cached + readiness and generic SDK failures conceal the actual authentication target/cause. +- **Boundary:** no OAuth refresh broker, automatic credential copy, new recovery + routing or changed sandbox policy. Only existing authorized terminal administrators + receive actual setup paths. Native SDK owns login/refresh and its persistent cache. +- **Validation:** deterministic model failures, actual SDK/CLI with an isolated + provider, and real PTYs verify protocol/target behavior. They do not repair or + establish validity of the live container OAuth cache; no login or deployment occurs. diff --git a/docs/backend-operations.md b/docs/backend-operations.md index 66260aa02..123317dbf 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -177,3 +177,31 @@ SDK validation](task-session-recovery.md). No live deployment follows source mer Chief 运维接收、签名通知接入、safe/watch 权限与可配置小时补偿,见 [协助闭环](chief-assistance.md)。按实际运行的插件路径核对升级版本;代码合入不代表实例已升级。 + + +## SDK登录态与资源profile + +SDK 模型登录与 GitHub App 身份独立。显式 `profiles[OWNER_ID].codex` 的 +home/configDir 决定 HOME/CODEX_HOME;`profileRoot` 的既有生成路径保持兼容。 +多个角色和 Attempt 使用同一选定 profile,无需为每个 Task 复制 auth.json。 +配置目录由操作员预置;若需要复用现有登录,显式选择获准的持久化目录/挂载, +不复制旧缓存,也不把整个个人 .codex 随意挂入执行器。 + +使用现有 `project-runtime-check`、`project-resources` 查看 profile 身份与健康状态。 +检查结果绑定资源、用户、Harness 和 profile;升级前没有绑定的 ready 以及目录/目标 +改变后的 ready 均需重新检查。profile ID 不包含凭据内容,公开查询不返回目录。 +`ready` 只证明那次最小 SDK 推理成功,不能保证未来 refresh 一直有效。 + +已有权限的资源管理员通过 `project-terminal-open` 打开实际目标资源终端;返回的 +profile.home/configDir 是这次终端真实使用的位置,和 SDK worker 使用同一个选择器。 +Codex 终端会解析资源内 SDK 自带的 CLI 并放到 PATH;codexLoginAvailable=false +表示先安装/修复该资源的锁定 SDK,不能拿宿主的全局 CLI 代替容器配置。 +在该终端执行 `codex login --device-auth` 或所安装 CLI 支持的登录方式; +不要在宿主默认目录登录后误以为容器已更新。关闭终端会重新检查,跳过旧健康缓存。 +目录配置变化时,原终端拒绝继续输入,需重新打开;不会迁移或删除登录态。 + +`refresh_token_reused` 等错误显示为原资源/profile 的 `authRequired`,保留安全错误码。 +缓存文件存在和 `codex login status` 显示已登录,都不等于刷新凭据仍有效。 +真正失效的 OAuth token 仍需原生登录流程;源码合入不会自动修复现场凭据。 +网络超时不会被当成重新登录要求,工具自身的认证错误也不会冒充模型登录失效。 +Task/Session/候选/Review 证据保留;Chief 决定换用已授权资源、等待或请求协助。 diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index 95a64b212..eddade666 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -3969,6 +3969,12 @@ "type": "null" } ] + }, + "profile": { + "$ref": "#/components/schemas/HarnessProfile" + }, + "authenticationFailure": { + "$ref": "#/components/schemas/SdkAuthenticationFailure" } }, "additionalProperties": true, @@ -4035,6 +4041,19 @@ "type": "null" } ] + }, + "profile": { + "anyOf": [ + { + "$ref": "#/components/schemas/HarnessProfile" + }, + { + "type": "null" + } + ] + }, + "authenticationFailure": { + "$ref": "#/components/schemas/SdkAuthenticationFailure" } }, "additionalProperties": true, @@ -4093,6 +4112,62 @@ }, "reason": { "type": "string" + }, + "profile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "source": { + "type": "string", + "enum": [ + "configured", + "generated" + ] + }, + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "principalId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "harness": { + "type": "string", + "enum": [ + "flybot", + "codex", + "claude-agent" + ] + }, + "home": { + "type": "string" + }, + "configDir": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "source", + "resourceId", + "principalId", + "harness", + "home", + "configDir" + ], + "description": "Only returned by terminal-open to the explicit resource administrator; actual target paths for login, never credential contents." + }, + "codexLoginAvailable": { + "type": "boolean" } }, "additionalProperties": true @@ -5297,6 +5372,26 @@ "type": "null" } ] + }, + "profile": { + "anyOf": [ + { + "$ref": "#/components/schemas/HarnessProfile" + }, + { + "type": "null" + } + ] + }, + "authenticationFailure": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkAuthenticationFailure" + }, + { + "type": "null" + } + ] } }, "additionalProperties": true @@ -5851,6 +5946,86 @@ "at" ] }, + "HarnessProfile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "source": { + "type": "string", + "enum": [ + "configured", + "generated" + ] + }, + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "principalId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "harness": { + "type": "string", + "enum": [ + "flybot", + "codex", + "claude-agent" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "source", + "resourceId", + "principalId", + "harness" + ] + }, + "SdkAuthenticationFailure": { + "type": "object", + "properties": { + "harness": { + "type": "string", + "enum": [ + "flybot", + "codex", + "claude-agent" + ] + }, + "code": { + "type": "string", + "enum": [ + "refresh_token_reused", + "refresh_token_expired", + "refresh_token_invalidated", + "invalid_refresh_token", + "invalid_grant", + "invalid_api_key", + "authentication_required", + "authentication_error" + ] + }, + "reauthenticationRequired": { + "const": true, + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "harness", + "code", + "reauthenticationRequired" + ] + }, "DevelopmentQueryInboxListPayload": { "type": "object", "properties": { @@ -32247,7 +32422,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" } }, { @@ -35232,7 +35407,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" } }, { @@ -35337,7 +35512,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" } }, { @@ -35714,7 +35889,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "94f597a01f1f02c8c87c07822570ac5e3da874b5e6cd30cc5282ca2f79743570" + "sha256": "6b6a5459d82307821fdcdb7ab25ab5ffa7ab0ee1177768a8d5d5094592c35da7" } }, { @@ -41034,7 +41209,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-resources.js", - "sha256": "24582ddb88111dc46cec134b5bb4489c8c530448b827ffba998d97e6f3b5401e" + "sha256": "9a267001af697abdcdd9d3dd3e06e97d9e3042b77e6e31cb781ca57a4ada5920" } }, { @@ -41128,10 +41303,10 @@ "unifiedCommandReceipt": false }, "invokesModel": true, - "notes": "Can invoke paid minimal inference. ready means connectivity only, not task execution.", + "notes": "Can invoke paid minimal inference. ready means connectivity only, not task execution. Health is bound to the actual selected profile. Legacy/changed profile readiness must be checked again. authRequired preserves a safe SDK error code; it is not a code-test/Review failure or a Task routing decision.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -41230,10 +41405,10 @@ "unifiedCommandReceipt": false }, "invokesModel": false, - "notes": "FBA tokens have no live human Session and cannot use this endpoint, including watch mode.", + "notes": "FBA tokens have no live human Session and cannot use this endpoint, including watch mode. Returns the actual HOME/configDir only to this authorized administrator. Codex CLI resolution uses the resource SDK installation; re-login happens in that terminal CODEX_HOME. No host auth cache is copied.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -41315,7 +41490,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -41397,7 +41572,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -41479,7 +41654,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -41561,7 +41736,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-runtime-setup.js", - "sha256": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0" + "sha256": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c" } }, { @@ -46129,7 +46304,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" }, "aliasOf": "development.command.chief.inspect" }, @@ -46232,7 +46407,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" }, "aliasOf": "development.command.chief.configure" }, @@ -46308,7 +46483,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4" + "sha256": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" }, "aliasOf": "development.query.chief.state" }, @@ -46677,7 +46852,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "94f597a01f1f02c8c87c07822570ac5e3da874b5e6cd30cc5282ca2f79743570" + "sha256": "6b6a5459d82307821fdcdb7ab25ab5ffa7ab0ee1177768a8d5d5094592c35da7" }, "aliasOf": "development.command.execution.input" }, @@ -47173,7 +47348,7 @@ "core/src/host/project-assistant.js": "4fa4309d4aa7abf78b6ba4e55ba754f320384f931e0e4a5c6620c859e1b50676", "core/src/host/project-board.js": "dfb0b4bc05ae42335632e3edbba883a03c5da2fb17899b23e8226320ea08aec4", "core/src/host/project-chief-assistance.js": "261f3767cf7fd4466f057c625b32e74b9c0a90c53c0fca3fcc835ac0f11c567b", - "core/src/host/project-chief-control.js": "813d011b3a98cfce090ab8f5593219110e4f22e3e9b6b4b9c9083b1fa1cc0fd4", + "core/src/host/project-chief-control.js": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6", "core/src/host/project-chief-lifecycle.js": "ed85bb1be90198910b20ffd605895812dfa848fce1049f7bd27b32dbfae3a328", "core/src/host/project-chief-protocol.js": "d56d4970a8a86f620005ad93ab70fecbd557d2f4980679c5c228237768fcd8d4", "core/src/host/project-chief-recovery.js": "b4efeef007222a87249a808bc49deb84e0e6014c700bb661629a9eb8426327f7", @@ -47205,8 +47380,9 @@ "core/src/host/project-github-read.js": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943", "core/src/host/project-github-role-artifacts.js": "a4c04ad7e7d7cac2f1f82a42535f6255e97a5985b4492641e1af64ce26266d1c", "core/src/host/project-github.js": "29c81ebad0d3cb094bb8c549f8ee89c674cb1685c2f73f79095b53a121a97c52", - "core/src/host/project-harness-turn.js": "fdfcccc090134dcdd7fb6ad8ab15d93e2e7ebec7d0a48af27b35f434993aa134", - "core/src/host/project-harness.js": "94f597a01f1f02c8c87c07822570ac5e3da874b5e6cd30cc5282ca2f79743570", + "core/src/host/project-harness-auth.js": "2684b6687433e128197a0cc67b671bbd02c3c71e39428af7393d0c0ca1658ac7", + "core/src/host/project-harness-turn.js": "3e24454593f8c12284fbe96a7c1b3ecb31d7774815fc0d4c2f5d332457d46400", + "core/src/host/project-harness.js": "6b6a5459d82307821fdcdb7ab25ab5ffa7ab0ee1177768a8d5d5094592c35da7", "core/src/host/project-history-pages.js": "38958623f0aa98099e570559c55a207285fd0349689ffdcec47b965c40f4e1bc", "core/src/host/project-inputs.js": "d1da0f7eb2ea07394bffae2522c86ebbbe041d38323ccda8ea2b8d078383e256", "core/src/host/project-lifecycle.js": "055784e0a1cbf2a9e465e990c60fbd6df4b8844052db43bbcb7cae5de9282936", @@ -47222,7 +47398,7 @@ "core/src/host/project-releases.js": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885", "core/src/host/project-repository-policy.js": "c16f469f3275f61e817bcad0bc8c753f01b034db6562c4e3d197f0349174823a", "core/src/host/project-resource-workspaces.js": "cc9525aab5ea9ea09d4fb2c557a1048fa034d638c0b80dbebea9cc8eba0ede8b", - "core/src/host/project-resources.js": "24582ddb88111dc46cec134b5bb4489c8c530448b827ffba998d97e6f3b5401e", + "core/src/host/project-resources.js": "9a267001af697abdcdd9d3dd3e06e97d9e3042b77e6e31cb781ca57a4ada5920", "core/src/host/project-reviewed-merge.js": "f34db330ee71cf9459bf88c26ce38aba57b46974225566a62253309dcc3dca2a", "core/src/host/project-role-admission.js": "ccd5f3ac4a5d1f0010a3e533e6809b20a4f88551fc55e6c775d8289d8f9e100f", "core/src/host/project-role-context.js": "e032b6c4e8b7a902c1dedc114782025d73c630d2419f912e10cbd103c8d7f69c", @@ -47230,12 +47406,12 @@ "core/src/host/project-role-governance.js": "fe9ca70113666b0223f55b9225890609c5a1e7ddcb9b6105a3dc0edc3b2ef005", "core/src/host/project-role-policy.js": "ba90bf8dcba9f4a2d659f62948b6437d739ee40e2d839cd213181731c4dab8a5", "core/src/host/project-rooms.js": "148ba6f39086dc74e0c9c92ab368361b256b5185490d7983e957dd28890cfbe9", - "core/src/host/project-runtime-setup.js": "7d4ff3f84c0b08540145b40c150262e8d85be957def7f14a06496064f20d51e0", + "core/src/host/project-runtime-setup.js": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c", "core/src/host/project-scheduled-tasks.js": "78c0e29bb830710dd9109d6a5fd1a67214fff01c55a79a38a3df7e5c3a194324", "core/src/host/project-scheduler.js": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6", "core/src/host/project-service-events.js": "71917d1ab367a9db8bef8c1d1003228255a57528e84a57e76de3a39d08ac28ec", "core/src/host/project-service-sse.js": "df97d6b11156da885a6a47d50a4a77c8c99b6af5eb0066de14312cc512762297", - "core/src/host/project-service-views.js": "693056fc2e92c0afde24798a2aa7bfe6707f0efae8ea3eedf6445be5d827a1c0", + "core/src/host/project-service-views.js": "742afc90668878e353b5353013f4865763ff496cb1c8d42b8fe1ffb76743d728", "core/src/host/project-service.js": "8ba30c1d7c066867eae06a6612d5bbba3bc2278f34fcf7f1c2242876565dba8f", "core/src/host/project-session-continuation.js": "8d1312bfbe1b69abbaefd8d55dc351bfe19aaa6ae85f16e83a9dd7ff148dfa4d", "core/src/host/project-sources.js": "cf4c11c8e853f3091c4d159b850d06fe7e8bf291fdc616d251eb23d071d63403", diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index 134b3dfec..f0d5c4d98 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -137,3 +137,10 @@ Chief invocation history accepts the combined configured maximum: one initial in #2365 keeps the existing public transport and regenerates OpenAPI for role App status/identity metadata and candidate-phase publication recovery. No public credential issuance API or deployment change; see [role contract](../role-github-apps.md). + + +## Resource SDK authentication — #2398 + +#2398 extends original HealthCheck/Terminal/ExecutionMetadata schemas with profile +binding and sanitized SDK auth facts. Existing permissions and routes remain; only the +explicit terminal administrator receives target paths. OpenAPI is regenerated. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 5d57f2ce9..a77f09993 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -1017,3 +1017,10 @@ no credential HTTP endpoint or second execution service. App-bound native roles saved Owner secrets; other personal/group behavior is unchanged. Dedicated helpers, redacted tools/model inputs and safe execution projections implement the explicit [Owner boundary](../../decisions/2026-11-04-role-github-apps.md). + + +## Resource SDK authentication — #2398 + +#2398 adds safe profile/authentication facts through original worker/health/Task/Chief +views. Authentication errors do not manufacture code/test/Review outcomes. Configuration +and actual login stay in the authorized resource terminal, not a new credential service. diff --git a/docs/subsystems/project-agents.md b/docs/subsystems/project-agents.md index c62ff2d33..418040c17 100644 --- a/docs/subsystems/project-agents.md +++ b/docs/subsystems/project-agents.md @@ -529,3 +529,10 @@ original Draft with its verified author/committer and a Reviewer submits an actu exact-SHA App Review. No role selects credentials or executes merge; absent App mode retains original host sealing/publication. Frozen older policy remains historical; new Attempts capture the updated conventions and delivery template. + + +## Resource SDK authentication — #2398 + +#2398 Chief inspection receives safe actual worker profile/authentication failure facts. +The host does not select another role/resource or automatically escalate; Chief judges +next actions within existing grants. Model login failure is not a Review verdict. diff --git a/docs/subsystems/project-harness.md b/docs/subsystems/project-harness.md index c4869f70d..5753ab54b 100644 --- a/docs/subsystems/project-harness.md +++ b/docs/subsystems/project-harness.md @@ -14,6 +14,9 @@ owned_paths: - core/test/project-task-sessions.test.js - core/src/host/project-git-sandbox.js - core/test/project-git-sandbox.test.js + - core/src/host/project-harness-auth.js + - core/test/project-harness-auth.test.js + - core/test/project-harness-auth-sdk.test.js - core/src/host/project-harness.js - core/src/host/project-codex-config.js - core/test/project-codex-config.test.js @@ -477,3 +480,12 @@ withholding saved Owner keys only for active App roles. Each grant rechecks curr worker/claim/resource/connection; stop disables renewal and cleanup retains honest expiry/unknown-exit facts. Actual SDK fixture acceptance is separate from live App writes. [Contract](../role-github-apps.md). + + +## Resource SDK authentication — #2398 + +#2398 shares the original profile selector across setup, probes and workers. Stable +safe profile IDs omit secrets/paths and preserve generated directories and Task-role +binding keys. Typed SDK authentication errors become authRequired for that profile; +raw provider error text is excluded from evidence. No credential copying, OAuth +manager, automatic role choice or change to SDK sandbox policy. diff --git a/docs/subsystems/project-resources.md b/docs/subsystems/project-resources.md index 8b5009f50..db8c5ecce 100644 --- a/docs/subsystems/project-resources.md +++ b/docs/subsystems/project-resources.md @@ -172,3 +172,11 @@ reservation/batch save failures cannot leak a startup capability before return. Per-execution helpers/private FIFO metadata remain isolated from global auth and ordinary host tests; current resource/lease authority gates every grant. App keys stay on the host. [Configuration and expiry boundaries](../role-github-apps.md). + + +## Resource SDK authentication — #2398 + +#2398 binds SDK readiness to the current profile identity. Legacy observations without +binding and changed profile/container/principal targets require the original runtime +check; probe leases stay eligible to repair readiness. list/select share this projection. +Model authRequired is distinct from environment health and does not prove worker exit. diff --git a/docs/subsystems/project-runtime-setup.md b/docs/subsystems/project-runtime-setup.md index b1762d329..67cea6638 100644 --- a/docs/subsystems/project-runtime-setup.md +++ b/docs/subsystems/project-runtime-setup.md @@ -116,3 +116,13 @@ validated persistent watch actors through the same current Project/resource poli They do not require a browser cookie when a current user grant exists. Revocation still denies or closes the terminal; other users' resources remain unavailable. See [delegation contract](project-delegations.md). + + +## Resource SDK authentication — #2398 + +#2398 returns the actual profile HOME/configDir only to the explicit terminal admin; +public health exposes safe profile IDs. Setup resolves the selected resource SDK CLI, +not the host/global CLI. Login/check/worker use the same selector; changed profiles +invalidate terminal input and ready caches. Close triggers a fresh original probe. +SDK auth codes reach authRequired without raw provider text or new business routing. +No auth.json copying, credential migration, automatic login or live repair is implied. diff --git a/docs/subsystems/project-service-contracts.md b/docs/subsystems/project-service-contracts.md index ff4faaaa3..f72b5e240 100644 --- a/docs/subsystems/project-service-contracts.md +++ b/docs/subsystems/project-service-contracts.md @@ -155,3 +155,10 @@ Attempt SDK publication/Effect references. Whitelists omit credentials and priva helper paths. Original PR metadata distinguishes Dev creator and host merger identities; context pages retain full scoped evidence/recovery history. OpenAPI documents the optional candidate adoption phase of the original publication-retry command. + + +## Resource SDK authentication — #2398 + +#2398 execution metadata includes safe profile identity and known authenticationFailure +codes. No profile paths or provider error payload enter shared Task views. Actual setup +paths are returned only through the existing human resource-administrator terminal. diff --git a/patches/ds-bot/files/src/host/project-chief-control.js b/patches/ds-bot/files/src/host/project-chief-control.js index a74109db9..012acaf5d 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -136,7 +136,7 @@ export function createProjectChiefControl(rt) { const claim = task.collaboration?.claim, worker = rt.state.projectHarnessWorkers?.[claim?.workerId] return { taskId: task.id, chiefActionIssue: task.chiefActionIssue ?? null, chiefDecisionIssue: task.chiefDecisionIssue ?? null, title: task.title, criteria: task.criteria ?? '', projectTaskKind: task.projectTaskKind ?? 'code', status: task.status, taskContract: task.projectTask ? { version: task.projectTask.version, admission: { status: task.projectTask.admission.status, ...(task.projectTask.admission.failure ? { failure: structuredClone(task.projectTask.admission.failure) } : {}) }, requirements: structuredClone(task.projectTask.requirements), wait: structuredClone(task.projectTask.wait ?? null), dependencies: task.projectTask.dependencies.map(row => ({ ...structuredClone(row), current: dependencyFact(rt.state, task, row.taskId) })) } : null, allocationProof: allocationProof(rt.state, task), - execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, + execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, profile: worker?.profile ?? null, authenticationFailure: worker?.authenticationFailure ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, handoff: task.collaboration?.history.at(-1) ? { id: task.collaboration.history.at(-1).id, purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, block: task.collaboration?.block ?? null, ...inspectionResources(rt.state, task), environment: { preparations: Object.values(attempt?.workspaces ?? {}).filter(row => row.environmentPreparation).slice(-6).map(row => ({ id: row.environmentPreparation.id, role: row.role, executor: row.environmentPreparation.executor, status: row.environmentPreparation.status, sha256: row.environmentPreparation.sha256 })), observations: Object.values(rt.state.projectHarnessWorkers ?? {}).filter(row => row.attemptId === attempt?.id && row.projectId === task.projectId && row.taskId === task.id).flatMap(row => (row.environmentObservations ?? []).map(value => ({ ...value, workerId: row.id, role: row.role, resourceId: row.resourceId }))).slice(-8) }, diff --git a/patches/ds-bot/files/src/host/project-harness-auth.js b/patches/ds-bot/files/src/host/project-harness-auth.js new file mode 100644 index 000000000..adfe2f302 --- /dev/null +++ b/patches/ds-bot/files/src/host/project-harness-auth.js @@ -0,0 +1,32 @@ +// Model login belongs to an explicit resource profile, not a Task-role Session. +import { createHash } from 'node:crypto' +import { isAbsolute, join } from 'node:path' +const ownEntry = (object, key) => Object.hasOwn(object, key) ? object[key] : undefined +const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const legacyProfileHash = value => createHash('sha256').update(value).digest('hex') +export function harnessProfile(resource, project, resourceId, harness) { + const principalId = project.ownerUserId + const configured = ownEntry(ownEntry(resource.profiles ?? {}, principalId) ?? {}, harness) + const generated = harness !== 'flybot' && isAbsolute(resource.profileRoot ?? '') ? { home: join(resource.profileRoot, legacyProfileHash(`${project.id}\n${principalId}`).slice(0, 24), harness), configDir: join(resource.profileRoot, legacyProfileHash(`${project.id}\n${principalId}`).slice(0, 24), harness, 'config') } : null + const selected = configured ?? generated + if (!selected || !isAbsolute(selected.home ?? '') || !isAbsolute(selected.configDir ?? '')) throw Object.assign(Error('Resource Harness needs an explicitly authorized principal/profile and model'), { code: 'bot/harness' }) + const profile = { id: hash([resourceId, resource.type, resource.container ?? null, resource.user ?? null, resource.runtimeDir ?? null, principalId, harness, selected.home, selected.configDir, selected.baseUrl ?? null, selected.envRefs ?? {}]), source: configured ? 'configured' : 'generated', resourceId, principalId, harness } + return { selected, principalId, profile } +} +const codes = new Set(['refresh_token_reused', 'refresh_token_expired', 'refresh_token_invalidated', 'invalid_refresh_token', 'invalid_grant', 'invalid_api_key', 'authentication_required', 'authentication_error']) +export function sdkAuthenticationFailure(value, harness) { + const error = value?.error && typeof value.error === 'object' ? value.error : value + let code = error?.code + const message = typeof error?.message === 'string' ? error.message.slice(0, 16000).toLowerCase() : '' + if (!codes.has(code)) { + code = [...codes].find(item => message.includes(item)) + if (!code && /refresh token.{0,160}(?:already.{0,20}used|reused)/s.test(message)) code = 'refresh_token_reused' + if (!code && /refresh token.{0,80}(?:expired|invalidated|revoked)/s.test(message)) code = 'invalid_refresh_token' + if (!code && /(?:not logged in|please (?:log|sign) in|invalid (?:api key|x-api-key)|incorrect api key)/.test(message)) code = 'authentication_required' + if (!code && (error?.status === 401 || error?.statusCode === 401)) code = 'authentication_required' + } + return codes.has(code) ? { harness, code, reauthenticationRequired: true } : null +} +export function authenticationError(failure) { + return Object.assign(Error(`${failure.harness} authentication requires configuration on the selected resource profile (${failure.code})`), { code: 'bot/harness-auth', authenticationFailure: failure }) +} diff --git a/patches/ds-bot/files/src/host/project-harness-turn.js b/patches/ds-bot/files/src/host/project-harness-turn.js index b37743e92..256d344f0 100644 --- a/patches/ds-bot/files/src/host/project-harness-turn.js +++ b/patches/ds-bot/files/src/host/project-harness-turn.js @@ -1,7 +1,8 @@ +import { sdkAuthenticationFailure, authenticationError } from './project-harness-auth.js' import { fileURLToPath } from 'node:url' import { validateCodexConfiguration } from './project-codex-config.js' // One real SDK turn. The host owns Workflow completion and acceptance, never this driver. -export async function runHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { +async function executeHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { const toolsDisabled = request.noTools || request.protocolCorrection === true let text = '', completed = false, sessionId = request.sessionId ?? null, usage = null if (request.harness === 'codex') { @@ -33,6 +34,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const { events } = await thread.runStreamed(request.prompt, { signal }) for await (const event of events) { if (event.type === 'thread.started' && (request.sessionId && event.thread_id !== request.sessionId || request.forbiddenSessionId && event.thread_id === request.forbiddenSessionId)) throw Object.assign(new Error('Codex resumed a different Task-role thread'), { code: 'bot/context-resume' }) + if (['turn.failed', 'error'].includes(event.type)) { const auth = sdkAuthenticationFailure(event, request.harness); if (auth) throw authenticationError(auth) } await emit({ type: 'sdk-event', harness: request.harness, event }) if (event.type === 'thread.started') sessionId = event.thread_id if (event.type === 'item.completed' && event.item?.type === 'agent_message') text = event.item.text @@ -60,6 +62,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida try { for await (const event of stream) { if (event.session_id && (request.sessionId && event.session_id !== request.sessionId || request.forbiddenSessionId && event.session_id === request.forbiddenSessionId)) throw Object.assign(new Error('Claude returned an incompatible Task-role Session'), { code: 'bot/context-resume' }) + if (event.type === 'result' && event.is_error) { const auth = sdkAuthenticationFailure({ message: event.result, code: event.error }, request.harness); if (auth) throw authenticationError(auth) } await emit({ type: 'sdk-event', harness: request.harness, event }) if (event.session_id) sessionId = event.session_id if (event.type === 'result') { @@ -72,3 +75,8 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida if (!completed || !sessionId || signal?.aborted) throw new Error('SDK stream ended without a completed turn and bound Session') return { text, sessionId, usage } } + +export async function runHarnessTurn(request, ports) { + try { return await executeHarnessTurn(request, ports) } + catch (error) { const auth = sdkAuthenticationFailure({ code: error?.authenticationFailure?.code }, request.harness) ?? sdkAuthenticationFailure(error, request.harness); if (auth) throw authenticationError(auth); throw error } +} diff --git a/patches/ds-bot/files/src/host/project-harness-worker.mjs b/patches/ds-bot/files/src/host/project-harness-worker.mjs index e446407c5..de837d66b 100644 --- a/patches/ds-bot/files/src/host/project-harness-worker.mjs +++ b/patches/ds-bot/files/src/host/project-harness-worker.mjs @@ -37,7 +37,7 @@ async function turn(message) { busy = false write(response) if (settings.probe) void shutdown() - } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } + } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), ...(error?.code === 'bot/harness-auth' ? { authenticationFailure: error.authenticationFailure } : {}), reason: ['bot/harness-capability', 'bot/context-resume', 'bot/harness-auth'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } finally { busy = false } } input.on('line', line => { diff --git a/patches/ds-bot/files/src/host/project-harness.js b/patches/ds-bot/files/src/host/project-harness.js index fe54539cc..4094c622c 100644 --- a/patches/ds-bot/files/src/host/project-harness.js +++ b/patches/ds-bot/files/src/host/project-harness.js @@ -1,10 +1,11 @@ +import { harnessProfile, sdkAuthenticationFailure, authenticationError } from './project-harness-auth.js' import { createRoleGithub } from './project-role-github.js' import { roleGithubRuntime } from './project-role-github-runtime.mjs' // Harness worker lifecycle plugs into the existing Workflow. It never accepts a Task. import { randomUUID } from 'node:crypto' import { createHash } from 'node:crypto' import { mkdir, appendFile, readFile, copyFile, writeFile } from 'node:fs/promises' -import { isAbsolute, join, resolve, relative, dirname } from 'node:path' +import { join, resolve, relative, dirname } from 'node:path' import { createInterface } from 'node:readline' import { verifyRolePolicy, roleExecutionPrompt } from './project-role-policy.js' import { sdkContinuationArchive } from './project-session-continuation.js' @@ -46,11 +47,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { rt.projectRoleGithub = github async function profile(projectId, resourceId, harness, { forSetup = false } = {}) { const resource = rt.projectResources.setting(projectId, resourceId) - const principalId = rt.readProject(projectId).ownerUserId - const configured = ownEntry(ownEntry(resource.profiles ?? {}, principalId) ?? {}, harness) - const generated = isAbsolute(resource.profileRoot ?? '') ? { home: join(resource.profileRoot, sha(`${projectId}\n${principalId}`).slice(0, 24), harness), configDir: join(resource.profileRoot, sha(`${projectId}\n${principalId}`).slice(0, 24), harness, 'config') } : null - const selected = configured ?? (harness === 'flybot' ? null : generated) - if (!selected || !isAbsolute(selected.home ?? '') || !isAbsolute(selected.configDir ?? '')) throw refuse('Resource Harness needs an explicitly authorized principal/profile and model') + const { selected, principalId, profile } = harnessProfile(resource, rt.readProject(projectId), resourceId, harness) const env = { PATH: `${resource.nodePath || resource.type === 'local' ? dirname(resource.nodePath ?? process.execPath) + ':' : ''}/usr/local/bin:/usr/bin:/bin`, LANG: 'C.UTF-8', HOME: selected.home, ...(harness === 'flybot' ? { DSH_HOME: selected.configDir } : harness === 'codex' ? { CODEX_HOME: selected.configDir } : { CLAUDE_CONFIG_DIR: selected.configDir, API_TIMEOUT_MS: '30000', CLAUDE_CODE_MAX_RETRIES: '1', CLAUDE_AGENT_SDK_CLIENT_APP: 'flybot' }) } if (resource.type === 'local' && !forSetup) { @@ -70,7 +67,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { if (!/^(?:OPENAI_API_KEY|CODEX_API_KEY|ANTHROPIC_API_KEY|ANTHROPIC_BASE_URL|ANTHROPIC_AUTH_TOKEN)$/.test(name) || typeof reference !== 'string' || !process.env[reference]) throw refuse('Explicit Harness credential reference is unavailable') env[name] = process.env[reference] } - return { resource, principalId, selected, env } + return { resource, principalId, selected, profile, env } } function redact(value, env) { let text = JSON.stringify(value) @@ -152,7 +149,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { const conversation = chief ? ownEntry(rt.state.projectAgents ?? {}, chief.agentId) : null const row = { id, ...(conversation?.roomId ? { roomId: conversation.roomId } : {}), model: session.execution.model ?? selected.selected.model ?? null, chiefRunId: chief?.runId ?? null, chiefInvocation: chief?.runId ? rt.state.projectExecutions?.[chief.runId]?.invocation ?? null : null, visibility: conversation?.visibility ?? 'project', ...(conversation?.visibility === 'private' ? { memberId: conversation.memberId } : {}), projectId: session.projectId, taskId: session.taskId, attemptId: session.attemptId, role: session.role, policyRole: session.policyRole ?? session.definition?.policyRole ?? session.role, sessionKey: session.id, resourceId: session.resourceLease.resourceId, leaseId: session.resourceLease.id, generation: session.resourceLease.generation, - harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, + harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, profile: selected.profile, cwd: workspace.path, scratchDirectory, dependencyPreparationId: session.environmentPreparation?.id ?? null, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } if (logical) Object.assign(row, { logicalSessionId: logical.id, bindingKey, bindingVersion: 'task-role-binding/v3', contextIdentityKey: backendContextKey({ ...identity, definition: definitionKey }), definitionKey, ...(previous ? { ...(previous.restoration?.mode === 'reconstructed' ? {} : { backendSessionId: previous.sessionId }), ...(previous.restoration ? { restoration: previous.restoration } : {}), continuedFrom: previous.workerId, ...(previous.key !== bindingKey ? { migratedBindingKey: previous.key } : {}) } : { rotationReason: 'First Task-role execution' }) }) else if (chief) Object.assign(row, { logicalSessionId: chief.agentId, bindingKey, ...(chief.resumeSessionId ? { backendSessionId: chief.resumeSessionId, continuedFrom: chief.previousWorkerId } : {}) }) @@ -219,7 +216,9 @@ export function createProjectHarness(rt, { roleGithub } = {}) { const currentLease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) if (active.get(id) !== control || row.status === 'released' || currentLease?.generation !== row.generation || currentLease.status === 'released') return if (line.length > 1024 * 1024) throw refuse('SDK event exceeds maximum size') - const event = github.redact(redact(JSON.parse(line), control.env)) + let event = github.redact(redact(JSON.parse(line), control.env)) + const authentication = event.type === 'failure' && sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, row.harness) + if (authentication) event = { type: 'failure', ...(event.turnId ? { turnId: event.turnId } : {}), authenticationFailure: authentication, reason: authenticationError(authentication).message } if (event.type === 'github-auth-request') { if (!control.githubScope || control.correctionTurnId || !/^[a-f0-9-]{36}$/.test(event.id ?? '')) throw refuse('Unexpected role credential request') row.githubAccess.requests = (row.githubAccess.requests ?? 0) + 1 @@ -321,11 +320,21 @@ export function createProjectHarness(rt, { roleGithub } = {}) { if (event.type === 'configuration-checked' && event.harness === 'codex' && event.capability?.kind === 'codex-cli-config/v1' && event.capability.version === VERSIONS.codex && event.capability.modelInvoked === false) row.configurationCheck = { kind: event.capability.kind, version: event.capability.version, profile: event.capability.profile === 'chief-read-only' ? 'chief-read-only' : 'flybot-role', at: Date.now(), modelInvoked: false } if (event.type === 'failure') { row.status = 'failed' + const auth = sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, row.harness) + if (auth) { + row.authenticationFailure = auth + let currentProfile + try { currentProfile = harnessProfile(rt.projectResources.setting(row.projectId, row.resourceId), rt.readProject(row.projectId), row.resourceId, row.harness).profile } catch { /* Retain the worker failure without overwriting a removed/revoked profile's health. */ } + if (currentProfile?.id === row.profile.id) { + rt.state.projectHarnessHealth ??= {} + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'authRequired', reason: `${row.harness} authentication requires configuration on the selected resource profile (${auth.code})`, authenticationFailure: auth, profile: row.profile, principalId: row.principalId, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) + } + } if (event.contextFailure?.code === 'bot/context-resume' && event.contextFailure.kind === 'backend-session-mismatch') row.contextFailure = { code: 'bot/context-resume', kind: 'backend-session-mismatch', contextPreserved: true } if (event.capabilityFailure?.code === 'bot/harness-capability' && event.capabilityFailure.stage === 'configuration' && event.capabilityFailure.modelInvoked === false && ['missing-cli', 'unsupported-cli-version', 'unsupported-platform', 'cli-unavailable', 'cli-configuration', 'legacy-sandbox-conflict', 'permission-profile-mismatch'].includes(event.capabilityFailure.kind)) { row.capabilityFailure = event.capabilityFailure rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', reason: event.reason, capabilityFailure: event.capabilityFailure, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', profile: row.profile, reason: event.reason, capabilityFailure: event.capabilityFailure, resourceId: row.resourceId, harness: row.harness, at: Date.now() }) } await report(row, false, event.reason) } @@ -340,7 +349,7 @@ export function createProjectHarness(rt, { roleGithub } = {}) { catch (error) { row.failure = { reason: error.message, at: Date.now() } rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', reason: error.message, sdkVersion: control.observedSdkVersion ?? null, expectedSdkVersion: VERSIONS[row.harness], principalId: row.principalId, at: Date.now(), resourceId: row.resourceId, harness: row.harness }) + setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', profile: row.profile, reason: error.message, sdkVersion: control.observedSdkVersion ?? null, expectedSdkVersion: VERSIONS[row.harness], principalId: row.principalId, at: Date.now(), resourceId: row.resourceId, harness: row.harness }) await release(id); throw error } finally { clearTimeout(handshakeTimer) } diff --git a/patches/ds-bot/files/src/host/project-resources.js b/patches/ds-bot/files/src/host/project-resources.js index 196380026..823b45063 100644 --- a/patches/ds-bot/files/src/host/project-resources.js +++ b/patches/ds-bot/files/src/host/project-resources.js @@ -1,3 +1,4 @@ +import { harnessProfile } from './project-harness-auth.js' // Operator-provisioned execution capabilities; Project configuration only references IDs. import { nativeExitConfirmed } from './project-native-sessions.js' import { captureProcessScope, priorScopeExited, supervisorProofMatches } from './project-process-proof.js' @@ -40,13 +41,21 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (lease.bindingHash && bindingHash(item) !== lease.bindingHash) throw failure('Execution resource binding changed; migration is not permitted') return item } + function harnessHealth(projectId, resourceId, harness) { + const saved = ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${resourceId}:${harness}`) + if (harness === 'flybot') return saved ?? { status: 'native-host-provider' } + let profile + try { profile = harnessProfile(setting(projectId, resourceId), rt.readProject(projectId), resourceId, harness).profile } + catch { return { status: 'unconfigured', reason: 'Explicit SDK profile is unavailable', at: null } } + return saved?.profile?.id === profile.id ? saved : { status: 'unconfigured', reason: 'Selected SDK profile has not been checked; open its resource configuration terminal', profile, at: null } + } function list(projectId, actor) { return Object.keys(resources()).flatMap(id => { try { const item = setting(projectId, id) const observation = ownEntry(state.projectResourceHealth ?? {}, id) return [{ id, type: item.type, canManage: Boolean(actor && item.adminMemberIds?.includes(actor.memberId)), capacity: { configured: item.capacity, occupied: occupied(id) }, harnesses: item.harnesses ?? [], - harnessStatus: (item.harnesses ?? []).map(harness => ({ harness, ...(ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${id}:${harness}`) ?? { status: harness === 'flybot' ? 'native-host-provider' : 'unconfigured' }) })), + harnessStatus: (item.harnesses ?? []).map(harness => ({ harness, ...harnessHealth(projectId, id, harness) })), health: observation ?? { status: 'unconfigured', reason: 'Environment has not been probed', at: null }, metrics: { cpu: null, memory: null, basis: 'unavailable' }, platformInstanceId: item.platformInstanceId ?? null }] } catch { return [] /* Unauthorized/malformed resources are not enumerable. */ } @@ -64,7 +73,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (!candidates.length) throw failure('No compatible execution resource in the selected pool') const available = candidates.filter(({ id, item }) => { const health = ownEntry(state.projectResourceHealth ?? {}, id) - const auth = ownEntry(state.projectHarnessHealth ?? {}, `${projectId}:${id}:${harness}`) + const auth = harnessHealth(projectId, id, harness) return health?.status === 'ready' && (harness === 'flybot' || request.purpose === 'probe' || auth?.status === 'ready') && usage(id) < item.capacity }) if (!available.length) throw failure('Execution resource capacity or readiness is unavailable') @@ -352,7 +361,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} state.projectResourceHealth ??= {}; setEntry(state.projectResourceHealth, resourceId, row); await rt.save() return structuredClone(row) } - return { setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, + return { harnessHealth, setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, preparationUncertain: reference => serial(() => preparationUncertain(reference)), beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), reserve: request => serial(() => reserve(request)), markStarted: (...args) => serial(() => markStarted(...args)), release: (...args) => serial(() => release(...args)) } } diff --git a/patches/ds-bot/files/src/host/project-runtime-setup.js b/patches/ds-bot/files/src/host/project-runtime-setup.js index ccbd9c1e5..22bb4bf87 100644 --- a/patches/ds-bot/files/src/host/project-runtime-setup.js +++ b/patches/ds-bot/files/src/host/project-runtime-setup.js @@ -1,7 +1,9 @@ +import { harnessProfile, sdkAuthenticationFailure } from './project-harness-auth.js' // Owner setup: bounded connectivity probes and private target-resource PTYs. import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' -import { join, resolve } from 'node:path' +import { join, resolve, dirname } from 'node:path' +import { pathToFileURL } from 'node:url' import { ownEntry, setEntry } from './store.js' import { createPluginModel } from './plugin-model.js' const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) @@ -25,7 +27,7 @@ export function createProjectRuntimeSetup(rt) { if (!model) return { status: 'unconfigured', reason: 'Set an explicit SDK model on this role/profile' } const id = `probe-${randomUUID()}`, cwd = join(resource.root, 'connection-probes', id) const lease = await rt.projectResources.reserve({ projectId: project.id, attemptId: id, sessionKey: id, role: 'probe', harness: body.harness, resourceIds: [body.resourceId], purpose: 'probe' }) - let child, exited = false, stopped = false, completion = false, authError = false, observedSdkVersion = null, readyObserved = false, startupFailed = false + let child, exited = false, stopped = false, completion = false, authError = false, authenticationFailure = null, observedSdkVersion = null, readyObserved = false, startupFailed = false try { await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['mkdir', '-p', cwd] }) await rt.projectResources.command(project.id, body.resourceId, { cwd, argv: ['git', 'init', '-q'] }) @@ -46,7 +48,7 @@ export function createProjectRuntimeSetup(rt) { if (event.type === 'ready') { readyObserved = true; const version = event.sdk?.[body.harness]; observedSdkVersion = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/.test(version ?? '') ? version : null; handshake() } if (event.type === 'stopped') stopped = event.released === true if (event.type === 'completed') { completion = true; finish() } - if (event.type === 'failure') finish() + if (event.type === 'failure') { authenticationFailure = sdkAuthenticationFailure({ code: event.authenticationFailure?.code }, body.harness); if (authenticationFailure) authError = true; finish() } if (/401|403|api.key|authentication|unauthorized|login required/i.test(line)) authError = true } catch { /* Malformed probe output is not successful authentication. */ } }) @@ -59,21 +61,24 @@ export function createProjectRuntimeSetup(rt) { ...(selected.selected.baseUrl ? { baseUrl: selected.selected.baseUrl } : {}) }, prompt: 'Reply ready. Do not use tools.' }) + '\n') await Promise.race([finished, closed, delay(10_000)]) if (!exited) { child.stdin.end(JSON.stringify({ type: 'stop' }) + '\n'); await Promise.race([closed, delay(6000)]) } - return { status: completion && exited && stopped ? 'ready' : authError ? 'authRequired' : 'environmentError', reason: completion && exited && stopped ? 'Minimal SDK inference completed; this is not task execution' : authError ? 'SDK authentication is required or was rejected' : 'SDK/runtime probe did not complete within its bound', model, sdkVersion: observedSdkVersion } + return { profile: selected.profile, ...(authenticationFailure ? { authenticationFailure } : {}), status: completion && exited && stopped ? 'ready' : authError ? 'authRequired' : 'environmentError', reason: completion && exited && stopped ? 'Minimal SDK inference completed; this is not task execution' : authError ? `SDK authentication requires configuration on the selected resource profile${authenticationFailure ? ' (' + authenticationFailure.code + ')' : ''}` : 'SDK/runtime probe did not complete within its bound', model, sdkVersion: observedSdkVersion } } finally { await rt.projectResources.release(lease.id, lease.generation, ...(child ? [{ handle: id, exited: exited && (stopped || startupFailed) }] : [])) if (exited && (stopped || startupFailed) || !child) await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['rm', '-rf', '--', cwd] }).catch(() => {}) // Owned probe directory only; failed cleanup is not auth success. } } - async function check(body, actor) { + async function check(body, actor, { fresh = false } = {}) { const { project, resource } = await authorized(body, actor) const key = `${project.id}:${body.resourceId}:${body.harness}` - if (checks.has(key)) return checks.get(key) - const previous = ownEntry(rt.state.projectHarnessHealth ?? {}, key) - if (previous && Date.now() - previous.at < 3000 && previous.status !== 'checking') return structuredClone(previous) + let profile = null + try { if (body.harness !== 'flybot') profile = harnessProfile(resource, project, body.resourceId, body.harness).profile } catch { /* The existing SDK configuration error result below owns incomplete profiles. */ } + const checkKey = key + ':' + (profile?.id ?? 'native-or-unconfigured') + if (checks.has(checkKey)) return checks.get(checkKey) + const previous = rt.projectResources.harnessHealth(project.id, body.resourceId, body.harness) + if (!fresh && previous && Date.now() - previous.at < 3000 && previous.status !== 'checking') return structuredClone(previous) const promise = (async () => { rt.state.projectHarnessHealth ??= {} - setEntry(rt.state.projectHarnessHealth, key, { status: 'checking', at: Date.now(), principalId: project.ownerUserId }) + setEntry(rt.state.projectHarnessHealth, key, { status: 'checking', profile, at: Date.now(), principalId: project.ownerUserId }) await rt.save() let result const environment = await rt.projectResources.probe(project.id, body.resourceId) @@ -89,10 +94,14 @@ export function createProjectRuntimeSetup(rt) { try { result = await probeSDK(body, project, resource) } catch (failure) { result = { status: failure.code === 'bot/resource' ? 'unreachable' : failure.code === 'bot/harness' ? 'unconfigured' : 'environmentError', reason: failure.code === 'bot/resource' ? 'Resource capacity or environment is unavailable' : 'Explicit SDK profile, model or runtime configuration is incomplete' } } } - const row = { ...result, at: Date.now(), principalId: project.ownerUserId, sdkVersion: result.sdkVersion ?? null, expectedSdkVersion: rt.projectHarness.versions[body.harness] ?? null, resourceId: body.resourceId, harness: body.harness } + await authorized(body, actor) + let currentProfile = null + try { if (body.harness !== 'flybot') currentProfile = harnessProfile(rt.projectResources.setting(project.id, body.resourceId), rt.readProject(project.id), body.resourceId, body.harness).profile } catch { /* A changed/removed profile cannot inherit the old probe outcome. */ } + if (profile?.id !== currentProfile?.id) return { status: 'unconfigured', reason: 'SDK profile changed during the check; inspect the current resource profile', profile: currentProfile, resourceId: body.resourceId, harness: body.harness, at: Date.now() } + const row = { ...result, profile, at: Date.now(), principalId: project.ownerUserId, sdkVersion: result.sdkVersion ?? null, expectedSdkVersion: rt.projectHarness.versions[body.harness] ?? null, resourceId: body.resourceId, harness: body.harness } setEntry(rt.state.projectHarnessHealth, key, row); await rt.save(); rt.projectScheduler?.wake(); return structuredClone(row) - })().finally(() => checks.delete(key)) - checks.set(key, promise); return promise + })().finally(() => checks.delete(checkKey)) + checks.set(checkKey, promise); return promise } async function close(terminal, reason) { if (terminal.closed) return @@ -102,7 +111,7 @@ export function createProjectRuntimeSetup(rt) { terminal.closed = true; terminal.output = [] Object.assign(ownEntry(audits(), terminal.id), { closedAt: Date.now(), reason, released: terminal.released === true }) await rt.save() - if (reason === 'owner-close') void check(terminal.target, terminal.actor).catch(() => {}) // Current grants are rechecked; a failed recheck does not reopen the terminal. + if (reason === 'owner-close') void check(terminal.target, terminal.actor, { fresh: true }).catch(() => {}) // Current grants are rechecked; a failed recheck does not reopen the terminal. } async function open(body, actor) { const { project, resource } = await authorized(body, actor, true) @@ -111,8 +120,20 @@ export function createProjectRuntimeSetup(rt) { const selected = await rt.projectHarness.profile(project.id, body.resourceId, body.harness, { forSetup: true }) const runtime = resource.runtimeDir ?? resolve(import.meta.dirname, '../..') await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['mkdir', '-p', selected.selected.home, selected.selected.configDir] }) + let codexLoginAvailable = false + if (body.harness === 'codex') { + try { + const module = pathToFileURL(join(runtime, 'src/host/project-codex-config.js')).href + const result = await rt.projectResources.command(project.id, body.resourceId, { cwd: resource.root, argv: ['node', '--input-type=module', '-e', `import {codexExecutable} from ${JSON.stringify(module)}; process.stdout.write(JSON.stringify(codexExecutable()))`] }) + const executable = JSON.parse(result.stdout).executable + if (!executable || !executable.startsWith('/')) throw Error('Invalid resource CLI path') + selected.env.PATH = dirname(executable) + ':' + selected.env.PATH + codexLoginAvailable = true + } catch { /* A missing SDK must not block its own setup terminal; never substitute an ambient CLI. */ } + } + const process = rt.projectResources.startProcess(project.id, body.resourceId, { cwd: resource.root, argv: ['python3', '-u', join(runtime, 'src/host/project-terminal-worker.py')] }) - const terminal = { id: `terminal-${randomUUID()}`, actor: { ...actor }, target: { projectId: project.id, resourceId: body.resourceId, harness: body.harness, role: body.role }, process, + const terminal = { id: `terminal-${randomUUID()}`, profile: selected.profile, actor: { ...actor }, target: { projectId: project.id, resourceId: body.resourceId, harness: body.harness, role: body.role }, process, output: [], seq: 0, createdAt: Date.now(), lastSeen: Date.now(), expiresAt: Date.now() + 10 * 60_000, released: false } terminal.exited = new Promise(resolve => process.once('close', () => { terminal.processExited = true; resolve() })) process.stdin.on('error', () => { terminal.processInputEnded = true }) @@ -129,17 +150,19 @@ export function createProjectRuntimeSetup(rt) { } catch { /* PTY protocol failures do not become screen/log output. */ } }) terminals.set(terminal.id, terminal) - setEntry(audits(), terminal.id, { id: terminal.id, projectId: project.id, resourceId: body.resourceId, harness: body.harness, principalId: selected.principalId, initiatedBy: actor.memberId, createdAt: terminal.createdAt }) + setEntry(audits(), terminal.id, { id: terminal.id, projectId: project.id, resourceId: body.resourceId, harness: body.harness, principalId: selected.principalId, profile: selected.profile, initiatedBy: actor.memberId, createdAt: terminal.createdAt }) process.stdin.write(JSON.stringify({ cwd: selected.selected.home, env: { ...selected.env, TERM: 'xterm-256color', FLYBOT_PROFILE_DIR: selected.selected.configDir, FLYBOT_SETUP_HARNESS: body.harness } }) + '\n') await rt.save() - return { terminalId: terminal.id, expiresAt: terminal.expiresAt, resourceId: body.resourceId, harness: body.harness, - instructions: body.harness === 'flybot' ? 'Configure the explicitly registered native DSH profile using its provider setup commands. Close to recheck.' : `Configure this private profile. To save an API key with hidden input: node ${JSON.stringify(join(runtime, 'src/host/project-profile-auth.mjs'))}. Codex may also use its supported login/device flow in this same profile. Claude Agent SDK uses API/cloud credentials.` } + return { terminalId: terminal.id, expiresAt: terminal.expiresAt, resourceId: body.resourceId, harness: body.harness, profile: { ...selected.profile, home: selected.selected.home, configDir: selected.selected.configDir }, ...(body.harness === 'codex' ? { codexLoginAvailable } : {}), + instructions: body.harness === 'flybot' ? 'Configure the explicitly registered native DSH profile using its provider setup commands. Close to recheck.' : `Configure this private profile. To save an API key with hidden input: node ${JSON.stringify(join(runtime, 'src/host/project-profile-auth.mjs'))}. ${body.harness === 'codex' ? codexLoginAvailable ? 'The SDK-resolved Codex CLI is on PATH; use codex login --device-auth here.' : 'The pinned Codex CLI is unavailable; provision the selected runtime before Codex login.' : ''} Codex login/device flow must run in this terminal's CODEX_HOME; this is the same profile used by the SDK worker. A cached login is not proof that token refresh succeeds. Do not copy old auth caches into new Attempts. Claude Agent SDK uses API/cloud credentials.` } } async function access(body, actor, heartbeat = true) { const terminal = terminals.get(body.terminalId) if (!terminal || terminal.actor.memberId !== actor.memberId || terminal.actor.sessionId !== actor.sessionId) throw refuse('This terminal belongs to another authenticated Session') try { await authorized(terminal.target, actor, true) + const resource = rt.projectResources.setting(terminal.target.projectId, terminal.target.resourceId) + if (harnessProfile(resource, rt.readProject(terminal.target.projectId), terminal.target.resourceId, terminal.target.harness).profile.id !== terminal.profile.id) { await close(terminal, 'profile-changed'); throw refuse('Resource profile changed; reopen its configuration terminal') } const session = ownEntry(rt.state.authSessions ?? {}, actor.sessionId) if (!session || session.revokedAt || Date.now() > terminal.expiresAt || rt.platformIdentityEnabled && !await (rt.validateHumanActor ? rt.validateHumanActor(actor) : rt.validatePlatformActor(actor))) throw refuse('Configuration terminal expired or access was revoked') } catch (error) { await close(terminal, 'authorization-ended'); throw error } diff --git a/patches/ds-bot/files/src/host/project-service-views.js b/patches/ds-bot/files/src/host/project-service-views.js index a79b97d85..16604c021 100644 --- a/patches/ds-bot/files/src/host/project-service-views.js +++ b/patches/ds-bot/files/src/host/project-service-views.js @@ -89,7 +89,7 @@ export function executionMetadataView(state, row) { const exit = native ? nativeExitConfirmed(state, row) : confirmedWorkerExit(state, row) return { ...pick(row, ['id', 'role', 'resourceId', 'generation', 'sessionKey', 'logicalSessionId', 'createdAt', 'executionStartedAt', 'releasedAt']), githubAccess: row.githubAccess ? pick(row.githubAccess, ['version', 'role', 'policyRole', 'connectionId', 'revision', 'identity', 'sha256', 'status', 'expiresAt', 'lastIssuedAt', 'renewing']) : null, - contextFailure: row.contextFailure ?? null, environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, + profile: row.profile ? pick(row.profile, ['id', 'source', 'resourceId', 'principalId', 'harness']) : null, authenticationFailure: row.authenticationFailure ? pick(row.authenticationFailure, ['harness', 'code', 'reauthenticationRequired']) : null, contextFailure: row.contextFailure ?? null, environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, contextRestoration: row.restoration ? pick(row.restoration, ['version', 'mode', 'logicalSessionId', 'contextIdentityKey', 'sourceWorkerId', 'sourceSessionId', 'physicalSessionId', 'sourceAttemptId', 'attemptId', 'workspaceId', 'checkout', 'archiveSha256', 'observedCheckout', 'at']) : null, harness: native ? 'flybot' : row.harness ?? null, status: row.status, startupPhase: native ? null : row.spawnPhase ?? null, policyRole: session?.policyRole ?? row.policyRole ?? row.role ?? null, diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index 86d3354c7..6b4ae3552 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -1,3 +1,4 @@ +import { harnessProfile } from '../src/host/project-harness-auth.js' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' @@ -386,7 +387,7 @@ write({type:'completed',sessionId:'contract-'+process.pid,text,usage:{input_toke rt.config.projectResources = { execution: { type: 'local', root: directory, runtimeDir, capacity: 3, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex', 'claude-agent'], profiles: { 'owner-1': { codex: { home: join(directory, 'codex-home'), configDir: join(directory, 'codex-config'), model: 'contract-model' }, 'claude-agent': { home: join(directory, 'claude-home'), configDir: join(directory, 'claude-config'), model: 'contract-model' } } } } } rt.state.projectResourceHealth = { execution: { status: 'ready', at: Date.now() } } - rt.state.projectHarnessHealth = { 'p1:execution:codex': { status: 'ready' }, 'p1:execution:claude-agent': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p1:execution:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['execution'], rt.readProject('p1'), 'execution', 'codex').profile }, 'p1:execution:claude-agent': { status: 'ready', profile: harnessProfile(rt.config.projectResources['execution'], rt.readProject('p1'), 'execution', 'claude-agent').profile } } for (const role of ['developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: role === 'developer' ? 'codex' : 'claude-agent', resourceIds: ['execution'] } }, alice) const started = await fixture.start('full-sdk-contract') assert.equal(started.attempt.status, 'running', started.attempt.failure?.message) @@ -498,7 +499,7 @@ write({type:'sdk-event',event:{type:'thread.started',thread_id:m.settings.sessio });`) rt.config.projectResources = { one: { type: 'local', root: directory, runtimeDir, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(directory, 'sdk-home'), configDir: join(directory, 'sdk-config'), model: 'contract-model' } } } } } rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['one'], rt.readProject('p1'), 'one', 'codex').profile } } for (const role of ['developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: 'codex', resourceIds: ['one'] } }, alice) const started = await fixture.start('dynamic-pool') assert.ok(started.attempt, `One free resource must admit only the receiving role, instead of reserving the full DAG: ${JSON.stringify(started.queue)}`) @@ -553,7 +554,7 @@ else text='OUTPUT handoff: '+JSON.stringify({action:'handoff',to:'p1:reviewer',r write({type:'sdk-event',event:{type:'thread.started',thread_id:m.settings.sessionId??('chief-contract-'+process.pid)}});write({type:'completed',sessionId:m.settings.sessionId??('chief-contract-'+process.pid),text,usage:{input_tokens:1}}); });`) rt.config.projectResources = { one: { type: 'local', root: directory, runtimeDir, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(directory, 'chief-sdk-home'), configDir: join(directory, 'chief-sdk-config'), model: 'contract-model' } } } } } - rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } }; rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } }; rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['one'], rt.readProject('p1'), 'one', 'codex').profile } } for (const role of ['chief', 'developer', 'tester', 'reviewer']) await rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role, execution: { harness: 'codex', resourceIds: ['one'] } }, alice) rt.state.projectAgents ??= {} rt.state.projectAgents['public-p1'] = { id: 'public-p1', projectId: 'p1', visibility: 'public', memberId: null, botId: 'a', sessionId: 'a', carrierGeneration: 1 } @@ -819,7 +820,7 @@ test('failed context admission preserves an existing unsealed developer workspac test('context failure releases an actual unbound reservation before registering any SDK Session',async t=>{ const f=await setup(t,undefined,{pool:true});f.rt.projectPools.connect({validate:()=>({ok:true})}) f.rt.config.projectResources={one:{type:'local',root:f.directory,capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex'],profiles:{'owner-1':{codex:{home:join(f.directory,'sdk-home'),configDir:join(f.directory,'sdk-config'),model:'fixture/model'}}}}} - f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex':{status:'ready'}} + f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile }} await f.rt.projectEndpoint('project-agent-definition',{projectId:'p1',role:'developer',execution:{harness:'codex',resourceIds:['one']}},alice) await f.start('unbound-context-failure') const a=f.attempt(),task=f.rt.state.tasks[f.task.id],dev=f.rt.projectAgents.deliveryDefinitions('p1').roles.find(r=>r.role==='developer') @@ -891,7 +892,7 @@ test('workspace allocation failure releases an actual reservation before any ext f.rt.projectScheduler.wake = () => {} f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } f.rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) await f.start('allocation-failure') const a = f.attempt(), task = f.rt.state.tasks[f.task.id] @@ -1044,7 +1045,7 @@ test('bound-resource preparation executes before SDK registration and failure re f.rt.projectScheduler.wake = () => {} f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } f.rt.state.projectResourceHealth = { one: { status: 'ready', at: Date.now() } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) const settings = f.rt.config.projectRepositories['example/demo'] settings.workspacePrepareCommand = [process.execPath, '-e', "if(!require('fs').existsSync('code.mjs'))process.exit(31)"] @@ -1196,7 +1197,7 @@ test('SDK Task rework preserves an unsealed same-base draft and old cleanup cann const f=await setup(t,undefined,{pool:true}) f.rt.projectScheduler.wake=()=>{};f.rt.projectPools.connect({validate:()=>({ok:true})}) f.rt.config.projectResources={one:{type:'local',root:f.directory,capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex'],profiles:{'owner-1':{codex:{home:join(f.directory,'sdk-home'),configDir:join(f.directory,'sdk-config'),model:'fixture/model'}}}}} - f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex':{status:'ready'}} + f.rt.state.projectResourceHealth={one:{status:'ready',at:Date.now()}};f.rt.state.projectHarnessHealth={'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile }} await f.rt.projectEndpoint('project-agent-definition',{projectId:'p1',role:'developer',execution:{harness:'codex',resourceIds:['one']}},alice) await f.start('retained-draft') const previous=f.attempt(),task=f.rt.state.tasks[f.task.id],dev=f.rt.projectAgents.deliveryDefinitions('p1').roles.find(r=>r.role==='developer') @@ -1222,8 +1223,9 @@ test('a returned SDK draft reuses its Task workspace after exact supervisor exit const f = await setup(t, undefined, { pool: true }) f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) f.rt.config.projectResources = { one: { type: 'local', root: f.directory, capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'] } } + f.rt.config.projectResources.one.profileRoot = join(f.directory, 'proof-profile') f.rt.state.projectResourceHealth = { one: { status: 'ready' } } - f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready' } } + f.rt.state.projectHarnessHealth = { 'p1:one:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['one'], f.rt.readProject('p1'), 'one', 'codex').profile } } await f.rt.projectEndpoint('project-agent-definition', { projectId: 'p1', role: 'developer', execution: { harness: 'codex', resourceIds: ['one'] } }, alice) await f.start('supervised-draft') const previous = f.attempt(), task = f.rt.state.tasks[f.task.id] @@ -1400,7 +1402,8 @@ test('an approved operator object reaches a resource clone through the existing git(f.repository,['update-ref',ref,sha]);git(f.repository,['reset','--hard',base]) const remote=join(f.directory,'emulated-resource');mkdirSync(remote) f.rt.config.projectResources={remote:{type:'docker',root:remote,container:'fixture',user:'1000:1000',capacity:1,allowedProjectOwnerIds:['owner-1'],harnesses:['codex']}} - f.rt.state.projectResourceHealth={remote:{status:'ready'}};f.rt.state.projectHarnessHealth={'p1:remote:codex':{status:'ready'}} + f.rt.config.projectResources.remote.profileRoot = join(f.directory, 'proof-profile') + f.rt.state.projectResourceHealth={remote:{status:'ready'}};f.rt.state.projectHarnessHealth={'p1:remote:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['remote'], f.rt.readProject('p1'), 'remote', 'codex').profile }} // Local transport emulation exercises real Git/bundle hashes, not Docker isolation. f.rt.projectResources.command=async(_p,_r,{argv,cwd})=>({stdout:execFileSync(argv[0],argv.slice(1),{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']})}) f.rt.projectResources.uploadFile=async(_p,_r,source,target)=>copyFileSync(source,target) @@ -1806,7 +1809,7 @@ async function baselineResourceFixture(t, kind = 'local') { f.rt.projectScheduler.wake = () => Promise.resolve(); f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) const resourceRoot = join(f.directory, 'resource'); mkdirSync(resourceRoot) f.rt.config.projectResources = { baseline: { type: kind, root: resourceRoot, ...(kind === 'docker' ? { container: 'isolated-fixture', user: '1000:1000' } : {}), capacity: 1, allowedProjectOwnerIds: ['owner-1'], harnesses: ['codex'], profiles: { 'owner-1': { codex: { home: join(f.directory, 'sdk-home'), configDir: join(f.directory, 'sdk-config'), model: 'fixture/model' } } } } } - f.rt.state.projectResourceHealth = { baseline: { status: 'ready' } }; f.rt.state.projectHarnessHealth = { 'p1:baseline:codex': { status: 'ready' } } + f.rt.state.projectResourceHealth = { baseline: { status: 'ready' } }; f.rt.state.projectHarnessHealth = { 'p1:baseline:codex': { status: 'ready', profile: harnessProfile(f.rt.config.projectResources['baseline'], f.rt.readProject('p1'), 'baseline', 'codex').profile } } if (kind === 'docker') { // Real Git and bundle verification, emulated transport; actual Docker acceptance is separate. f.rt.projectResources.command = async (_p, _r, { argv, cwd }) => ({ stdout: execFileSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }) diff --git a/patches/ds-bot/files/test/project-harness-auth-sdk.test.js b/patches/ds-bot/files/test/project-harness-auth-sdk.test.js new file mode 100644 index 000000000..27def6845 --- /dev/null +++ b/patches/ds-bot/files/test/project-harness-auth-sdk.test.js @@ -0,0 +1,37 @@ +// Opt-in actual pinned Codex SDK/CLI + worker; provider response is an isolated fixture. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createServer } from 'node:http' +import { spawn, execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createInterface } from 'node:readline' +if (process.env.FLYBOT_TEST_AUTH_SDK === '1') test('actual Codex SDK worker preserves refresh-token failure without provider payload or successful completion', { timeout: 45000 }, async () => { + const root = await mkdtemp(join(tmpdir(),'flybot-auth-sdk-')), home=join(root,'home'), cwd=join(root,'cwd') + await mkdir(home); await mkdir(cwd); execFileSync('git',['init','-q',cwd]) + const secret='fixture-private-provider-auth-details', server=createServer(async (request,response)=>{ + await new Promise(resolve => { request.on('end', resolve); request.resume() }) + response.writeHead(401,{'content-type':'application/json'}) + response.end(JSON.stringify({error:{type:'authentication_error',code:'refresh_token_reused',message:'Your refresh token has already been used. Please log in again. '+secret}})) + }) + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) + const child=spawn(process.execPath,[resolve(import.meta.dirname,'../src/host/project-harness-worker.mjs')],{env:{PATH:process.env.PATH},stdio:['pipe','pipe','pipe']}) + const frames=[],lines=createInterface({input:child.stdout}); child.stderr.resume() + let finish, reject, timer + const result=new Promise((resolve,rejected)=>{finish=resolve;reject=rejected;timer=setTimeout(()=>rejected(Error('Actual worker auth fixture timed out')),30000)}) + const exit=new Promise(resolve=>child.once('close',resolve)) + lines.on('line',line=>{ + const frame=JSON.parse(line);frames.push(frame) + if(frame.type==='ready')child.stdin.write(JSON.stringify({type:'start',settings:{harness:'codex',cwd,model:'fixture-model',noTools:true,env:{HOME:home,CODEX_HOME:home,PATH:process.env.PATH},config:{model_provider:'fixture',model_providers:{fixture:{name:'fixture',base_url:`http://127.0.0.1:${server.address().port}`,wire_api:'responses',requires_openai_auth:false}}}},prompt:'Isolated auth failure probe'})+'\n') + if(frame.type==='failure'){finish(frame);child.stdin.end(JSON.stringify({type:'stop'})+'\n')} + if(frame.type==='completed')reject(Error('Authentication failure must not become a completed turn')) + }) + try{ + const failure=await result + assert.equal(failure.authenticationFailure?.code,'refresh_token_reused',JSON.stringify(frames)) + assert.ok(!JSON.stringify(frames).includes(secret)) + await exit + assert.ok(frames.some(frame=>frame.type==='stopped'&&frame.released===true)) + }finally{clearTimeout(timer);child.kill('SIGTERM');lines.close();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));await rm(root,{recursive:true,force:true})} +}) diff --git a/patches/ds-bot/files/test/project-harness-auth.test.js b/patches/ds-bot/files/test/project-harness-auth.test.js new file mode 100644 index 000000000..159f6f482 --- /dev/null +++ b/patches/ds-bot/files/test/project-harness-auth.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createHash } from 'node:crypto' +import { harnessProfile, sdkAuthenticationFailure, authenticationError } from '../src/host/project-harness-auth.js' +test('profile identity preserves generated paths and explicit existing login reuse without Task/role copies', () => { + const project = { id: 'p', ownerUserId: 'owner' }, resource = { type: 'docker', container: 'one', user: '1000', profileRoot: '/profiles' } + const first = harnessProfile(resource, project, 'r', 'codex') + assert.equal(first.selected.configDir, '/profiles/' + createHash('sha256').update('p\nowner').digest('hex').slice(0,24) + '/codex/config') + assert.deepEqual(harnessProfile(resource, { ...project, taskId: 'another', role: 'reviewer' }, 'r', 'codex').profile, first.profile) + resource.profiles = { owner: { codex: { home: '/existing', configDir: '/existing/codex' } } } + const configured = harnessProfile(resource, project, 'r', 'codex') + assert.equal(configured.profile.source, 'configured'); assert.equal(configured.selected.configDir, '/existing/codex') + assert.notEqual(configured.profile.id, first.profile.id) + assert.ok(!JSON.stringify(configured.profile).includes('/existing')) + resource.profiles.owner.codex.configDir = '/other' + assert.notEqual(harnessProfile(resource, project, 'r', 'codex').profile.id, configured.profile.id) + assert.throws(() => harnessProfile({}, project, 'r', 'codex'), /explicitly authorized/) +}) +test('known SDK auth failure shapes retain safe codes without provider text or treating network errors as login failures', () => { + for (const value of [{ error: { code: 'refresh_token_reused', message: 'private details' } }, { message: 'Your refresh token has already been used to generate a new access token.' }, { message: 'refresh_token_reused' }]) { + const failure = sdkAuthenticationFailure(value, 'codex') + assert.equal(failure.code, 'refresh_token_reused'); assert.equal(failure.reauthenticationRequired, true) + assert.equal(authenticationError(failure).code, 'bot/harness-auth'); assert.ok(!authenticationError(failure).message.includes('private details')) + } + for (const value of [{ code: 'refresh_token_expired' }, { code: 'invalid_grant' }, { message: 'Refresh token has expired' }, { message: 'Invalid API key' }, { status: 401 }]) assert.ok(sdkAuthenticationFailure(value, 'codex')) + for (const value of [null, {}, { message: 'Network timeout' }, { status: 503 }, { code: 'ECONNREFUSED' }]) assert.equal(sdkAuthenticationFailure(value, 'codex'), null) +}) diff --git a/patches/ds-bot/files/test/project-harness-turn.test.js b/patches/ds-bot/files/test/project-harness-turn.test.js index aee311d79..2ab387aae 100644 --- a/patches/ds-bot/files/test/project-harness-turn.test.js +++ b/patches/ds-bot/files/test/project-harness-turn.test.js @@ -136,3 +136,17 @@ test('receipt correction resumes the original SDK Session without tools or chang } }) }) assert.equal(asked, false) }) + +test('Codex refresh-token reuse is a safe authentication failure before provider error text reaches evidence', async () => { + const emitted = [], secret = 'fixture-private-provider-material' + for (const event of [{ type: 'turn.failed', error: { message: 'Your refresh token has already been used. Please log in again. ' + secret } }, { type: 'error', message: 'refresh_token_reused ' + secret }]) { + await assert.rejects(runHarnessTurn(request, { emit: value => emitted.push(value), loadCodex: codex([event]) }), error => error.code === 'bot/harness-auth' && error.authenticationFailure.code === 'refresh_token_reused' && !error.message.includes(secret)) + } + assert.equal(emitted.length, 0) + await assert.rejects(runHarnessTurn(request, { emit() {}, loadCodex: codex([{ type: 'turn.failed', error: { message: 'Network connection timed out' } }]) }), error => error.code !== 'bot/harness-auth') +}) + +test('a tool command reporting its own authentication failure does not invalidate the model login', async () => { + const result = await runHarnessTurn(request,{emit(){},loadCodex:codex([{type:'thread.started',thread_id:'tool-scope'},{type:'item.completed',item:{type:'command_execution',exit_code:1,aggregated_output:'refresh_token_reused in a tool integration'}},{type:'turn.completed'}])}) + assert.equal(result.sessionId,'tool-scope') +}) diff --git a/patches/ds-bot/files/test/project-harness.test.js b/patches/ds-bot/files/test/project-harness.test.js index 4c7e26f17..b553a18ab 100644 --- a/patches/ds-bot/files/test/project-harness.test.js +++ b/patches/ds-bot/files/test/project-harness.test.js @@ -1,3 +1,4 @@ +import { harnessProfile } from '../src/host/project-harness-auth.js' import assert from 'node:assert/strict' import { test } from 'node:test' import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises' @@ -194,7 +195,7 @@ if(m.type==='input'){if(!m.prompt.includes('Verified pnpm decision'))process.exi if(data.memoryUpdates?.length)memoryWrites+=data.memoryUpdates.length return {reads:data.contextRequests?.length?readContextArchive(snapshot,data.contextRequests):[],writes:[]} }} - rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l + rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l const agent={id:'public-p',projectId:'p',lastRequest:{}} const id=await harness.chiefTurn({agent,delegation:{id:'d',definitionRevision:1},definition:{execution:{harness:'codex',resourceIds:['r'],model:'test-model'}},context,text:'Read the saved decision',onResult:finish}) const result=await done @@ -210,7 +211,7 @@ if(m.type==='input'){if(!m.prompt.includes('Verified pnpm decision'))process.exi test('conversation carriers for non-Chief roles cannot gain development shell or write capabilities', async t => { const {rt,harness}=await fixture(t) - rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l + rt.config.projectResources.r.harnesses=['codex'];rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l rt.projectAgents={isDelegationActive:()=>true} for(const role of ['developer','reviewer']) { const agent={id:'room-'+role,projectId:'p',role,roomId:'room',lastRequest:{}} @@ -415,7 +416,7 @@ test('changed caller prompt resumes the Task-role SDK thread when its role defin session.id = 'returned-role-session' session.attemptId = 'second-attempt' rt.state.projectResourceHealth = { r: { status: 'ready' } } - rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready' } } + rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile } } session.resourceLease = await rt.projectResources.reserve({ projectId: 'p', attemptId: session.attemptId, role: 'developer', sessionKey: session.id, harness: 'codex', resourceIds: ['r'] }) const next = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Tester returned the Task with new feedback and changed candidate facts' }) assert.equal(rt.state.projectHarnessWorkers[next].continuedFrom, first) @@ -450,7 +451,7 @@ test('every SDK delivery role resumes the original thread across an admitted cwd rt.state.projectAttempts.a2 = { id: 'a2', projectId: 'p', taskId: 't', status: 'running', roleSessions: { [role]: nextSession }, workspaces: { [role]: workspace } } rt.state.projectWorkspaces = { [workspace.id]: workspace } rt.state.tasks.t.deliveryAttemptId = 'a2' - rt.state.projectResourceHealth = { r: { status: 'ready' } }; rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready' } } + rt.state.projectResourceHealth = { r: { status: 'ready' } }; rt.state.projectHarnessHealth = { 'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile } } nextSession.resourceLease = await rt.projectResources.reserve({ projectId: 'p', attemptId: 'a2', role, sessionKey: nextSession.id, harness: 'codex', resourceIds: ['r'] }) rt.projectDelivery.sessionFor = () => nextSession rt.projectDelivery.ownsWorkspace = (_projectId, selected) => selected === path ? workspace : null @@ -654,7 +655,7 @@ test('an actual SDK protocol subprocess must exit before its Chief input wait fr test('Chief reservation save failure must release its unstarted resource',async t=>{ const {rt,harness}=await fixture(t); -rt.projectAgents={isDelegationActive:()=>true}; rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex':{status:'ready'}};delete rt.state.projectResourceLeases.l; +rt.projectAgents={isDelegationActive:()=>true}; rt.state.projectResourceHealth={r:{status:'ready',at:Date.now()}};rt.state.projectHarnessHealth={'p:r:codex': { status: 'ready', profile: harnessProfile(rt.config.projectResources['r'], rt.readProject('p'), 'r', 'codex').profile }};delete rt.state.projectResourceLeases.l; rt.state.projectExecutions={inspection:{invocation:1}};let saves=0;rt.save=async()=>{if(++saves===2)throw new Error('correlation save failed')}; await assert.rejects(harness.chiefTurn({agent:{id:'public-p',projectId:'p',lastRequest:{}},delegation:{id:'d',chiefJob:{runId:'inspection',invocation:1}},definition:{execution:{harness:'codex',resourceIds:['r']}},context:{text:'Inspect'},text:'Inspect',onResult:async()=>{}}),/correlation save failed/); assert.equal(rt.projectResources.occupied('r'),0); @@ -745,3 +746,26 @@ test('role App worker capability is checked before admission and auth frames nev await harness.release(id) assert.equal(row.githubAccess.renewing, false) }) + +test('refresh-token reuse reaches the original worker/profile health without becoming a code verdict or leaking provider text', async t => { + const { rt, harness, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + let source = await readFile(file, 'utf8') + source = source.replace("write({type:'completed',sessionId:'bound-thread',text:'OUTPUT implementation: actual contract worker output',usage:{input_tokens:1}});", "write({type:'failure',authenticationFailure:{code:'refresh_token_reused'},reason:'fixture-private-auth-response'});") + await writeFile(file, source) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Review the current candidate' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + const failed = await result, row = rt.state.projectHarnessWorkers[id], health = rt.projectResources.harnessHealth('p','r','codex') + assert.equal(failed.ok, false); assert.match(failed.text, /refresh_token_reused/) + assert.equal(health.status, 'authRequired'); assert.equal(health.profile.id, row.profile.id) + assert.equal(row.authenticationFailure.code, 'refresh_token_reused') + assert.ok(!(await readFile(row.eventLog,'utf8')).includes('fixture-private-auth-response')) + assert.ok(!JSON.stringify(rt.state).includes('fixture-private-auth-response')) + assert.equal(rt.state.projectAttempts, undefined, 'Authentication does not manufacture a candidate/test/Review verdict') + const before = row.profile.id + rt.config.projectResources.r.profiles.owner.codex.configDir += '-changed' + const current = rt.projectResources.harnessHealth('p','r','codex') + assert.equal(current.status, 'unconfigured'); assert.notEqual(current.profile.id, before) + await harness.release(id) +}) diff --git a/patches/ds-bot/files/test/project-runtime-setup.test.js b/patches/ds-bot/files/test/project-runtime-setup.test.js index d81f7aa6f..0cfe0a5b4 100644 --- a/patches/ds-bot/files/test/project-runtime-setup.test.js +++ b/patches/ds-bot/files/test/project-runtime-setup.test.js @@ -125,3 +125,39 @@ test('missing SDK bridge cannot consume probe capacity or imply authentication r assert.equal(result.sdkVersion, null) assert.equal(rt.projectResources.occupied('local'), 0) }) + +test('configuration PTY reports and actually uses the same persistent profile as SDK roles, and refuses changed profile input', async t => { + const { setup, rt, body, root } = await fixture(t) + const opened = await setup.endpoint('project-terminal-open', body, owner), selected = await rt.projectHarness.profile('p', 'local', 'codex') + assert.equal(opened.profile.id, selected.profile.id) + assert.equal(opened.codexLoginAvailable, true) + assert.equal(opened.profile.configDir, selected.env.CODEX_HOME) + await setup.endpoint('project-terminal-input', {terminalId:opened.terminalId,text:"printf 'ACTUAL_CODEX_PROFILE=%s\\n' \"$CODEX_HOME\"\n"}, owner) + assert.ok((await screen(setup, opened.terminalId, /ACTUAL_CODEX_PROFILE=\//)).includes(selected.env.CODEX_HOME)) + await setup.endpoint('project-terminal-input', { terminalId: opened.terminalId, text: "printf 'PINNED_CODEX=%s\\n' \"$(command -v codex)\"\n" }, owner) + assert.match(await screen(setup, opened.terminalId, /PINNED_CODEX=\//), /vendor.*codex/) + const next = await rt.projectHarness.profile('p','local','codex') + assert.equal(next.profile.id, opened.profile.id) + rt.config.projectResources.local.profileRoot = join(root,'new-profiles') + await assert.rejects(setup.endpoint('project-terminal-input',{terminalId:opened.terminalId,text:'must not run\n'},owner),/profile changed/) + assert.equal(rt.state.projectTerminalAudit[opened.terminalId].released,true) +}) + +test('SDK probe preserves typed refresh-token failure and does not reuse readiness from another profile', async t => { + const { setup, rt, body, root } = await fixture(t) + const { mkdir, writeFile } = await import('node:fs/promises') + const runtime = join(root,'runtime'); await mkdir(join(runtime,'src/host'),{recursive:true}) + rt.config.projectResources.local.runtimeDir = runtime + rt.projectAgents.deliveryDefinitions = () => ({ roles:[{role:'reviewer',execution:{harness:'codex',model:'fixture-model'}}] }) + await writeFile(join(runtime,'src/host/project-harness-worker.mjs'), `import{createInterface}from'node:readline';const lines=createInterface({input:process.stdin});const send=value=>process.stdout.write(JSON.stringify(value)+'\\n');send({type:'ready',sdk:{codex:'0.166.0'}});lines.on('line',line=>{const m=JSON.parse(line);if(m.type==='start')send({type:'failure',authenticationFailure:{code:'refresh_token_reused'},reason:'safe auth failure'});if(m.type==='stop'){send({type:'stopped',released:true});process.exit(0)}});`) + const result = await setup.check(body,owner) + assert.equal(result.status,'authRequired'); assert.equal(result.authenticationFailure.code,'refresh_token_reused') + const selected = await rt.projectHarness.profile('p','local','codex') + assert.equal(result.profile.id, selected.profile.id); assert.equal(rt.projectResources.occupied('local'),0) + rt.state.projectHarnessHealth['p:local:codex'] = {...result,status:'ready'} + assert.equal(rt.projectResources.harnessHealth('p','local','codex').status,'ready') + rt.config.projectResources.local.profileRoot = join(root,'other-profile') + assert.equal(rt.projectResources.harnessHealth('p','local','codex').status,'unconfigured') + const again = await setup.check(body,owner) + assert.equal(again.status,'authRequired'); assert.notEqual(again.profile.id,result.profile.id) +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index 20ea75a90..2d279574b 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -396,3 +396,17 @@ const publicationRetry = commands.get('attempt.publication-retry') publicationRetry.request.properties.phase = lit('candidate') publicationRetry.preconditions = [...projectActive, 'current-attempt-requirements-authority-and-confirmed-worker-exit', 'candidate-phase-requires-role-app-mode-original-dev-session-and-retained-clean-commit', 'omitted-phase-requires-matching-host-tests-review-and-existing-pr-binding'] publicationRetry.notes = 'phase=candidate observes and adopts an already SDK-published PR before host tests; it never creates a PR, rewrites SHA, grants a Review PASS or closes a Task. Original github.candidate Effect is durable; a failed Effect must first be explicitly retried through effect.retry. Without phase, retain existing reviewed publication reconciliation semantics.' + +// SDK model authentication is observed on the selected resource profile, separate from Task outcomes. +schemas.HarnessProfile = obj({ id: hash, source: choice('configured', 'generated'), resourceId: id, principalId: id, harness }, ['id', 'source', 'resourceId', 'principalId', 'harness']) +schemas.SdkAuthenticationFailure = obj({ harness, code: choice('refresh_token_reused', 'refresh_token_expired', 'refresh_token_invalidated', 'invalid_refresh_token', 'invalid_grant', 'invalid_api_key', 'authentication_required', 'authentication_error'), reauthenticationRequired: lit(true) }, ['harness', 'code', 'reauthenticationRequired']) +schemas.HealthCheck.properties.profile = nullable(ref('HarnessProfile')) +schemas.HealthCheck.properties.authenticationFailure = ref('SdkAuthenticationFailure') +schemas.ExecutionMetadata.properties.profile = nullable(ref('HarnessProfile')) +schemas.ExecutionMetadata.properties.authenticationFailure = nullable(ref('SdkAuthenticationFailure')) +schemas.Resource.properties.harnessStatus.items.properties.profile = ref('HarnessProfile') +schemas.Resource.properties.harnessStatus.items.properties.authenticationFailure = ref('SdkAuthenticationFailure') +schemas.Terminal.properties.profile = obj({ ...schemas.HarnessProfile.properties, home: str(), configDir: str() }, [...schemas.HarnessProfile.required, 'home', 'configDir'], { description: 'Only returned by terminal-open to the explicit resource administrator; actual target paths for login, never credential contents.' }) +schemas.Terminal.properties.codexLoginAvailable = bool +native.get('project-runtime-check').notes += ' Health is bound to the actual selected profile. Legacy/changed profile readiness must be checked again. authRequired preserves a safe SDK error code; it is not a code-test/Review failure or a Task routing decision.' +native.get('project-terminal-open').notes += ' Returns the actual HOME/configDir only to this authorized administrator. Codex CLI resolution uses the resource SDK installation; re-login happens in that terminal CODEX_HOME. No host auth cache is copied.'