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.'