diff --git a/packages/agent/src/server/agent-host/__tests__/legacyAdmissionCompatibility.test.ts b/packages/agent/src/server/agent-host/__tests__/legacyAdmissionCompatibility.test.ts index 39c3c8ad3..4e3caa857 100644 --- a/packages/agent/src/server/agent-host/__tests__/legacyAdmissionCompatibility.test.ts +++ b/packages/agent/src/server/agent-host/__tests__/legacyAdmissionCompatibility.test.ts @@ -82,6 +82,14 @@ function createFixture(options: { readonly failPromptAfterMutation?: boolean readonly failPromptBusy?: boolean readonly failPromptSynchronously?: boolean + readonly distinctPreboundService?: boolean + readonly preboundRuntimeScopeIdentity?: string + readonly missingSession?: boolean + readonly migration?: { + readonly fromIdentity: string + readonly toIdentity: string + readonly fail?: boolean + } } = {}) { const events: string[] = [] const ledger = new RecordingLedger(events) @@ -148,8 +156,54 @@ function createFixture(options: { } const admit = options.admit ?? (async (ctx) => { events.push(`callback:${ctx.requestId}`) }) const service = withAgentEffectAdmission(base, admit) + const preboundService: AgentCoreSessionService = options.distinctPreboundService + ? { + ...service, + async prompt(_ctx, _sessionId, payload) { + mutate('prebound.session.prompt') + return { accepted: true, cursor: 99, clientNonce: payload.clientNonce } + }, + } + : service + let persistedPin = options.migration?.fromIdentity ?? 'legacy-test-runtime' + const runtimeScope = { + identity: options.migration?.toIdentity ?? 'legacy-test-runtime', + environment: { + placementIdentity: 'legacy-test-placement', + workspaceRoot: '/tmp/legacy-test', + provisioningFingerprint: 'legacy-test-provisioning', + }, + sessionNamespace: 'legacy-test-sessions', + ...(options.migration ? { + sessionIdentityMigrations: [{ + schemaVersion: 1 as const, + agentTypeId: 'default', + workspaceScopeId: 'workspace-a', + sessionNamespace: 'legacy-test-sessions', + fromIdentity: options.migration.fromIdentity, + toIdentity: options.migration.toIdentity, + evidenceDigest: 'f'.repeat(64), + }], + } : {}), + } const gateway = new EmbeddedAgentGateway({ ledger, + compiledById: new Map([['default', { agentTypeId: 'default', legacyDefault: true }]]), + options: { resolveRuntimeScope: async () => runtimeScope }, + resolveSessionRuntime: async () => options.missingSession ? null : ({ + runtimeScope, + runtimeScopeIdentity: persistedPin, + migrateRuntimeScopeIdentity: async () => { + events.push('migration:cas') + if (options.migration?.fail) return 'mismatch' as const + persistedPin = runtimeScope.identity + return 'migrated' as const + }, + }), + resolveBinding: async () => { + events.push('binding:resolved') + return { scope: runtimeScope, composition: { service } } + }, effectAdmission: { async admit() { return { type: 'accepted', admissionReceipt: 'legacy-at-most-once' } @@ -164,11 +218,20 @@ function createFixture(options: { } as never) const compatibility = createLegacyPiChatCompatibilityService({ gateway, - service, + service: preboundService, + runtimeScopeIdentity: options.preboundRuntimeScopeIdentity, scope, agentTypeId: 'default', }) - return { compatibility, events, gateway, ledger, mutations, promptAttempts: () => promptAttempts } + return { + compatibility, + events, + gateway, + ledger, + mutations, + promptAttempts: () => promptAttempts, + persistedPin: () => persistedPin, + } } async function exactReplay(operation: () => Promise): Promise { @@ -178,6 +241,78 @@ async function exactReplay(operation: () => Promise): Promise { } describe('legacy admitEffect Level-B compatibility', () => { + it('migrates the session pin before legacy prompt admission and mutation', async () => { + const fromIdentity = 'a'.repeat(64) + const toIdentity = 'b'.repeat(64) + const fixture = createFixture({ + distinctPreboundService: true, + migration: { fromIdentity, toIdentity }, + }) + + await fixture.compatibility.prompt(context('legacy-migration-http'), 'session-a', { + message: 'migrate before prompt', + clientNonce: 'legacy-migration-prompt', + }) + + expect(fixture.persistedPin()).toBe(toIdentity) + expect(fixture.mutations.get('prebound.session.prompt')).toBeUndefined() + expect(fixture.events).toEqual([ + 'binding:resolved', + 'migration:cas', + 'prepare:session.prompt', + 'accept:session.prompt', + 'begin:session.prompt', + 'callback:legacy-migration-http', + 'mutation:session.prompt', + 'complete:session.prompt', + ]) + }) + + it('preserves the frozen legacy session-not-found code before mutation admission', async () => { + const fixture = createFixture({ missingSession: true }) + + await expect(fixture.compatibility.deleteSession!(context('legacy-missing-http'), 'missing')).rejects.toMatchObject({ + code: ErrorCode.enum.SESSION_NOT_FOUND, + }) + expect(fixture.events).toEqual([]) + }) + + it('reuses a pre-resolved compatibility service only when its identity matches the verified binding', async () => { + const runtimeScopeIdentity = 'legacy-test-runtime' + const fixture = createFixture({ + distinctPreboundService: true, + preboundRuntimeScopeIdentity: runtimeScopeIdentity, + }) + + await fixture.compatibility.prompt(context('legacy-matching-binding-http'), 'session-a', { + message: 'use the already resolved target binding', + clientNonce: 'legacy-matching-binding-prompt', + }) + + expect(fixture.mutations.get('prebound.session.prompt')).toBe(1) + expect(fixture.mutations.get('session.prompt')).toBeUndefined() + expect(fixture.events.slice(0, 2)).toEqual([ + 'binding:resolved', + 'prepare:session.prompt', + ]) + }) + + it('admits no legacy effect and preserves the old pin when migration CAS fails', async () => { + const fromIdentity = 'c'.repeat(64) + const fixture = createFixture({ + migration: { fromIdentity, toIdentity: 'd'.repeat(64), fail: true }, + }) + + await expect(fixture.compatibility.prompt(context('legacy-failed-migration-http'), 'session-a', { + message: 'must not run', + clientNonce: 'legacy-failed-migration-prompt', + })).rejects.toMatchObject({ code: AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH }) + + expect(fixture.persistedPin()).toBe(fromIdentity) + expect(fixture.events).toEqual(['binding:resolved', 'migration:cas']) + expect(fixture.mutations.get('session.prompt')).toBeUndefined() + }) + it('covers every legacy effect with prepare → beginEffect → callback → mutation and exact at-most-once replay', async () => { const fixture = createFixture() @@ -209,6 +344,7 @@ describe('legacy admitEffect Level-B compatibility', () => { clientSeq: 1, })) + expect(fixture.events.filter((event) => event === 'binding:resolved')).toHaveLength(12) expect(fixture.ledger.acceptances).toEqual(Array(7).fill('legacy-at-most-once')) expect([...fixture.mutations.entries()].sort()).toEqual([ ['session.create', 1], diff --git a/packages/agent/src/server/agent-host/__tests__/runtimeScopeIdentity.test.ts b/packages/agent/src/server/agent-host/__tests__/runtimeScopeIdentity.test.ts index 0a7d7ef5b..dba174c0b 100644 --- a/packages/agent/src/server/agent-host/__tests__/runtimeScopeIdentity.test.ts +++ b/packages/agent/src/server/agent-host/__tests__/runtimeScopeIdentity.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { appendFile, chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -9,10 +9,16 @@ import type { RuntimeModeAdapter } from '../../runtime/mode' import { createAgentHost } from '../createAgentHost' import { EmbeddedAgentGateway } from '../embeddedGateway' import { sessionNamespaceForAgent } from '../sessionInventory' -import type { AgentEffectAdmission, AgentHostAgentSpec, CreateAgentHostOptions } from '../types' +import type { + AgentEffectAdmission, + AgentHostAgentSpec, + CreateAgentHostOptions, + RuntimeScopeIdentityMigrationAuthorization, +} from '../types' import { createEnvironmentProvisioningFingerprint, createResolvedRuntimeScopeIdentity, + createRuntimeScopeIdentityDiagnostic, type RuntimeScopeIdentityInput, } from '../runtimeScopeIdentity' @@ -35,6 +41,8 @@ function hostOptions(input: { runtimeIdentity: (scope: AuthorizedAgentScope) => string createRuntime?: RuntimeModeAdapter['create'] effectAdmission?: AgentEffectAdmission + migration?: RuntimeScopeIdentityMigrationAuthorization + bindingIdentity?: string | ((scope: AuthorizedAgentScope) => string) }): CreateAgentHostOptions { const baseMode = createTestRuntimeModeAdapter('direct') return { @@ -54,8 +62,14 @@ function hostOptions(input: { ...(input.effectAdmission ? { effectAdmission: input.effectAdmission } : {}), resolveRuntimeScope: async ({ scope }: { scope: AuthorizedAgentScope }) => ({ identity: input.runtimeIdentity(scope), + ...(input.bindingIdentity + ? { bindingIdentity: typeof input.bindingIdentity === 'function' ? input.bindingIdentity(scope) : input.bindingIdentity } + : {}), + ...(input.migration ? { sessionIdentityMigrations: [input.migration] } : {}), environment: { - placementIdentity: 'direct:workspace', + placementIdentity: input.bindingIdentity + ? typeof input.bindingIdentity === 'function' ? input.bindingIdentity(scope) : input.bindingIdentity + : 'direct:workspace', workspaceRoot: input.sessionRoot, provisioningFingerprint: 'provider:generation-a', }, @@ -88,6 +102,133 @@ describe('runtime scope identity', () => { .not.toBe(createResolvedRuntimeScopeIdentity(base)) }) + it('emits separate v1 and versioned v2 identities for offline diagnostics', () => { + const diagnostic = createRuntimeScopeIdentityDiagnostic(base) + expect(diagnostic.legacyV1Identity).toMatch(/^[a-f0-9]{64}$/) + expect(diagnostic.semanticV2Identity).toMatch(/^[a-f0-9]{64}$/) + expect(diagnostic.semanticV2Identity).not.toBe(diagnostic.legacyV1Identity) + }) + + it('replaces only the header and preserves a malformed transcript tail byte-for-byte', async () => { + const sessionRoot = await temporaryRoot() + const store = new PiSessionStore(sessionRoot, { + sessionRoot, + sessionNamespace: 'header-cas', + storageCwd: sessionRoot, + }) + const oldIdentity = '4'.repeat(64) + const nextIdentity = '5'.repeat(64) + const created = await store.create({ + workspaceId: 'workspace-a', + runtimeScopeIdentity: oldIdentity, + } as Parameters[0]) + const transcriptPath = join(sessionRoot, 'header-cas', `${created.id}.jsonl`) + const malformedTail = '{"type":"message","payload":"preserve spacing"}\r\n{malformed-tail\u0000bytes}\n' + await appendFile(transcriptPath, malformedTail, 'utf8') + await appendFile(transcriptPath, new Uint8Array([0xff, 0xfe, 0x00])) + const before = await readFile(transcriptPath) + const beforeTail = before.subarray(before.indexOf(0x0a)) + + await expect(store.migrateRuntimeScopeIdentity( + { workspaceId: 'workspace-a' }, + created.id, + { expectedIdentity: oldIdentity, nextIdentity, evidenceDigest: '6'.repeat(64) }, + )).resolves.toBe('migrated') + + const after = await readFile(transcriptPath) + expect(after.subarray(after.indexOf(0x0a)).equals(beforeTail)).toBe(true) + const header = new TextDecoder().decode(after.subarray(0, after.indexOf(0x0a))) + expect(JSON.parse(header).boringSessionCtx.runtimeScopeIdentity).toBe(nextIdentity) + }) + + it('fails closed on a malformed authoritative header', async () => { + const sessionRoot = await temporaryRoot() + const store = new PiSessionStore(sessionRoot, { + sessionRoot, + sessionNamespace: 'malformed-header-cas', + storageCwd: sessionRoot, + }) + const created = await store.create({ + workspaceId: 'workspace-a', + runtimeScopeIdentity: 'a'.repeat(64), + } as Parameters[0]) + const transcriptPath = join(sessionRoot, 'malformed-header-cas', `${created.id}.jsonl`) + const malformed = '{malformed-header}\n{"tail":"unchanged"}\n' + await writeFile(transcriptPath, malformed, 'utf8') + await expect(store.migrateRuntimeScopeIdentity( + { workspaceId: 'workspace-a' }, + created.id, + { expectedIdentity: 'a'.repeat(64), nextIdentity: 'b'.repeat(64), evidenceDigest: 'c'.repeat(64) }, + )).rejects.toThrow(/Session (?:metadata is malformed|not found)/) + expect(await readFile(transcriptPath, 'utf8')).toBe(malformed) + }) + + it('fails closed after a bounded wait on a stale filesystem lock', async () => { + const sessionRoot = await temporaryRoot() + const store = new PiSessionStore(sessionRoot, { + sessionRoot, + sessionNamespace: 'stale-lock-cas', + storageCwd: sessionRoot, + }) + const oldIdentity = 'd'.repeat(64) + const created = await store.create({ + workspaceId: 'workspace-a', + runtimeScopeIdentity: oldIdentity, + } as Parameters[0]) + const transcriptPath = join(sessionRoot, 'stale-lock-cas', `${created.id}.jsonl`) + await writeFile(`${transcriptPath}.runtime-identity.lock`, 'stale', { flag: 'wx' }) + const before = await readFile(transcriptPath) + await expect(store.migrateRuntimeScopeIdentity( + { workspaceId: 'workspace-a' }, + created.id, + { expectedIdentity: oldIdentity, nextIdentity: 'e'.repeat(64), evidenceDigest: 'f'.repeat(64) }, + )).rejects.toThrow(/migration is locked/) + expect((await readFile(transcriptPath)).equals(before)).toBe(true) + }) + + it('serializes conflicting migrations across independent stores', async () => { + const sessionRoot = await temporaryRoot() + const options = { sessionRoot, sessionNamespace: 'cross-store-cas', storageCwd: sessionRoot } + const firstStore = new PiSessionStore(sessionRoot, options) + const secondStore = new PiSessionStore(sessionRoot, options) + const oldIdentity = '7'.repeat(64) + const created = await firstStore.create({ + workspaceId: 'workspace-a', + runtimeScopeIdentity: oldIdentity, + } as Parameters[0]) + const results = await Promise.all([ + firstStore.migrateRuntimeScopeIdentity( + { workspaceId: 'workspace-a' }, + created.id, + { expectedIdentity: oldIdentity, nextIdentity: '8'.repeat(64), evidenceDigest: 'a'.repeat(64) }, + ), + secondStore.migrateRuntimeScopeIdentity( + { workspaceId: 'workspace-a' }, + created.id, + { expectedIdentity: oldIdentity, nextIdentity: '9'.repeat(64), evidenceDigest: 'b'.repeat(64) }, + ), + ]) + expect(results.filter((result) => result === 'migrated')).toHaveLength(1) + expect(results.filter((result) => result === 'mismatch')).toHaveLength(1) + }) + + it('keeps semantic identity stable when only the physical binding changes', () => { + const semantic = { + ...base, + placementClassIdentity: 'direct', + provisioningIdentity: 'generation-a', + } + expect(createResolvedRuntimeScopeIdentity({ + ...semantic, + placementIdentity: '/checkout/one', + provisioningGeneration: '/checkout/one/.runtime', + })).toBe(createResolvedRuntimeScopeIdentity({ + ...semantic, + placementIdentity: '/checkout/two', + provisioningGeneration: '/checkout/two/.runtime', + })) + }) + it('is stable across ordering-only changes', () => { const first = createResolvedRuntimeScopeIdentity({ ...base, @@ -104,6 +245,44 @@ describe('runtime scope identity', () => { expect(first).toBe(second) }) + it('uses physical binding identity for cache separation without changing persisted identity', async () => { + const sessionRoot = await temporaryRoot() + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const host = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => 'semantic-runtime', + bindingIdentity: (scope) => `physical:${scope.authSubjectId}`, + createRuntime, + })) + for (const authSubjectId of ['subject-a', 'subject-b']) { + await host.gateway.createSession({ + scope: { workspaceScopeId: 'workspace-a', authSubjectId } as AuthorizedAgentScope, + agentTypeId: 'alpha', + requestId: `create-${authSubjectId}`, + }) + } + expect(createRuntime).toHaveBeenCalledTimes(2) + await host.host.close() + }) + + it('rejects an explicitly empty physical binding identity', async () => { + const sessionRoot = await temporaryRoot() + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const host = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => 'semantic-runtime', + bindingIdentity: ' ', + createRuntime, + })) + await expect(host.gateway.createSession({ + scope: { workspaceScopeId: 'workspace-a', authSubjectId: 'subject' } as AuthorizedAgentScope, + agentTypeId: 'alpha', + requestId: 'empty-binding', + })).rejects.toThrow(/binding identity must be non-empty/) + expect(createRuntime).not.toHaveBeenCalled() + await host.host.close() + }) + it('persists a creation pin and rehydrates the matching runtime after Host cache loss', async () => { const sessionRoot = await temporaryRoot() const creator = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope @@ -143,6 +322,243 @@ describe('runtime scope identity', () => { await restarted.host.close() }) + it('migrates one exact scoped v1 pin before binding and survives a restart without authorization', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const oldIdentity = 'a'.repeat(64) + const newIdentity = 'b'.repeat(64) + const evidenceDigest = 'c'.repeat(64) + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => oldIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-migration' }) + await first.host.close() + + const migration: RuntimeScopeIdentityMigrationAuthorization = { + schemaVersion: 1, + agentTypeId: 'alpha', + workspaceScopeId: 'workspace-a', + sessionNamespace: 'sessions', + fromIdentity: oldIdentity, + toIdentity: newIdentity, + evidenceDigest, + } + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => newIdentity, + migration, + createRuntime, + })) + await Promise.all([ + restarted.gateway.renameSession({ scope, ref, requestId: 'migration-a', title: 'Migrated A' }), + restarted.gateway.renameSession({ scope, ref, requestId: 'migration-b', title: 'Migrated B' }), + ]) + expect(createRuntime).toHaveBeenCalledOnce() + await restarted.host.close() + + const namespace = sessionNamespaceForAgent(agent, 'workspace-a', 'sessions')! + const header = JSON.parse((await readFile(join(sessionRoot, namespace, `${ref.sessionId}.jsonl`), 'utf8')).split('\n')[0]!) as { + boringSessionCtx?: { + runtimeScopeIdentity?: string + runtimeScopeIdentityMigration?: { fromIdentity?: string; evidenceDigest?: string } + } + } + expect(header.boringSessionCtx).toMatchObject({ + runtimeScopeIdentity: newIdentity, + runtimeScopeIdentityMigration: { fromIdentity: oldIdentity, evidenceDigest }, + }) + + const secondRestart = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => newIdentity })) + await expect(secondRestart.gateway.renameSession({ + scope, + ref, + requestId: 'post-migration', + title: 'Still writable', + })).resolves.toMatchObject({ title: 'Still writable' }) + await secondRestart.host.close() + }) + + it('accepts an exact authorized raw legacy scope key', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const rawLegacyIdentity = JSON.stringify(['direct', 'workspace-a', '/historical/checkout', null]) + const newIdentity = 'c'.repeat(64) + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => rawLegacyIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-raw-v1' }) + await first.host.close() + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => newIdentity, + migration: { + schemaVersion: 1, + agentTypeId: 'alpha', + workspaceScopeId: 'workspace-a', + sessionNamespace: 'sessions', + fromIdentity: rawLegacyIdentity, + toIdentity: newIdentity, + evidenceDigest: 'd'.repeat(64), + }, + })) + await expect(restarted.gateway.renameSession({ + scope, + ref, + requestId: 'migrate-raw-v1', + title: 'Raw v1 migrated', + })).resolves.toMatchObject({ title: 'Raw v1 migrated' }) + await restarted.host.close() + }) + + it('keeps the observed legacy pin fail-closed without exact authorization evidence', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const observedIdentity = '33293674ddb7f24bcc036f4b5bedbf2457ac3a639e2969353ccb0175d385d7fe' + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => observedIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-observed-pin' }) + await first.host.close() + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => 'e'.repeat(64), + createRuntime, + })) + await expect(restarted.gateway.renameSession({ + scope, + ref, + requestId: 'observed-remains-locked', + title: 'Must remain locked', + })).rejects.toMatchObject({ code: AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH }) + expect(createRuntime).not.toHaveBeenCalled() + await restarted.host.close() + }) + + it('fails a wrong-scope migration closed before binding or transcript mutation', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const oldIdentity = 'd'.repeat(64) + const newIdentity = 'e'.repeat(64) + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => oldIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-wrong-scope' }) + await first.host.close() + const namespace = sessionNamespaceForAgent(agent, 'workspace-a', 'sessions')! + const transcriptPath = join(sessionRoot, namespace, `${ref.sessionId}.jsonl`) + const before = await readFile(transcriptPath, 'utf8') + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => newIdentity, + createRuntime, + migration: { + schemaVersion: 1, + agentTypeId: 'alpha', + workspaceScopeId: 'workspace-other', + sessionNamespace: 'sessions', + fromIdentity: oldIdentity, + toIdentity: newIdentity, + evidenceDigest: 'f'.repeat(64), + }, + })) + await expect(restarted.gateway.renameSession({ + scope, + ref, + requestId: 'must-not-migrate', + title: 'Must not change', + })).rejects.toMatchObject({ code: AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH }) + expect(createRuntime).not.toHaveBeenCalled() + expect(await readFile(transcriptPath, 'utf8')).toBe(before) + await restarted.host.close() + }) + + it('leaves the old pin untouched when the authorized target binding cannot be prepared', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const oldIdentity = '0'.repeat(64) + const newIdentity = '1'.repeat(64) + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => oldIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-target-failure' }) + await first.host.close() + const namespace = sessionNamespaceForAgent(agent, 'workspace-a', 'sessions')! + const transcriptPath = join(sessionRoot, namespace, `${ref.sessionId}.jsonl`) + const before = await readFile(transcriptPath, 'utf8') + const createRuntime = vi.fn(async () => { throw new Error('target binding failed') }) + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => newIdentity, + createRuntime, + migration: { + schemaVersion: 1, + agentTypeId: 'alpha', + workspaceScopeId: 'workspace-a', + sessionNamespace: 'sessions', + fromIdentity: oldIdentity, + toIdentity: newIdentity, + evidenceDigest: '2'.repeat(64), + }, + })) + await expect(restarted.gateway.renameSession({ + scope, + ref, + requestId: 'target-binding-failure', + title: 'Must not change', + })).rejects.toThrow(/target binding failed/) + expect(createRuntime).toHaveBeenCalledOnce() + expect(await readFile(transcriptPath, 'utf8')).toBe(before) + await restarted.host.close() + + const oldRuntime = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => oldIdentity })) + await expect(oldRuntime.gateway.renameSession({ + scope, + ref, + requestId: 'old-runtime-still-usable', + title: 'Old runtime still works', + })).resolves.toMatchObject({ title: 'Old runtime still works' }) + await oldRuntime.host.close() + }) + + it('fails a migration write closed before binding or transcript mutation', async () => { + const sessionRoot = await temporaryRoot() + const scope = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope + const oldIdentity = '1'.repeat(64) + const newIdentity = '2'.repeat(64) + const first = await createAgentHost(hostOptions({ sessionRoot, runtimeIdentity: () => oldIdentity })) + const ref = await first.gateway.createSession({ scope, agentTypeId: 'alpha', requestId: 'create-write-failure' }) + await first.host.close() + const namespace = sessionNamespaceForAgent(agent, 'workspace-a', 'sessions')! + const sessionDir = join(sessionRoot, namespace) + const transcriptPath = join(sessionDir, `${ref.sessionId}.jsonl`) + const before = await readFile(transcriptPath, 'utf8') + const createRuntime = vi.fn(createTestRuntimeModeAdapter('direct').create) + const admit = vi.fn(async () => ({ type: 'accepted' as const, admissionReceipt: 'accepted' })) + await chmod(sessionDir, 0o500) + try { + const restarted = await createAgentHost(hostOptions({ + sessionRoot, + runtimeIdentity: () => newIdentity, + createRuntime, + effectAdmission: { admit }, + migration: { + schemaVersion: 1, + agentTypeId: 'alpha', + workspaceScopeId: 'workspace-a', + sessionNamespace: 'sessions', + fromIdentity: oldIdentity, + toIdentity: newIdentity, + evidenceDigest: '3'.repeat(64), + }, + })) + await expect(restarted.gateway.renameSession({ + scope, + ref, + requestId: 'migration-write-failure', + title: 'Must not change', + })).rejects.toMatchObject({ code: AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH }) + expect(createRuntime).toHaveBeenCalledOnce() + expect(admit).not.toHaveBeenCalled() + expect(await readFile(transcriptPath, 'utf8')).toBe(before) + await restarted.host.close() + } finally { + await chmod(sessionDir, 0o700) + } + }) + it('fails a restarted mismatching actor closed before a second runtime binding or transcript effect', async () => { const sessionRoot = await temporaryRoot() const creator = { workspaceScopeId: 'workspace-a', authSubjectId: 'creator' } as AuthorizedAgentScope diff --git a/packages/agent/src/server/agent-host/createAgentHost.ts b/packages/agent/src/server/agent-host/createAgentHost.ts index fb4e54e24..eaeaf1e0a 100644 --- a/packages/agent/src/server/agent-host/createAgentHost.ts +++ b/packages/agent/src/server/agent-host/createAgentHost.ts @@ -40,6 +40,15 @@ interface RuntimeBinding { const compatibilityRuntimes = new WeakMap() const compatibilityGateways = new WeakMap() +const compatibilityServiceIdentities = new WeakMap< + import('../../core/piChatSessionService').AgentCoreSessionService, + string +>() + +function rememberCompatibilityBinding(binding: RuntimeBinding): RuntimeBinding { + compatibilityServiceIdentities.set(binding.composition.service, binding.scope.identity) + return binding +} export interface AgentHostRuntime { readonly options: CreateAgentHostOptions @@ -155,6 +164,9 @@ async function resolveHostId(options: CreateAgentHostOptions): Promise { function validateResolvedRuntimeScope(resolved: ResolvedAgentRuntimeScope): void { if (!resolved.identity.trim()) throw new TypeError('resolved runtime scope identity must be non-empty') + if (resolved.bindingIdentity !== undefined && !resolved.bindingIdentity.trim()) { + throw new TypeError('resolved runtime binding identity must be non-empty when provided') + } if (!resolved.environment.placementIdentity.trim() || !resolved.environment.provisioningFingerprint.trim()) { throw new TypeError('resolved environment identity must be non-empty') } @@ -242,7 +254,11 @@ function createRuntime( if (!agent) throw new AgentGatewayError(AgentGatewayErrorCode.AGENT_TYPE_UNKNOWN, 'agent type is not available') const resolved = resolvedRuntimeScope ?? await options.resolveRuntimeScope({ agentTypeId, scope }) validateResolvedRuntimeScope(resolved) - const key = JSON.stringify([agentTypeId, claim.workspaceScopeId, resolved.identity]) + const key = JSON.stringify([ + agentTypeId, + claim.workspaceScopeId, + resolved.bindingIdentity ?? resolved.identity, + ]) let promise = bindings.get(key) if (!promise) { let rejectForDrain!: (error: unknown) => void @@ -428,7 +444,8 @@ export async function createAgentHost( gateway, async resolveComposition(agentTypeId: string, scope: AuthorizedAgentScope) { const claim = await runtime.verify(scope) - const composition = (await runtime.resolveBinding(agentTypeId, scope, claim)).composition + const binding = rememberCompatibilityBinding(await runtime.resolveBinding(agentTypeId, scope, claim)) + const composition = binding.composition return { agent: composition.agent, harness: composition.harness, @@ -448,10 +465,13 @@ export async function createAgentHost( async resolveLegacyPiChatService(request) { const scope = await addressedOptions.authorizeRequest(request) const claim = await runtime.verify(scope) - const binding = await runtime.resolveBinding(addressedOptions.defaultAgentTypeId, scope, claim) + const binding = rememberCompatibilityBinding( + await runtime.resolveBinding(addressedOptions.defaultAgentTypeId, scope, claim), + ) return createLegacyPiChatCompatibilityService({ gateway, service: binding.composition.service, + runtimeScopeIdentity: binding.scope.identity, scope, agentTypeId: addressedOptions.defaultAgentTypeId, }) @@ -459,7 +479,13 @@ export async function createAgentHost( }) }, createPiChatService({ service, scope, agentTypeId }: Parameters[0]) { - return createLegacyPiChatCompatibilityService({ gateway, service, scope, agentTypeId }) + return createLegacyPiChatCompatibilityService({ + gateway, + service, + runtimeScopeIdentity: compatibilityServiceIdentities.get(service), + scope, + agentTypeId, + }) }, }) @@ -515,10 +541,13 @@ export async function createAgentHost( async resolveLegacyPiChatService(request) { const scope = await authorizeRequest(request) const claim = await runtime.verify(scope) - const binding = await runtime.resolveBinding(projectionOptions.defaultAgentTypeId, scope, claim) + const binding = rememberCompatibilityBinding( + await runtime.resolveBinding(projectionOptions.defaultAgentTypeId, scope, claim), + ) return createLegacyPiChatCompatibilityService({ gateway, service: binding.composition.service, + runtimeScopeIdentity: binding.scope.identity, scope, agentTypeId: projectionOptions.defaultAgentTypeId, }) @@ -558,10 +587,13 @@ export function createAgentHostCompatibilityRoutes( async resolveLegacyPiChatService(request) { const scope = await authorizeRequest(request) const claim = await runtime.verify(scope) - const binding = await runtime.resolveBinding(projectionOptions.defaultAgentTypeId, scope, claim) + const binding = rememberCompatibilityBinding( + await runtime.resolveBinding(projectionOptions.defaultAgentTypeId, scope, claim), + ) return createLegacyPiChatCompatibilityService({ gateway, service: binding.composition.service, + runtimeScopeIdentity: binding.scope.identity, scope, agentTypeId: projectionOptions.defaultAgentTypeId, }) @@ -582,7 +614,7 @@ export async function resolveAgentHostCompatibilityComposition( const runtime = compatibilityRuntimes.get(created) if (!runtime) throw new TypeError('unknown Agent Host compatibility handle') const claim = await runtime.verify(scope) - return (await runtime.resolveBinding(agentTypeId, scope, claim)).composition + return rememberCompatibilityBinding(await runtime.resolveBinding(agentTypeId, scope, claim)).composition } export function createAgentHostLegacyPiChatCompatibilityService( @@ -593,7 +625,13 @@ export function createAgentHostLegacyPiChatCompatibilityService( ): import('../../core/piChatSessionService').PiChatSessionService { const gateway = compatibilityGateways.get(created) if (!gateway) throw new TypeError('unknown Agent Host compatibility handle') - return createLegacyPiChatCompatibilityService({ gateway, service, scope, agentTypeId }) + return createLegacyPiChatCompatibilityService({ + gateway, + service, + runtimeScopeIdentity: compatibilityServiceIdentities.get(service), + scope, + agentTypeId, + }) } export async function retireAgentHostCompatibilityComposition( diff --git a/packages/agent/src/server/agent-host/embeddedGateway.ts b/packages/agent/src/server/agent-host/embeddedGateway.ts index 5ccf8fd8d..abb5e5f85 100644 --- a/packages/agent/src/server/agent-host/embeddedGateway.ts +++ b/packages/agent/src/server/agent-host/embeddedGateway.ts @@ -20,6 +20,7 @@ import { ErrorCode } from '../../shared/error-codes' import { AgentEffectAdmissionError, isObservedSynchronousServiceError, + type AgentCoreSessionService, type PiChatSessionService, type PiSessionRequestContext, } from '../../core/piChatSessionService' @@ -485,13 +486,49 @@ export class EmbeddedAgentGateway implements AgentGateway { } const resolved = authority?.runtimeScope ?? await this.runtime.options.resolveRuntimeScope({ agentTypeId: ref.agentTypeId, scope }) - const persistedPin = authority?.runtimeScopeIdentity - const pinned = persistedPin ?? cached + let persistedPin = authority?.runtimeScopeIdentity + let pinned = persistedPin ?? cached + let preparedBinding: Awaited> | undefined if (pinned && pinned !== resolved.identity) { - throw new AgentGatewayError( - AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH, - 'session is pinned to a different runtime scope', - ) + const migrations = resolved.sessionIdentityMigrations ?? [] + const candidates = migrations.filter((migration) => ( + migration.schemaVersion === 1 + && migration.agentTypeId === ref.agentTypeId + && migration.workspaceScopeId === claim.workspaceScopeId + && migration.sessionNamespace === resolved.sessionNamespace + && migration.fromIdentity === pinned + && migration.toIdentity === resolved.identity + && migration.fromIdentity.trim().length > 0 + && migration.fromIdentity.length <= 8_192 + && /^[a-f0-9]{64}$/.test(migration.toIdentity) + && /^[a-f0-9]{64}$/.test(migration.evidenceDigest) + )) + if (!authority || candidates.length !== 1) { + throw new AgentGatewayError( + AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH, + 'session is pinned to a different runtime scope', + ) + } + // Prove the authorized target can be constructed before irreversibly + // changing the persisted pin. Binding construction admits no session effect. + preparedBinding = await this.runtime.resolveBinding(ref.agentTypeId, scope, claim, resolved) + try { + const result = await authority.migrateRuntimeScopeIdentity({ + expectedIdentity: candidates[0]!.fromIdentity, + nextIdentity: candidates[0]!.toIdentity, + evidenceDigest: candidates[0]!.evidenceDigest, + }) + if (result === 'mismatch') throw new Error('runtime identity migration compare-and-swap failed') + } catch { + // The proven target binding may remain cached, but bindingForSession + // fails here so no session effect or admission can observe it. + throw new AgentGatewayError( + AgentGatewayErrorCode.AGENT_SESSION_RUNTIME_SCOPE_MISMATCH, + 'session is pinned to a different runtime scope', + ) + } + persistedPin = resolved.identity + pinned = resolved.identity } // Missing pins are pre-AH0 compatibility transcripts. They use the first // current runtime for this Host lifetime without mutating historical JSONL. @@ -501,7 +538,7 @@ export class EmbeddedAgentGateway implements AgentGateway { runtimeScopeIdentity, }) this.pins.set(key, runtimeScopeIdentity) - return await this.runtime.resolveBinding(ref.agentTypeId, scope, claim, resolved) + return preparedBinding ?? await this.runtime.resolveBinding(ref.agentTypeId, scope, claim, resolved) } private async loadSummary( @@ -544,12 +581,18 @@ export class EmbeddedAgentGateway implements AgentGateway { readonly target: AgentRequestTarget readonly requestId: string readonly payload: JsonValue - readonly action: () => Promise + readonly action?: () => Promise + readonly runtimeScopeIdentity?: string + readonly sessionAction?: ( + service: AgentCoreSessionService, + runtimeScopeIdentity: string, + ) => Promise }): Promise { const claim = await this.verify(input.scope) if (input.operation === 'session.create') { if (input.target.kind !== 'agent') throw new TypeError('session.create requires an Agent target') - return await this.effect( + if (!input.action) throw new TypeError('session.create requires an Agent action') + const created = await this.effect( claim, input.operation, input.target, @@ -559,15 +602,35 @@ export class EmbeddedAgentGateway implements AgentGateway { false, true, ) + const sessionId = created && typeof created === 'object' && 'id' in created + && typeof created.id === 'string' && created.id.length > 0 + ? created.id + : undefined + if (sessionId && input.runtimeScopeIdentity) { + const ref = { agentTypeId: input.target.agentTypeId, sessionId } + this.pins.set(sessionKey(claim.workspaceScopeId, ref), input.runtimeScopeIdentity) + this.runtime.activity.set(claim.workspaceScopeId, ref, 'idle') + } + return created } if (input.target.kind !== 'session') throw new TypeError(`${input.operation} requires a session target`) + if (!input.sessionAction) throw new TypeError(`${input.operation} requires a session action`) + let binding: Awaited> + try { + binding = await this.bindingForSession(input.scope, claim, input.target.ref) + } catch (error) { + if (error instanceof AgentGatewayError && error.code === AgentGatewayErrorCode.AGENT_SESSION_NOT_FOUND) { + throw Object.assign(new Error('session not found'), { code: ErrorCode.enum.SESSION_NOT_FOUND }) + } + throw error + } return await this.sessionEffect( input.target.ref, claim, input.operation, input.requestId, input.payload, - input.action, + () => input.sessionAction!(binding.composition.service, binding.scope.identity), false, true, ) diff --git a/packages/agent/src/server/agent-host/legacyPiChatCompatibility.ts b/packages/agent/src/server/agent-host/legacyPiChatCompatibility.ts index 5bc3f2b0b..f8239d422 100644 --- a/packages/agent/src/server/agent-host/legacyPiChatCompatibility.ts +++ b/packages/agent/src/server/agent-host/legacyPiChatCompatibility.ts @@ -13,7 +13,12 @@ interface LegacyCompatibilityGateway { readonly target: AgentRequestTarget readonly requestId: string readonly payload: JsonValue - readonly action: () => Promise + readonly action?: () => Promise + readonly runtimeScopeIdentity?: string + readonly sessionAction?: ( + service: AgentCoreSessionService, + runtimeScopeIdentity: string, + ) => Promise }): Promise } @@ -43,6 +48,8 @@ function requestIdForFollowUp(clientNonce: string, clientSeq: number): string { export function createLegacyPiChatCompatibilityService(input: { readonly gateway: LegacyCompatibilityGateway readonly service: AgentCoreSessionService + /** Identity proven when the compatibility service's binding was resolved. */ + readonly runtimeScopeIdentity?: string readonly scope: AuthorizedAgentScope readonly agentTypeId: string }): PiChatSessionService { @@ -51,35 +58,39 @@ export function createLegacyPiChatCompatibilityService(input: { readonly target: AgentRequestTarget readonly requestId: string readonly payload: unknown - readonly action: () => Promise + readonly action: (service: AgentCoreSessionService) => Promise }): Promise => await input.gateway.runLegacyCompatibilityEffect({ scope: input.scope, operation: options.operation, target: options.target, requestId: options.requestId, payload: jsonProjection(options.payload), - action: options.action, + sessionAction: (service, runtimeScopeIdentity) => options.action( + input.runtimeScopeIdentity === runtimeScopeIdentity ? input.service : service, + ), }) as T return { ...(input.service.listSessions ? { listSessions: (ctx, options) => input.service.listSessions!(ctx, options) } : {}), - createSession: (ctx, init) => effect({ + createSession: (ctx, init) => input.gateway.runLegacyCompatibilityEffect({ + scope: input.scope, operation: 'session.create', target: { kind: 'agent', agentTypeId: input.agentTypeId }, requestId: ctx.requestId, - payload: { title: init?.title ?? null, modelDefault: init?.modelDefault ?? null }, + payload: jsonProjection({ title: init?.title ?? null, modelDefault: init?.modelDefault ?? null }), + runtimeScopeIdentity: input.runtimeScopeIdentity, action: () => input.service.createSession(ctx, init), - }), + }) as ReturnType, async deleteSession(ctx, sessionId) { await effect({ operation: 'session.delete', target: sessionTarget(input.agentTypeId, sessionId), requestId: ctx.requestId, payload: {}, - action: async () => { - await input.service.deleteSession(ctx, sessionId) + action: async (service) => { + await service.deleteSession(ctx, sessionId) return null }, }) @@ -94,35 +105,35 @@ export function createLegacyPiChatCompatibilityService(input: { target: sessionTarget(input.agentTypeId, sessionId), requestId: requestIdForPayload(ctx, payload.clientNonce), payload, - action: () => input.service.prompt(ctx, sessionId, payload), + action: (service) => service.prompt(ctx, sessionId, payload), }), followUp: (ctx, sessionId, payload) => effect({ operation: 'session.followup', target: sessionTarget(input.agentTypeId, sessionId), requestId: requestIdForFollowUp(payload.clientNonce, payload.clientSeq), payload, - action: () => input.service.followUp(ctx, sessionId, payload), + action: (service) => service.followUp(ctx, sessionId, payload), }), clearQueue: (ctx, sessionId, payload) => effect({ operation: 'session.queue.clear', target: sessionTarget(input.agentTypeId, sessionId), requestId: requestIdForPayload(ctx, payload.clientNonce), payload, - action: () => input.service.clearQueue(ctx, sessionId, payload), + action: (service) => service.clearQueue(ctx, sessionId, payload), }), interrupt: (ctx, sessionId, payload) => effect({ operation: 'session.interrupt', target: sessionTarget(input.agentTypeId, sessionId), requestId: ctx.requestId, payload, - action: () => input.service.interrupt(ctx, sessionId, payload), + action: (service) => service.interrupt(ctx, sessionId, payload), }), stop: (ctx, sessionId, payload) => effect({ operation: 'session.stop', target: sessionTarget(input.agentTypeId, sessionId), requestId: ctx.requestId, payload, - action: () => input.service.stop(ctx, sessionId, payload), + action: (service) => service.stop(ctx, sessionId, payload), }), } } diff --git a/packages/agent/src/server/agent-host/runtimeScopeIdentity.ts b/packages/agent/src/server/agent-host/runtimeScopeIdentity.ts index f4cb0ef8c..e2226097d 100644 --- a/packages/agent/src/server/agent-host/runtimeScopeIdentity.ts +++ b/packages/agent/src/server/agent-host/runtimeScopeIdentity.ts @@ -8,10 +8,16 @@ export interface RuntimeScopeIdentityInput { }[] readonly validatedConfig: JsonValue readonly grants: readonly string[] - readonly placementIdentity: string + /** Stable semantic placement class; never an absolute root or lease key. */ + readonly placementClassIdentity?: string + /** @deprecated v1 compatibility input. */ + readonly placementIdentity?: string readonly isolationMode: string readonly toolContractDigests: readonly string[] - readonly provisioningGeneration: string + /** Stable semantic provisioning identity. */ + readonly provisioningIdentity?: string + /** @deprecated v1 compatibility input. */ + readonly provisioningGeneration?: string readonly bindingInputs?: JsonValue } @@ -44,20 +50,59 @@ function digest(value: JsonValue): string { export function createResolvedRuntimeScopeIdentity( input: RuntimeScopeIdentityInput, ): string { + const placementClassIdentity = input.placementClassIdentity ?? input.placementIdentity + const provisioningIdentity = input.provisioningIdentity ?? input.provisioningGeneration + if (!placementClassIdentity || !provisioningIdentity) { + throw new Error('runtime scope identity requires stable placement and provisioning identities') + } return digest({ - artifacts: [...input.artifacts] - .map((artifact) => ({ pluginId: artifact.pluginId, digest: artifact.digest })) - .sort((a, b) => a.pluginId.localeCompare(b.pluginId) || a.digest.localeCompare(b.digest)), + schemaVersion: 2, + artifacts: normalizedArtifacts(input), validatedConfig: input.validatedConfig, grants: [...input.grants].sort(), - placementIdentity: input.placementIdentity, + placementClassIdentity, isolationMode: input.isolationMode, toolContractDigests: [...input.toolContractDigests].sort(), - provisioningGeneration: input.provisioningGeneration, + provisioningIdentity, ...(input.bindingInputs === undefined ? {} : { bindingInputs: input.bindingInputs }), }) } +/** Server-side reconstruction helper for exact, evidence-backed v1 migrations. */ +export function createLegacyRuntimeScopeIdentityV1(input: RuntimeScopeIdentityInput): string { + const placementIdentity = input.placementIdentity ?? input.placementClassIdentity + const provisioningGeneration = input.provisioningGeneration ?? input.provisioningIdentity + if (!placementIdentity || !provisioningGeneration) { + throw new Error('legacy runtime scope identity requires placement and provisioning identities') + } + return digest({ + artifacts: normalizedArtifacts(input), + validatedConfig: input.validatedConfig, + grants: [...input.grants].sort(), + placementIdentity, + isolationMode: input.isolationMode, + toolContractDigests: [...input.toolContractDigests].sort(), + provisioningGeneration, + ...(input.bindingInputs === undefined ? {} : { bindingInputs: input.bindingInputs }), + }) +} + +export function createRuntimeScopeIdentityDiagnostic(input: RuntimeScopeIdentityInput): { + readonly legacyV1Identity: string + readonly semanticV2Identity: string +} { + return { + legacyV1Identity: createLegacyRuntimeScopeIdentityV1(input), + semanticV2Identity: createResolvedRuntimeScopeIdentity(input), + } +} + +function normalizedArtifacts(input: RuntimeScopeIdentityInput) { + return [...input.artifacts] + .map((artifact) => ({ pluginId: artifact.pluginId, digest: artifact.digest })) + .sort((a, b) => a.pluginId.localeCompare(b.pluginId) || a.digest.localeCompare(b.digest)) +} + /** * Produces only the Environment-mutating identity. Contribution grants and * tool contracts are deliberately absent, so grant-only changes share the diff --git a/packages/agent/src/server/agent-host/sessionInventory.ts b/packages/agent/src/server/agent-host/sessionInventory.ts index 2b7c8644b..103668dae 100644 --- a/packages/agent/src/server/agent-host/sessionInventory.ts +++ b/packages/agent/src/server/agent-host/sessionInventory.ts @@ -15,6 +15,11 @@ export interface AgentSessionRuntimeAuthority { readonly runtimeScope: ResolvedAgentRuntimeScope /** Absent only for a pre-AH0 transcript created before runtime pins existed. */ readonly runtimeScopeIdentity?: string + migrateRuntimeScopeIdentity(input: { + expectedIdentity: string + nextIdentity: string + evidenceDigest: string + }): Promise<'migrated' | 'already-current' | 'mismatch'> } function safeScopeSegment(scope: string): string { @@ -70,6 +75,11 @@ export class AgentSessionInventory { { workspaceId: claim.workspaceScopeId }, sessionId, ), + migrateRuntimeScopeIdentity: async (input) => await resolved.store.migrateRuntimeScopeIdentity( + { workspaceId: claim.workspaceScopeId }, + sessionId, + input, + ), } } catch (error) { if (error instanceof Error && error.message === `Session not found: ${sessionId}`) return undefined diff --git a/packages/agent/src/server/agent-host/types.ts b/packages/agent/src/server/agent-host/types.ts index 973a4a535..a316fe01a 100644 --- a/packages/agent/src/server/agent-host/types.ts +++ b/packages/agent/src/server/agent-host/types.ts @@ -164,19 +164,42 @@ export interface AgentFleetCompiler { } export interface ResolvedEnvironmentScope { + /** Physical lease/cache identity. May contain absolute deployment paths. */ readonly placementIdentity: string + /** Stable semantic placement class used by persisted session identity. */ + readonly sessionPlacementIdentity?: string readonly workspaceRoot: string readonly templatePath?: string + /** Physical provisioning cache identity. */ readonly provisioningFingerprint: string + /** Stable semantic provisioning identity used by persisted session identity. */ + readonly sessionProvisioningIdentity?: string readonly provisionRuntime?: (input: { readonly runtimeBundle: Awaited> readonly signal: AbortSignal }) => Promise } +/** + * Exact operator-provided authorization for an irreversible persisted pin CAS. + * Hosts never synthesize these entries; rollout requires reproduced v1 evidence. + */ +export interface RuntimeScopeIdentityMigrationAuthorization { + readonly schemaVersion: 1 + readonly agentTypeId: string + readonly workspaceScopeId: string + readonly sessionNamespace: string + readonly fromIdentity: string + readonly toIdentity: string + readonly evidenceDigest: string +} + export interface ResolvedAgentRuntimeScope { - /** Complete app-canonicalized PL1 composition identity. */ + /** Persisted semantic compatibility identity. */ readonly identity: string + /** Physical process binding/cache identity. Defaults to identity. */ + readonly bindingIdentity?: string + readonly sessionIdentityMigrations?: readonly RuntimeScopeIdentityMigrationAuthorization[] readonly environment: ResolvedEnvironmentScope readonly sessionNamespace: string readonly pi?: PiHarnessOptions diff --git a/packages/agent/src/server/agentHostLegacyRouteRuntime.ts b/packages/agent/src/server/agentHostLegacyRouteRuntime.ts index 2bf1caef3..209acc6cb 100644 --- a/packages/agent/src/server/agentHostLegacyRouteRuntime.ts +++ b/packages/agent/src/server/agentHostLegacyRouteRuntime.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' -import { basename } from 'node:path' +import { createHash } from 'node:crypto' +import { basename, isAbsolute, relative, resolve } from 'node:path' import { type ToolReadinessState } from '@hachej/boring-bash/agent' import type { AgentTool, ToolReadinessRequirement } from '../shared/tool' import type { @@ -114,7 +115,10 @@ type RuntimeBindingEntry = ManagedRuntimeBindingEntry interface RuntimeScope { root: string + /** Physical binding key. */ key: string + /** Stable semantic compatibility key. */ + sessionIdentity: string templatePath?: string pi: ResolvedPiHarnessOptions sessionNamespace?: string @@ -126,6 +130,29 @@ interface SkillScope { pi: ResolvedPiHarnessOptions } +function stableRuntimeIdentity(input: Readonly> & { root: string }): string { + const { root, ...semantic } = input + const normalized = normalizeSemanticIdentityValue(semantic, resolve(root)) + return createHash('sha256').update(JSON.stringify(normalized)).digest('hex') +} + +function normalizeSemanticIdentityValue(value: unknown, root: string): unknown { + if (typeof value === 'string') { + if (!isAbsolute(value)) return value + const rel = relative(root, resolve(value)) + return rel && !rel.startsWith('..') && !isAbsolute(rel) + ? `$workspace/${rel.replaceAll('\\', '/')}` + : `$external/${basename(value)}` + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') return value + if (Array.isArray(value)) return value.map((entry) => normalizeSemanticIdentityValue(entry, root)) + if (!value || typeof value !== 'object') return null + return Object.fromEntries(Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined && typeof entry !== 'function') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, normalizeSemanticIdentityValue(entry, root)])) +} + function getRequestWorkspaceId(request: FastifyRequest): string { return request.workspaceContext?.workspaceId ?? DEFAULT_WORKSPACE_ID } @@ -344,22 +371,34 @@ export async function mountAgentHostLegacyRouteRuntime( : opts.sessionNamespace) const extraToolsAuthSubject = opts.getExtraTools ? trustedCtx?.userId ?? getRequestAuthSubject(request) : undefined const contribution = await opts.getRuntimeScopeContribution?.({ workspaceId, workspaceRoot: root, request }) + const key = JSON.stringify([ + resolvedMode, + workspaceId, + root, + scopedTemplatePath ?? null, + pi, + sessionNamespace ?? null, + extraToolsAuthSubject ?? null, + contribution?.identity ?? null, + ]) + const sessionIdentity = stableRuntimeIdentity({ + resolvedMode, + workspaceId, + root, + templatePath: scopedTemplatePath, + pi, + sessionNamespace, + extraToolsAuthSubject, + contributionIdentity: contribution?.identity, + }) return { root, templatePath: scopedTemplatePath, pi, sessionNamespace, loadSystemPromptAppend: contribution?.loadSystemPromptAppend, - key: JSON.stringify([ - resolvedMode, - workspaceId, - root, - scopedTemplatePath ?? null, - pi, - sessionNamespace ?? null, - extraToolsAuthSubject ?? null, - contribution?.identity ?? null, - ]), + key, + sessionIdentity, } } @@ -579,11 +618,13 @@ export async function mountAgentHostLegacyRouteRuntime( ? () => opts.getSystemPromptDynamic?.({ workspaceId, workspaceRoot: root }) : opts.systemPromptDynamic const hostScope: CompatibilityResolvedAgentRuntimeScope = { - identity: scope.key, + identity: scope.sessionIdentity, + bindingIdentity: scope.key, environment: { // Compatibility projection preserves the legacy one-provider-per-binding // lifecycle; canonical multi-Agent consumers supply shared placement IDs. placementIdentity: scope.key, + sessionPlacementIdentity: JSON.stringify([resolvedMode, workspaceId]), workspaceRoot: root, templatePath: scope.templatePath, compatibilityModeContext: { @@ -598,6 +639,13 @@ export async function mountAgentHostLegacyRouteRuntime( scope.templatePath ?? null, opts.runtimeEnvContributions?.map((contribution) => contribution.id) ?? [], ]), + sessionProvisioningIdentity: stableRuntimeIdentity({ + resolvedMode, + workspaceId, + root, + templatePath: scope.templatePath, + runtimeContributions: opts.runtimeEnvContributions?.map((contribution) => contribution.id) ?? [], + }), }, sessionNamespace: scope.sessionNamespace ?? '', pi: compositionPi, diff --git a/packages/agent/src/server/harness/pi-coding-agent/sessions.ts b/packages/agent/src/server/harness/pi-coding-agent/sessions.ts index dc8d62f0b..58c86769b 100644 --- a/packages/agent/src/server/harness/pi-coding-agent/sessions.ts +++ b/packages/agent/src/server/harness/pi-coding-agent/sessions.ts @@ -9,9 +9,10 @@ import { appendFile, rename, open, + utimes, } from "node:fs/promises"; import { closeSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs"; -import { join, basename, resolve } from "node:path"; +import { join, basename, dirname, resolve } from "node:path"; import { homedir } from "node:os"; import { getEnv } from "../../config/env.js"; import { @@ -64,6 +65,12 @@ const SESSION_ROOT_ENV = "BORING_AGENT_SESSION_ROOT"; const SUMMARY_PREFIX_BYTES = 64 * 1024; const DEFAULT_LEGACY_WORKSPACE_ID = "default"; const TRUSTED_LOCAL_USER_ID = "local"; +// Online migration retains the deployment invariant of one active Host writer +// per session namespace. This lock serializes stores within that mounted +// namespace; stale locks fail closed for operator investigation and are never +// auto-broken as an unsafe cross-host recovery mechanism. +const RUNTIME_IDENTITY_LOCK_ATTEMPTS = 40; +const RUNTIME_IDENTITY_LOCK_WAIT_MS = 25; type SessionFileStat = { filepath: string; stat: Awaited> }; type RuntimePinnedSessionCtx = SessionCtx & { runtimeScopeIdentity?: string }; @@ -93,6 +100,41 @@ function sessionDirForNamespace(namespace: string, explicitRoot?: string): strin return join(sessionBaseDir(explicitRoot), safeNamespace); } +async function acquireRuntimeIdentityMigrationLock(path: string): Promise>> { + for (let attempt = 0; attempt < RUNTIME_IDENTITY_LOCK_ATTEMPTS; attempt += 1) { + try { + return await open(path, "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (attempt === RUNTIME_IDENTITY_LOCK_ATTEMPTS - 1) { + throw new Error("runtime identity migration is locked"); + } + await new Promise((resolve) => setTimeout(resolve, RUNTIME_IDENTITY_LOCK_WAIT_MS)); + } + } + throw new Error("runtime identity migration is locked"); +} + +function sameFileGeneration( + left: Awaited>, + right: Awaited>, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +async function syncPath(path: string): Promise { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + function normalizeListOptions(options: SessionListOptions | undefined): NormalizedListOptions { return { limit: options?.limit === undefined ? undefined : Math.max(0, options.limit), @@ -101,6 +143,14 @@ function normalizeListOptions(options: SessionListOptions | undefined): Normaliz }; } +export interface RuntimeScopeIdentityMigrationInput { + expectedIdentity: string; + nextIdentity: string; + evidenceDigest: string; +} + +export type RuntimeScopeIdentityMigrationResult = "migrated" | "already-current" | "mismatch"; + export interface PiSessionStoreOptions { sessionDir?: string; sessionNamespace?: string; @@ -149,6 +199,88 @@ export class PiSessionStore implements SessionStore { return readHeaderRuntimeScopeIdentity(header); } + async migrateRuntimeScopeIdentity( + ctx: SessionCtx, + sessionId: string, + input: RuntimeScopeIdentityMigrationInput, + ): Promise { + return await this.withWriter(sessionId, async () => { + const filepath = await this.resolveSessionFile(sessionId, ctx); + const lockPath = `${filepath}.runtime-identity.lock`; + const lock = await acquireRuntimeIdentityMigrationLock(lockPath); + const temporary = `${filepath}.runtime-identity-${randomUUID()}`; + let temporaryHandle: Awaited> | undefined; + let replaced = false; + try { + const sourceStat = await fsStat(filepath); + const content = await readFile(filepath); + const afterReadStat = await fsStat(filepath); + if (!sameFileGeneration(sourceStat, afterReadStat)) return "mismatch"; + + const newline = content.indexOf(0x0a); + const rawHeader = new TextDecoder().decode( + newline === -1 ? content : content.subarray(0, newline), + ).replace(/\r$/, ""); + let header: SessionHeader; + try { + const parsed = JSON.parse(rawHeader) as unknown; + if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "session") { + throw new Error("missing session header"); + } + header = parsed as SessionHeader; + } catch { + throw new Error(`Session metadata is malformed: ${sessionId}`); + } + if (!this.headerBelongsToCtx(header, ctx)) throw new Error(`Session not found: ${sessionId}`); + const current = readHeaderRuntimeScopeIdentity(header); + if (current === input.nextIdentity) return "already-current"; + if (current !== input.expectedIdentity) return "mismatch"; + + const sessionCtx = (header as { boringSessionCtx?: RuntimePinnedSessionCtx }).boringSessionCtx ?? {}; + const replacementHeader = JSON.stringify({ + ...header, + boringSessionCtx: { + ...sessionCtx, + runtimeScopeIdentity: input.nextIdentity, + runtimeScopeIdentityMigration: { + schemaVersion: 1, + fromIdentity: input.expectedIdentity, + toIdentity: input.nextIdentity, + evidenceDigest: input.evidenceDigest, + migratedAt: new Date().toISOString(), + }, + }, + }); + const encodedHeader = new TextEncoder().encode(replacementHeader); + const tail = newline === -1 ? content.subarray(content.length) : content.subarray(newline); + const replacement = new Uint8Array(encodedHeader.length + tail.length); + replacement.set(encodedHeader); + replacement.set(tail, encodedHeader.length); + + temporaryHandle = await open(temporary, "wx"); + await temporaryHandle.writeFile(replacement); + await temporaryHandle.sync(); + await temporaryHandle.close(); + temporaryHandle = undefined; + + const beforeRenameStat = await fsStat(filepath); + if (!sameFileGeneration(sourceStat, beforeRenameStat)) return "mismatch"; + await rename(temporary, filepath); + replaced = true; + await utimes(filepath, sourceStat.atime, sourceStat.mtime); + await syncPath(filepath); + await syncPath(dirname(filepath)); + this.prefixCache.delete(filepath); + return "migrated"; + } finally { + await temporaryHandle?.close().catch(() => {}); + if (!replaced) await rm(temporary, { force: true }).catch(() => {}); + await lock.close().catch(() => {}); + await rm(lockPath, { force: true }); + } + }); + } + async list(ctx: SessionCtx, options?: SessionListOptions): Promise { const normalizedOptions = normalizeListOptions(options); const inFlightKey = JSON.stringify([ diff --git a/packages/agent/src/server/index.ts b/packages/agent/src/server/index.ts index aed551af9..f95a0c8eb 100644 --- a/packages/agent/src/server/index.ts +++ b/packages/agent/src/server/index.ts @@ -143,7 +143,9 @@ export { EmbeddedAgentGateway } from './agent-host/embeddedGateway' export { InMemoryAgentRequestLedger } from './agent-host/requestLedger' export { createEnvironmentProvisioningFingerprint, + createLegacyRuntimeScopeIdentityV1, createResolvedRuntimeScopeIdentity, + createRuntimeScopeIdentityDiagnostic, } from './agent-host/runtimeScopeIdentity' export type { AgentEffectAdmission, @@ -170,6 +172,7 @@ export type { LegacyDefaultAgentHostSpec, ResolvedAgentRuntimeScope, ResolvedEnvironmentScope, + RuntimeScopeIdentityMigrationAuthorization, } from './agent-host/types' export type { AuthorizedAgentScope, diff --git a/packages/workspace/src/app/server/__tests__/createWorkspaceAgentServer.test.ts b/packages/workspace/src/app/server/__tests__/createWorkspaceAgentServer.test.ts index 540a94284..d6fe62606 100644 --- a/packages/workspace/src/app/server/__tests__/createWorkspaceAgentServer.test.ts +++ b/packages/workspace/src/app/server/__tests__/createWorkspaceAgentServer.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import Fastify from "fastify" @@ -1086,10 +1086,13 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { policyRevision: string prompt: string toolDescription: string + model?: string piPackage?: string artifactDigest?: string placementIdentity?: string provisioningGeneration?: string + sessionPlacementIdentity?: string + sessionProvisioningIdentity?: string includePolicyDigest?: boolean }): Promise { const workspaceRoot = await makeTempDir("boring-agent-runtime-identity-") @@ -1120,6 +1123,7 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { agents: [{ agentTypeId: "identity-agent", definition: { label: "Identity", instructions: "identity" }, + model: { preferred: input.model ?? "provider/model-a" }, plugins: [{ name: "identity-plugin", config: { mode: "fixed" } }], }], defaultAgentTypeId: "identity-agent", @@ -1149,8 +1153,10 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { identity: "fixed-base-placement-and-provisioning", environment: { placementIdentity: input.placementIdentity ?? "fixed-placement", + ...(input.sessionPlacementIdentity ? { sessionPlacementIdentity: input.sessionPlacementIdentity } : {}), workspaceRoot, provisioningFingerprint: input.provisioningGeneration ?? "fixed-provisioning", + ...(input.sessionProvisioningIdentity ? { sessionProvisioningIdentity: input.sessionProvisioningIdentity } : {}), }, sessionNamespace: "", pi: routeOptions.pi, @@ -1169,6 +1175,7 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { prompt: "IDENTITY_PROMPT_A", toolDescription: "identity tool a", piPackage: "npm:identity-a", + model: "provider/model-a", } const stableOne = await resolveIdentity(fixed) const stableTwo = await resolveIdentity(fixed) @@ -1176,9 +1183,24 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { const promptChanged = await resolveIdentity({ ...fixed, prompt: "IDENTITY_PROMPT_B" }) const toolChanged = await resolveIdentity({ ...fixed, toolDescription: "identity tool b" }) const piChanged = await resolveIdentity({ ...fixed, piPackage: "npm:identity-b" }) + const modelChanged = await resolveIdentity({ ...fixed, model: "provider/model-b" }) const artifactBytesChanged = await resolveIdentity({ ...fixed, artifactDigest: "artifact-bytes-b" }) const placementChanged = await resolveIdentity({ ...fixed, placementIdentity: "sandbox-placement" }) const provisioningChanged = await resolveIdentity({ ...fixed, provisioningGeneration: "generation-b" }) + const stableSemanticPlacementOne = await resolveIdentity({ + ...fixed, + placementIdentity: "/checkout/one", + provisioningGeneration: "/checkout/one/runtime", + sessionPlacementIdentity: "direct:workspace", + sessionProvisioningIdentity: "provider:generation-a", + }) + const stableSemanticPlacementTwo = await resolveIdentity({ + ...fixed, + placementIdentity: "/checkout/two", + provisioningGeneration: "/checkout/two/runtime", + sessionPlacementIdentity: "direct:workspace", + sessionProvisioningIdentity: "provider:generation-a", + }) await expect(resolveIdentity({ ...fixed, includePolicyDigest: false })).rejects.toMatchObject({ code: "BORING_AGENT_RUNTIME_IDENTITY_INCOMPLETE", }) @@ -1188,9 +1210,11 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { expect(promptChanged).not.toBe(stableOne) expect(toolChanged).not.toBe(stableOne) expect(piChanged).not.toBe(stableOne) + expect(modelChanged).not.toBe(stableOne) expect(artifactBytesChanged).not.toBe(stableOne) expect(placementChanged).not.toBe(stableOne) expect(provisioningChanged).not.toBe(stableOne) + expect(stableSemanticPlacementOne).toBe(stableSemanticPlacementTwo) expect(stableOne).toMatch(/^[a-f0-9]{64}$/) }) @@ -1227,6 +1251,90 @@ describe("createWorkspaceAgentServer plugin runtime options", () => { expect(await resolveDigest(secondRoot)).not.toBe(first) }) + test("declared runtime identity hashes the entry declaration, server bytes, and package Pi context", async () => { + const workspaceRoot = await makeTempDir("boring-declared-runtime-identity-") + const pluginRoot = join(workspaceRoot, "plugin") + await mkdir(join(pluginRoot, "dist", "server"), { recursive: true }) + await mkdir(join(pluginRoot, "dist", "front"), { recursive: true }) + const serverPath = join(pluginRoot, "dist", "server", "index.mjs") + const alternateServerPath = join(pluginRoot, "dist", "server", "alternate.mjs") + const frontPath = join(pluginRoot, "dist", "front", "index.js") + const readmePath = join(pluginRoot, "README.md") + const writePackage = async ( + version: string, + prompt: string, + paths = ["dist/server"], + server = "dist/server/index.mjs", + ) => { + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ + name: "declared-runtime-plugin", + version, + boring: { server, runtimeIdentity: { paths } }, + pi: { systemPrompt: prompt }, + }), "utf8") + } + const fixedServer = "export default { id: 'declared-runtime-plugin', systemPrompt: 'fixed' }\n" + await writeFile(serverPath, fixedServer, "utf8") + await writeFile(alternateServerPath, fixedServer, "utf8") + await writeFile(frontPath, "front-a\n", "utf8") + await writeFile(readmePath, "docs-a\n", "utf8") + await writePackage("1.0.0", "PI_A") + const digest = async () => ( + await resolveWorkspaceAgentServerPluginCollection({ + workspaceRoot, + bridge: {} as never, + plugins: [{ dir: pluginRoot, hotReload: true }], + installPluginAuthoring: false, + }) + ).resolvedPluginArtifacts[0]!.contentDigest + + const first = await digest() + await writeFile(frontPath, "front-b\n", "utf8") + await writeFile(readmePath, "docs-b\n", "utf8") + await writePackage("2.0.0", "PI_A") + expect(await digest()).toBe(first) + await writePackage("2.0.0", "PI_A", ["dist/server"], "dist/server/alternate.mjs") + expect(await digest()).not.toBe(first) + await writePackage("2.0.0", "PI_A") + await writeFile(serverPath, "export default { id: 'declared-runtime-plugin', systemPrompt: 'changed' }\n", "utf8") + expect(await digest()).not.toBe(first) + await writeFile(serverPath, fixedServer, "utf8") + await writePackage("2.0.0", "PI_B") + expect(await digest()).not.toBe(first) + }) + + test("rejects missing, traversing, and symlinked declared runtime identity paths", async () => { + const workspaceRoot = await makeTempDir("boring-invalid-runtime-identity-") + const pluginRoot = join(workspaceRoot, "plugin") + await mkdir(join(pluginRoot, "dist", "server"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "server", "index.mjs"), "export default { id: 'invalid-runtime-plugin' }\n", "utf8") + const writePackage = async (paths: string[], server = "dist/server/index.mjs") => await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ + name: "invalid-runtime-plugin", + boring: { server, runtimeIdentity: { paths } }, + }), "utf8") + const resolvePlugin = () => resolveWorkspaceAgentServerPluginCollection({ + workspaceRoot, + bridge: {} as never, + plugins: [{ dir: pluginRoot, hotReload: true }], + installPluginAuthoring: false, + }) + + await writePackage(["dist/server", "missing"]) + await expect(resolvePlugin()).rejects.toThrow(/path is missing/) + await writePackage(["dist/server", "../outside"]) + await expect(resolvePlugin()).rejects.toThrow(/invalid declared runtime identity path/) + await mkdir(join(pluginRoot, "runtime-only"), { recursive: true }) + await writeFile(join(pluginRoot, "runtime-only", "resource.txt"), "resource", "utf8") + await writePackage(["runtime-only"]) + await expect(resolvePlugin()).rejects.toThrow(/must contain the boring.server executable/) + await writeFile(join(pluginRoot, "dist", "server", "index.mjs"), "export default { id: 'mutated-excluded-server' }\n", "utf8") + await expect(resolvePlugin()).rejects.toThrow(/must contain the boring.server executable/) + await writeFile(join(pluginRoot, "dist", "server", "index.mjs"), "export default { id: 'invalid-runtime-plugin' }\n", "utf8") + await symlink(join(pluginRoot, "dist", "server"), join(pluginRoot, "runtime-link")) + await writePackage(["runtime-link"], "runtime-link/index.mjs") + await expect(resolvePlugin()).rejects.toThrow(/unsupported symlink/) + }) + test("trusted host capabilities are passed only to internal directory plugins", async () => { const workspaceRoot = await makeTempDir("boring-trusted-plugin-context-") const internalRoot = join(workspaceRoot, "internal") diff --git a/packages/workspace/src/app/server/createWorkspaceAgentServer.ts b/packages/workspace/src/app/server/createWorkspaceAgentServer.ts index 8e5bbf759..5a122d505 100644 --- a/packages/workspace/src/app/server/createWorkspaceAgentServer.ts +++ b/packages/workspace/src/app/server/createWorkspaceAgentServer.ts @@ -24,6 +24,7 @@ import { type ProvisionWorkspaceRuntimeOptions, type RegisterAgentRoutesOptions, type ResolvedAgentRuntimeScope, + type RuntimeScopeIdentityMigrationAuthorization, type VerifiedAgentScopeClaim, type WorkspaceAgentDispatcherResolver, } from "@hachej/boring-agent/server" @@ -131,6 +132,8 @@ export interface CreateWorkspaceAgentServerOptions defaultAgentTypeId?: string /** Optional host admission called immediately before each Agent effect. */ admitEffect?: RegisterAgentRoutesOptions["admitEffect"] + /** Exact, audited v1->v2 session identity migrations. Wildcards are not supported. */ + runtimeScopeIdentityMigrations?: readonly RuntimeScopeIdentityMigrationAuthorization[] /** * Host-installed server plugins. Accepts pre-built `WorkspaceServerPlugin` * objects or `{ dir, options?, hotReload?, trust? }` directory-source entries. @@ -549,8 +552,73 @@ function directoryContentDigest(root: string): string { return hash.digest("hex") } +function declaredDirectoryRuntimeContentDigest(root: string): string | undefined { + let packageJson: unknown + try { + packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) + } catch { + return undefined + } + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) return undefined + const boring = (packageJson as { boring?: unknown }).boring + if (!boring || typeof boring !== "object" || Array.isArray(boring)) return undefined + const runtimeIdentity = (boring as { runtimeIdentity?: unknown }).runtimeIdentity + if (!runtimeIdentity || typeof runtimeIdentity !== "object" || Array.isArray(runtimeIdentity)) return undefined + const paths = (runtimeIdentity as { paths?: unknown }).paths + if (!Array.isArray(paths) || paths.length === 0 || paths.some((path) => typeof path !== "string")) { + throw new AgentRuntimeIdentityError("boring.runtimeIdentity.paths must be a non-empty string array") + } + + const serverEntry = (boring as { server?: unknown }).server + if (typeof serverEntry !== "string" || !serverEntry) { + throw new AgentRuntimeIdentityError("boring.runtimeIdentity requires an explicit boring.server entry") + } + const absoluteRoot = resolve(root) + const hash = createHash("sha256") + const visit = (absolute: string, relativePath: string) => { + if (!existsSync(absolute)) throw new AgentRuntimeIdentityError(`declared runtime identity path is missing: ${relativePath}`) + const stat = lstatSync(absolute) + if (stat.isSymbolicLink()) { + throw new AgentRuntimeIdentityError(`declared runtime identity path contains unsupported symlink: ${relativePath}`) + } + if (stat.isDirectory()) { + for (const name of readdirSync(absolute).sort()) visit(join(absolute, name), `${relativePath}/${name}`) + return + } + if (!stat.isFile()) return + hash.update(`file\0${relativePath}\0`) + hash.update(readFileSync(absolute)) + hash.update("\0") + } + const uniquePaths = [...new Set(paths as string[])].sort() + for (const path of uniquePaths) { + if (!path || isAbsolute(path) || path.split(/[\\/]+/).some((segment) => segment === ".." || segment === "." || !segment)) { + throw new AgentRuntimeIdentityError(`invalid declared runtime identity path: ${path}`) + } + } + const normalizedServerEntry = serverEntry.replaceAll("\\", "/").replace(/^\.\/+/, "") + const normalizedPaths = uniquePaths.map((path) => path.replaceAll("\\", "/")) + if (!normalizedPaths.some((path) => normalizedServerEntry === path || normalizedServerEntry.startsWith(`${path}/`))) { + throw new AgentRuntimeIdentityError("boring.runtimeIdentity.paths must contain the boring.server executable") + } + hash.update(`runtime-declaration\0${canonicalIdentityJson({ + server: normalizedServerEntry, + runtimeIdentity: { paths: normalizedPaths }, + })}\0`) + for (const [index, path] of uniquePaths.entries()) { + const absolute = resolve(absoluteRoot, path) + if (absolute !== absoluteRoot && !absolute.startsWith(`${absoluteRoot}/`)) { + throw new AgentRuntimeIdentityError(`declared runtime identity path escapes package: ${path}`) + } + visit(absolute, normalizedPaths[index]!) + } + const packagePi = (packageJson as { pi?: unknown }).pi ?? null + hash.update(`package-pi\0${canonicalIdentityJson(jsonIdentityValue(packagePi, "package.json#pi"))}\0`) + return hash.digest("hex") +} + function resolvedArtifactContentDigest(entry: WorkspacePluginEntry, plugin: WorkspaceServerPlugin): string { - if ("dir" in entry) return directoryContentDigest(entry.dir) + if ("dir" in entry) return declaredDirectoryRuntimeContentDigest(entry.dir) ?? directoryContentDigest(entry.dir) if (typeof plugin.contentDigest === "string" && plugin.contentDigest.trim()) return plugin.contentDigest.trim() if (pluginHasAgentRuntimeContribution(plugin)) { throw new AgentRuntimeIdentityError( @@ -1042,9 +1110,39 @@ function emitLocalCliBridgeAuthWarning(): void { console.warn(message) } +function validateRuntimeScopeIdentityMigrations( + migrations: readonly RuntimeScopeIdentityMigrationAuthorization[] | undefined, +): void { + const seen = new Set() + for (const migration of migrations ?? []) { + const exactScope = [migration.agentTypeId, migration.workspaceScopeId, migration.sessionNamespace] + if (migration.schemaVersion !== 1 || exactScope.some((value) => value.includes("*") || value.includes("?"))) { + throw new AgentRuntimeIdentityError("runtime identity migrations require exact v1 scopes") + } + if (!migration.agentTypeId || !migration.workspaceScopeId) { + throw new AgentRuntimeIdentityError("runtime identity migrations require agent and workspace scopes") + } + if (!migration.fromIdentity.trim() || migration.fromIdentity.length > 8_192) { + throw new AgentRuntimeIdentityError("runtime identity migrations require a bounded non-empty legacy identity") + } + if (![migration.toIdentity, migration.evidenceDigest].every((value) => /^[a-f0-9]{64}$/.test(value))) { + throw new AgentRuntimeIdentityError("runtime identity migrations require canonical SHA-256 target identity and evidence") + } + const key = canonicalIdentityJson(jsonIdentityValue({ + agentTypeId: migration.agentTypeId, + workspaceScopeId: migration.workspaceScopeId, + sessionNamespace: migration.sessionNamespace, + fromIdentity: migration.fromIdentity, + }, "runtimeScopeIdentityMigration")) + if (seen.has(key)) throw new AgentRuntimeIdentityError("duplicate or conflicting runtime identity migration") + seen.add(key) + } +} + export async function createWorkspaceAgentServer( opts: CreateWorkspaceAgentServerOptions = {}, ): Promise { + validateRuntimeScopeIdentityMigrations(opts.runtimeScopeIdentityMigrations) const workspaceRoot = opts.workspaceRoot ?? process.cwd() const bridge = createInMemoryBridge() const resolvedMode = opts.runtimeModeAdapter?.id ?? opts.mode ?? autoDetectMode() @@ -1393,24 +1491,27 @@ export async function createWorkspaceAgentServer( ].filter((part): part is string => Boolean(part)).join("\n\n") || undefined : undefined + const identity = createResolvedRuntimeScopeIdentity({ + artifacts: contribution.artifacts, + validatedConfig: contribution.validatedConfig, + grants: contribution.grants, + placementClassIdentity: base.environment.sessionPlacementIdentity ?? base.environment.placementIdentity, + isolationMode: resolvedMode, + toolContractDigests: contribution.toolContractDigests, + provisioningIdentity: base.environment.sessionProvisioningIdentity ?? base.environment.provisioningFingerprint, + bindingInputs: { + sessionNamespace: base.sessionNamespace, + base: baseBindingInputs, + contribution: contribution.bindingInputs, + }, + }) return { ...base, - identity: createResolvedRuntimeScopeIdentity({ - artifacts: contribution.artifacts, - validatedConfig: contribution.validatedConfig, - grants: contribution.grants, - placementIdentity: base.environment.placementIdentity, - isolationMode: resolvedMode, - toolContractDigests: contribution.toolContractDigests, - provisioningGeneration: base.environment.provisioningFingerprint, - bindingInputs: { - baseRuntimeScopeIdentity: base.identity, - environmentProvisioningFingerprint: base.environment.provisioningFingerprint, - sessionNamespace: base.sessionNamespace, - base: baseBindingInputs, - contribution: contribution.bindingInputs, - }, - }), + identity, + bindingIdentity: JSON.stringify([base.bindingIdentity ?? base.identity, identity]), + ...(opts.runtimeScopeIdentityMigrations?.length + ? { sessionIdentityMigrations: opts.runtimeScopeIdentityMigrations } + : {}), pi: { ...basePi, ...selectedPi, diff --git a/packages/workspace/src/plugin.ts b/packages/workspace/src/plugin.ts index a34cf3333..48d187a0d 100644 --- a/packages/workspace/src/plugin.ts +++ b/packages/workspace/src/plugin.ts @@ -61,6 +61,7 @@ export type { BoringPackagePiField, BoringPackagePiSource, BoringPackagePiSourceObject, + BoringPackageRuntimeIdentityField, BoringPluginManifestErrorCode, BoringPluginManifestIssue, BoringPluginManifestValidationResult, diff --git a/packages/workspace/src/shared/plugins/__tests__/manifest.test.ts b/packages/workspace/src/shared/plugins/__tests__/manifest.test.ts index 253961663..f0653e644 100644 --- a/packages/workspace/src/shared/plugins/__tests__/manifest.test.ts +++ b/packages/workspace/src/shared/plugins/__tests__/manifest.test.ts @@ -34,6 +34,7 @@ describe("validateBoringPluginManifest", () => { boring: { front: "front/index.tsx", server: "server/index.ts", + runtimeIdentity: { paths: ["dist/server"] }, label: "Playground Data", }, }) @@ -41,9 +42,22 @@ describe("validateBoringPluginManifest", () => { expect(result.valid).toBe(true) if (result.valid) { expect(result.packageJson.boring?.front).toBe("front/index.tsx") + expect(result.packageJson.boring?.runtimeIdentity?.paths).toEqual(["dist/server"]) } }) + it("rejects unsafe runtime identity paths", () => { + const result = validateBoringPluginManifest({ + name: "unsafe-runtime-identity", + boring: { server: "server/index.ts", runtimeIdentity: { paths: ["../outside"] } }, + }) + expect(result.valid).toBe(false) + if (!result.valid) expect(result.issues).toContainEqual(expect.objectContaining({ + code: "INVALID_PATH", + field: "boring.runtimeIdentity.paths[0]", + })) + }) + it("rejects removed package.json#boring UI registration arrays", () => { const result = validateBoringPluginManifest({ name: "removed-ui-arrays", diff --git a/packages/workspace/src/shared/plugins/manifest.ts b/packages/workspace/src/shared/plugins/manifest.ts index 706f7d8f0..b74dce923 100644 --- a/packages/workspace/src/shared/plugins/manifest.ts +++ b/packages/workspace/src/shared/plugins/manifest.ts @@ -8,6 +8,11 @@ * - `boring`: workspace/UI package discovery (front/server entrypoints and labels) */ +export interface BoringPackageRuntimeIdentityField { + /** Runtime files/directories admitted into semantic Agent identity. */ + paths: string[] +} + export interface BoringPackageBoringField { /** Optional stable plugin id. Defaults to package.json#name normalized for package discovery. */ id?: string @@ -15,6 +20,7 @@ export interface BoringPackageBoringField { front?: string /** Workspace/UI support server entry. Set false to disable convention lookup. */ server?: string | false + runtimeIdentity?: BoringPackageRuntimeIdentityField label?: string } @@ -153,6 +159,17 @@ function validateBoringField( if (server !== undefined && server !== false && (typeof server !== "string" || !isSafePluginRelativePath(server))) { issues.push(issue("INVALID_PATH", "boring.server", "boring.server must be a safe relative path or false")) } + const runtimeIdentity = boring.runtimeIdentity + if (runtimeIdentity !== undefined) { + if (!isRecord(runtimeIdentity)) { + issues.push(issue("INVALID_FIELD", "boring.runtimeIdentity", "boring.runtimeIdentity must be an object when provided")) + } else { + validateStringArray(issues, runtimeIdentity.paths, "boring.runtimeIdentity.paths", true) + if (Array.isArray(runtimeIdentity.paths) && runtimeIdentity.paths.length === 0) { + issues.push(issue("INVALID_FIELD", "boring.runtimeIdentity.paths", "boring.runtimeIdentity.paths must not be empty")) + } + } + } if (boring.label !== undefined && typeof boring.label !== "string") { issues.push(issue("INVALID_FIELD", "boring.label", "boring.label must be a string when provided")) } @@ -160,6 +177,9 @@ function validateBoringField( ...(typeof boring.id === "string" ? { id: boring.id } : {}), ...(typeof boring.front === "string" ? { front: boring.front } : {}), ...(typeof boring.server === "string" || boring.server === false ? { server: boring.server } : {}), + ...(isRecord(runtimeIdentity) && Array.isArray(runtimeIdentity.paths) + ? { runtimeIdentity: { paths: runtimeIdentity.paths.filter((path): path is string => typeof path === "string") } } + : {}), ...(typeof boring.label === "string" ? { label: boring.label } : {}), } } diff --git a/plugins/ask-user/package.json b/plugins/ask-user/package.json index a056bcca2..2ef8fd624 100644 --- a/plugins/ask-user/package.json +++ b/plugins/ask-user/package.json @@ -14,7 +14,10 @@ "id": "ask-user", "label": "Questions", "front": "dist/front/index.js", - "server": "dist/server/index.js" + "server": "dist/server/index.js", + "runtimeIdentity": { + "paths": ["dist/server"] + } }, "pi": { "systemPrompt": "Use the ask-user tool when you need a decision, confirmation, or freeform input from the user before continuing. The user answers via the Questions panel and the tool resolves with their response." diff --git a/plugins/diagram/package.json b/plugins/diagram/package.json index 4d86c3295..82a8fdede 100644 --- a/plugins/diagram/package.json +++ b/plugins/diagram/package.json @@ -14,7 +14,10 @@ "id": "diagram", "label": "Diagram", "front": "dist/front/index.js", - "server": "dist/server/index.js" + "server": "dist/server/index.js", + "runtimeIdentity": { + "paths": ["dist/server"] + } }, "pi": { "systemPrompt": "Diagram plugin: open .excalidraw and .excalidraw.png workspace files through workspace.open.path. The editor autosaves .excalidraw JSON with optimistic conflict detection." diff --git a/plugins/tasks/package.json b/plugins/tasks/package.json index a68f9cc36..5189d62ed 100644 --- a/plugins/tasks/package.json +++ b/plugins/tasks/package.json @@ -14,7 +14,10 @@ "id": "tasks", "label": "Tasks", "front": "dist/front/index.js", - "server": "dist/server/index.js" + "server": "dist/server/index.js", + "runtimeIdentity": { + "paths": ["dist/server"] + } }, "files": [ "dist"