diff --git a/src/durable-operations.test.ts b/src/durable-operations.test.ts index 6959b7cf4..911dc1ee0 100644 --- a/src/durable-operations.test.ts +++ b/src/durable-operations.test.ts @@ -376,6 +376,86 @@ test("dependency_sync frozen recipe succeeds without changing manifest or lock i } }); +test("dependency_sync OWNER_DIRECT isolated mode does not require carrier authority", async () => { + const f = await fixture(); + try { + const project = join(f.root, "project"); + await mkdir(project); + await writeFile(join(project, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }) + "\n"); + await writeFile(join(project, "package-lock.json"), JSON.stringify({ name: "fixture", version: "1.0.0", lockfileVersion: 3, packages: {} }) + "\n"); + await git(project, "init"); + await git(project, "config", "user.email", "devspace@example.com"); + await git(project, "config", "user.name", "DevSpace Test"); + await git(project, "add", "."); + await git(project, "commit", "-m", "fixture"); + let calls = 0; + const runner: CommandRunner = async () => { + calls += 1; + return { exitCode: 0, stdout: "ok", stderr: "" }; + }; + const manager = new DurableOperationManager(f.config, runner); + try { + const result = await manager.dependencySync({ + attemptKey: "deps-owner-direct-isolated-1", + workspaceId: "ws_isolated", + workspaceRoot: project, + recipe: "npm_ci", + authorityMode: "OWNER_DIRECT", + ownerDirectIsolated: true, + }); + assert.equal(result.status, "succeeded"); + assert.equal(calls, 1); + const witness = manager.store.readDependencyTerminal(result.operationId); + assert.equal(witness?.leaseId, "OWNER_DIRECT_ISOLATED"); + assert.equal(witness?.requestHash, result.requestHash); + assert.equal(witness?.frozenInputsUnchanged, true); + assert.equal((result.request as Record).ownerDirectIsolated, true); + } finally { + manager.close(); + } + } finally { + await f.cleanup(); + } +}); + +test("dependency_sync OWNER_DIRECT isolated reconciliation requires its exact terminal witness", async () => { + const f = await fixture(); + try { + const project = join(f.root, "project"); + await mkdir(project); + await writeFile(join(project, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }) + "\n"); + await writeFile(join(project, "package-lock.json"), JSON.stringify({ name: "fixture", version: "1.0.0", lockfileVersion: 3, packages: {} }) + "\n"); + await git(project, "init"); + await git(project, "config", "user.email", "devspace@example.com"); + await git(project, "config", "user.name", "DevSpace Test"); + await git(project, "add", "."); + await git(project, "commit", "-m", "fixture"); + const manager = new DurableOperationManager(f.config, async () => { + throw new Error("simulated unacknowledged dependency process"); + }); + try { + const result = await manager.dependencySync({ + attemptKey: "deps-owner-direct-isolated-unknown", + workspaceId: "ws_isolated", + workspaceRoot: project, + recipe: "npm_ci", + authorityMode: "OWNER_DIRECT", + ownerDirectIsolated: true, + }); + assert.equal(result.status, "outcome_unknown"); + assert.throws( + () => manager.reconcileOwnerDirectDependencySync(result.operationId), + /No exact terminal witness exists/, + ); + assert.equal(manager.store.getByOperationId(result.operationId)?.status, "outcome_unknown"); + } finally { + manager.close(); + } + } finally { + await f.cleanup(); + } +}); + test("dependency_sync rejects a changed request before creating an operation or pin", async () => { const f = await fixture(); try { diff --git a/src/durable-operations.ts b/src/durable-operations.ts index fe5f29b6b..c66d03b71 100644 --- a/src/durable-operations.ts +++ b/src/durable-operations.ts @@ -187,6 +187,31 @@ export class DurableOperationStore { .run(operationId,requestHash,leaseId,exitCode,frozenInputsUnchanged?1:0); } + readDependencyTerminal(operationId: string): { + operationId: string; + requestHash: string; + leaseId: string; + exitCode: number; + frozenInputsUnchanged: boolean; + } | undefined { + const row = this.database.sqlite.prepare( + "select operation_id,request_hash,lease_id,exit_code,frozen_inputs_unchanged from dependency_terminal_witnesses where operation_id=?", + ).get(operationId) as { + operation_id: string; + request_hash: string; + lease_id: string; + exit_code: number; + frozen_inputs_unchanged: number; + } | undefined; + return row ? { + operationId: row.operation_id, + requestHash: row.request_hash, + leaseId: row.lease_id, + exitCode: row.exit_code, + frozenInputsUnchanged: row.frozen_inputs_unchanged === 1, + } : undefined; + } + markInterruptedUnknown(): number { const now = new Date().toISOString(); const preflight = this.database.sqlite.prepare(` @@ -344,6 +369,12 @@ export interface DependencySyncInput { workspaceRoot: string; recipe: DependencySyncRecipe; authorityMode?: ExecutionAuthorityMode; + /** + * Trusted server-side admission only. Callers cannot set this through MCP. + * Allows OWNER_DIRECT frozen dependency sync without a carrier only for a + * DevSpace-managed isolated worktree already admitted for this conversation. + */ + ownerDirectIsolated?: boolean; } export type CommandRunner = ( @@ -688,6 +719,7 @@ export class DurableOperationManager { workspaceRoot, recipe: input.recipe, frozenInputs: before, + ...(input.ownerDirectIsolated === true ? { ownerDirectIsolated: true } : {}), }; const requestHash = hashJson(request); const operationId = stableOperationId("dependency_sync", workspaceRoot, input.attemptKey); @@ -696,64 +728,92 @@ export class DurableOperationManager { } async dependencySync(input: DependencySyncInput, consumerContext?: unknown): Promise { - if (!this.consumer) throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED", "dependency sync requires a trusted host authority reader"); + const ownerDirectIsolated = input.ownerDirectIsolated === true; + if (!ownerDirectIsolated && !this.consumer) { + throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED", "dependency sync requires a trusted host authority reader"); + } const consumer = this.consumer; const {subject, request, workspaceRoot, authorityMode, frozenInputs, before, baseRevision, requestHash, operationId} = await this.planDependencySync(input); - const { record, created, binding, pinnedVersion } = this.store.atomic(() => { - const binding = consumer.authorize(consumerContext, subject); + const directWitnessId = "OWNER_DIRECT_ISOLATED"; + const prepared = this.store.atomic(() => { + if (ownerDirectIsolated) { + const value = this.store.createOrReplay({ + operationId, + attemptKey: input.attemptKey, + requestHash, + kind: "dependency_sync", + authorityMode, + scopeRoot: workspaceRoot, + workspaceId: input.workspaceId, + request, + }); + return { ...value, binding: undefined, pinnedVersion: undefined }; + } + const binding = consumer!.authorize(consumerContext, subject); const value = this.store.createOrReplay({ - operationId, - attemptKey: input.attemptKey, - requestHash, - kind: "dependency_sync", - authorityMode, - scopeRoot: workspaceRoot, - workspaceId: input.workspaceId, - request, + operationId, + attemptKey: input.attemptKey, + requestHash, + kind: "dependency_sync", + authorityMode, + scopeRoot: workspaceRoot, + workspaceId: input.workspaceId, + request, }); - const pinnedVersion = value.created ? consumer.pin(consumerContext, subject, binding) : binding.leaseVersion; - return {...value, binding, pinnedVersion}; + const pinnedVersion = value.created ? consumer!.pin(consumerContext, subject, binding) : binding.leaseVersion; + return { ...value, binding, pinnedVersion }; }); + const { record, created, binding, pinnedVersion } = prepared; if (!created) return replayResult(record); const finish = (patch: Parameters[1]) => this.store.atomic(() => { - consumer.finish(consumerContext, subject, binding, pinnedVersion); + if (!ownerDirectIsolated) { + consumer!.finish(consumerContext, subject, binding!, pinnedVersion!); + } return this.store.finish(operationId, patch); }); try { - if (await readGitHead(workspaceRoot) !== baseRevision || hashJson(await hashFiles(workspaceRoot, frozenInputs)) !== hashJson(before)) { - throw new Error("Frozen dependency input or base revision changed before launch"); - } - consumer.assertPinned(consumerContext, subject, binding, pinnedVersion); - const command = dependencyCommand(input.recipe); - const result = await this.runCommand(command.command, command.args, workspaceRoot); - if (result.exitCode === null) throw new Error("Command termination is unconfirmed"); - const after = await hashFiles(workspaceRoot, frozenInputs); - const frozenInputsUnchanged = hashJson(before) === hashJson(after) && await readGitHead(workspaceRoot) === baseRevision; - this.store.recordDependencyTerminal(operationId,requestHash,binding.leaseId,result.exitCode,frozenInputsUnchanged); - if (!frozenInputsUnchanged) { - return finish({ - status: "failed", - retrySafe: false, - errorCode: "FROZEN_INPUT_CHANGED", - errorMessage: "Dependency specification or lock input changed during a FROZEN dependency sync.", - receipt: { recipe: input.recipe, before, after, exitCode: result.exitCode }, - }); - } - if (result.exitCode !== 0) { + if (await readGitHead(workspaceRoot) !== baseRevision || hashJson(await hashFiles(workspaceRoot, frozenInputs)) !== hashJson(before)) { + throw new Error("Frozen dependency input or base revision changed before launch"); + } + if (!ownerDirectIsolated) { + consumer!.assertPinned(consumerContext, subject, binding!, pinnedVersion!); + } + const command = dependencyCommand(input.recipe); + const result = await this.runCommand(command.command, command.args, workspaceRoot); + if (result.exitCode === null) throw new Error("Command termination is unconfirmed"); + const after = await hashFiles(workspaceRoot, frozenInputs); + const frozenInputsUnchanged = hashJson(before) === hashJson(after) && await readGitHead(workspaceRoot) === baseRevision; + this.store.recordDependencyTerminal( + operationId, + requestHash, + ownerDirectIsolated ? directWitnessId : binding!.leaseId, + result.exitCode, + frozenInputsUnchanged, + ); + if (!frozenInputsUnchanged) { + return finish({ + status: "failed", + retrySafe: false, + errorCode: "FROZEN_INPUT_CHANGED", + errorMessage: "Dependency specification or lock input changed during a FROZEN dependency sync.", + receipt: { recipe: input.recipe, before, after, exitCode: result.exitCode }, + }); + } + if (result.exitCode !== 0) { + return finish({ + status: "failed", + retrySafe: false, + errorCode: "DEPENDENCY_SYNC_FAILED", + errorMessage: redactSecrets(result.stderr || `${command.command} exited ${result.exitCode}`), + receipt: { recipe: input.recipe, frozenInputs: after, exitCode: result.exitCode }, + }); + } return finish({ - status: "failed", + status: "succeeded", retrySafe: false, - errorCode: "DEPENDENCY_SYNC_FAILED", - errorMessage: redactSecrets(result.stderr || `${command.command} exited ${result.exitCode}`), receipt: { recipe: input.recipe, frozenInputs: after, exitCode: result.exitCode }, }); - } - return finish({ - status: "succeeded", - retrySafe: false, - receipt: { recipe: input.recipe, frozenInputs: after, exitCode: result.exitCode }, - }); } catch (error) { return this.store.atomic(() => { if (JSON.stringify(this.store.getByOperationId(operationId)) !== JSON.stringify(record)) { @@ -764,6 +824,45 @@ export class DurableOperationManager { } } + reconcileOwnerDirectDependencySync(operationId: string): DurableOperationRecord { + return this.store.atomic(() => { + const record = this.store.getByOperationId(operationId); + if ( + !record || + record.kind !== "dependency_sync" || + record.authorityMode !== "OWNER_DIRECT" || + record.request.ownerDirectIsolated !== true + ) { + throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED", "owner-direct isolated reconciliation is not authorized for this operation"); + } + if (record.status === "succeeded" || record.status === "failed") return record; + const witness = this.store.readDependencyTerminal(operationId); + if ( + !witness || + witness.requestHash !== record.requestHash || + witness.leaseId !== "OWNER_DIRECT_ISOLATED" + ) { + throw new DurableOperationError( + "RECONCILIATION_REQUIRED", + "No exact terminal witness exists for this owner-direct isolated dependency operation; preserve outcome_unknown and do not replay.", + record, + ); + } + return this.store.finish(operationId, { + status: witness.exitCode === 0 && witness.frozenInputsUnchanged ? "succeeded" : "failed", + retrySafe: false, + receipt: { + reconciliation: { + mode: "OWNER_DIRECT_ISOLATED", + requestHash: witness.requestHash, + exitCode: witness.exitCode, + frozenInputsUnchanged: witness.frozenInputsUnchanged, + }, + }, + }); + }); + } + reconcileDependencySync(operationId: string, evidence: DependencyReconciliationEvidence, consumerContext?: unknown): DurableOperationRecord { if (!this.consumer) throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED", "dependency reconciliation requires trusted host authority"); const consumer = this.consumer; @@ -806,6 +905,9 @@ export class DurableOperationManager { if (!record) throw new DurableOperationError("RECONCILIATION_REQUIRED", `Unknown durable operation: ${operationId}`); if (record.kind === "cutover_start") return this.reconcileCutoverStart(operationId,consumerContext); if (record.kind === "dependency_sync") { + if (record.authorityMode === "OWNER_DIRECT" && record.request.ownerDirectIsolated === true) { + return this.reconcileOwnerDirectDependencySync(operationId); + } if (!this.consumer || typeof record.request.baseRevision !== "string") throw new ControlPlaneOwnershipError("AUTHORITY_REQUIRED", "dependency reconciliation requires revision-bound host authority"); const subject = {operationId, requestHash:record.requestHash, workspaceRoot:record.scopeRoot, baseRevision:record.request.baseRevision, operation:"dependency_sync" as const}; return this.reconcileDependencySync(operationId, this.consumer.readReconciliation(consumerContext, subject), consumerContext); diff --git a/src/server.test.ts b/src/server.test.ts index 35692b531..275702c4d 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -4530,6 +4530,54 @@ test("OWNER_DIRECT workspace_clone works and dependency_sync denies unauthentica assert.equal(nexusBlocked.isError, true); }); +test("OWNER_DIRECT dependency_sync bypasses carrier only for a managed isolated worktree", async (t) => { + const context = await fixture(t, { git: true }); + await writeFile(join(context.project, "package.json"), JSON.stringify({ name: "isolated-fixture", version: "1.0.0" }) + "\n"); + await writeFile(join(context.project, "package-lock.json"), JSON.stringify({ + name: "isolated-fixture", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { "": { name: "isolated-fixture", version: "1.0.0" } }, + }) + "\n"); + await git(context.project, ["add", "package.json", "package-lock.json"]); + await git(context.project, ["commit", "-m", "add dependency fixture"]); + + const isolated = await callOpen(context.client, context.project, "isolated-dependency-sync", "worktree"); + const isolatedWorkspaceId = structuredContent(isolated).workspaceId as string; + const isolatedResult = await context.client.callTool({ + name: "dependency_sync", + arguments: { + workspaceId: isolatedWorkspaceId, + attemptKey: "isolated-owner-direct-deps", + recipe: "npm_ci", + authorityMode: "OWNER_DIRECT", + }, + _meta: { "openai/session": "isolated-dependency-sync" }, + }); + assert.equal(isolatedResult.isError, undefined, JSON.stringify(isolatedResult)); + assert.equal(structuredContent(isolatedResult).status, "succeeded"); + assert.equal( + ((structuredContent(isolatedResult).request as Record) ?? {}).ownerDirectIsolated, + true, + ); + + const checkout = await callOpen(context.client, context.project, "checkout-dependency-sync", "checkout"); + const checkoutWorkspaceId = structuredContent(checkout).workspaceId as string; + const checkoutResult = await context.client.callTool({ + name: "dependency_sync", + arguments: { + workspaceId: checkoutWorkspaceId, + attemptKey: "checkout-owner-direct-deps", + recipe: "npm_ci", + authorityMode: "OWNER_DIRECT", + }, + _meta: { "openai/session": "checkout-dependency-sync" }, + }); + assert.equal(checkoutResult.isError, true); + assert.match(JSON.stringify(checkoutResult), /authenticated MCP client context is required/); +}); + test("command_status metadata annotations and minimal mode visibility", async (t) => { // Test minimal mode tools const context = await fixture(t, { toolMode: "minimal" }); diff --git a/src/server.ts b/src/server.ts index c649579e6..ba5dbd9d3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4112,9 +4112,14 @@ export function createMcpServer( }, async ({ workspaceId, attemptKey, recipe, authorityMode }, extra) => { const { _meta } = extra; - const consumerContext = dependencyConsumerContext(extra); - await workspaces.assertConversationMutationAllowed(workspaceId, openAiConversationScopeId(_meta)); + const safety = await workspaces.assertConversationMutationAllowed(workspaceId, openAiConversationScopeId(_meta)); const workspace = workspaces.getWorkspace(workspaceId); + const ownerDirectIsolated = + authorityMode === "OWNER_DIRECT" && + workspace.mode === "worktree" && + workspace.worktree?.managed === true && + safety.state === "ISOLATED_WORKTREE"; + const consumerContext = ownerDirectIsolated ? undefined : dependencyConsumerContext(extra); try { const operation = await durableOperations.dependencySync({ workspaceId, @@ -4122,6 +4127,7 @@ export function createMcpServer( attemptKey, recipe, authorityMode, + ownerDirectIsolated, }, consumerContext); return operationResponse(operation); } catch (error) { @@ -4169,8 +4175,12 @@ export function createMcpServer( annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ operationId }, extra) => { - const context = ["dependency_sync", "cutover_start"].includes(durableOperations.store.getByOperationId(operationId)?.kind ?? "") - ? dependencyConsumerContext(extra) : undefined; + const record = durableOperations.store.getByOperationId(operationId); + const context = + record?.kind === "cutover_start" || + (record?.kind === "dependency_sync" && record.request.ownerDirectIsolated !== true) + ? dependencyConsumerContext(extra) + : undefined; return operationResponse(await durableOperations.reconcile(operationId, context)); }, );