Skip to content
Closed

closed #7635

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/thread-settings-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
readonly label: string;
readonly description: string;
}> = [
{
mode: "read-only",
label: "Read only",
description: "Allow inspection but deny commands and file changes that need write access.",
},
{
mode: "approval-required",
label: "Supervised",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ function pageIncludesTerminalTaskResult(input: {

function runtimeModeRank(mode: RuntimeMode): number {
switch (mode) {
case "read-only":
return -1;
case "approval-required":
return 0;
case "auto-accept-edits":
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,9 @@ const isClaudeRuntimeReadOnlyFullAccessSandboxPolicy = Schema.is(
function sandboxPolicyKindForClaudeRuntimePolicy(
runtimePolicy: ProviderAdapterV2RuntimePolicy,
): ClaudeRuntimeSandboxPolicyKindName | undefined {
if (runtimePolicy.sandboxPolicy === undefined && runtimePolicy.runtimeMode === "read-only") {
return "readOnly";
}
return runtimePolicy.sandboxPolicy !== undefined &&
isClaudeRuntimeSandboxPolicyKind(runtimePolicy.sandboxPolicy)
? runtimePolicy.sandboxPolicy.type
Expand Down Expand Up @@ -1205,6 +1208,8 @@ function permissionModeForClaudeRuntimePolicy(
}

switch (runtimePolicy.runtimeMode) {
case "read-only":
return "dontAsk";
case "approval-required":
return "default";
case "auto-accept-edits":
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,12 @@ describe("CodexAdapterV2 runtime policy", () => {
it.effect("derives concrete Codex turn policies from every T3 runtime mode", () =>
Effect.gen(function* () {
const build = (
runtimeMode: "approval-required" | "auto-accept-edits" | "auto" | "full-access",
runtimeMode:
| "read-only"
| "approval-required"
| "auto-accept-edits"
| "auto"
| "full-access",
) =>
buildCodexTurnStartParams({
nativeThreadId: `native-${runtimeMode}`,
Expand All @@ -282,11 +287,15 @@ describe("CodexAdapterV2 runtime policy", () => {
},
});

const readOnly = yield* build("read-only");
const approvalRequired = yield* build("approval-required");
const autoAcceptEdits = yield* build("auto-accept-edits");
const auto = yield* build("auto");
const fullAccess = yield* build("full-access");

assert.equal(readOnly.approvalPolicy, "never");
assert.equal(readOnly.approvalsReviewer, "user");
assert.equal(readOnly.sandboxPolicy?.type, "readOnly");
assert.equal(approvalRequired.approvalPolicy, "untrusted");
assert.equal(approvalRequired.approvalsReviewer, "user");
assert.equal(approvalRequired.sandboxPolicy?.type, "readOnly");
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,14 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): {
readonly sandboxPolicy: CodexSchema.V2TurnStartParams__SandboxPolicy;
} {
switch (runtimeMode) {
case "read-only":
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: {
type: "readOnly",
},
};
case "approval-required":
return {
approvalPolicy: "untrusted",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function makeExecutorLayer(input: {
Layer.succeed(
ProviderTurnStartServiceV2,
ProviderTurnStartServiceV2.of({
fail: () => Effect.void,
start: () =>
Effect.gen(function* () {
yield* record("start");
Expand Down
32 changes: 29 additions & 3 deletions apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface OrchestrationEffectExecutorV2Shape {
readonly execute: (
effect: OrchestrationEffectV2,
) => Effect.Effect<void, OrchestrationEffectExecutionError>;
readonly terminalize?: (
effect: OrchestrationEffectV2,
error: string,
) => Effect.Effect<void, OrchestrationEffectExecutionError>;
}

export class OrchestrationEffectExecutorV2 extends Context.Service<
Expand Down Expand Up @@ -95,6 +99,25 @@ export const executorLayer: Layer.Layer<
const runtimeRequests = yield* RuntimeRequestServiceV2;
const threadTitleRegeneration = yield* ThreadTitleRegenerationService;
return OrchestrationEffectExecutorV2.of({
terminalize: (effect, failure) => {
if (
effect.request.type !== "provider-turn.start" &&
effect.request.type !== "provider-turn.restart"
)
return Effect.void;
return providerTurnStart
.fail({ threadId: effect.threadId, runId: effect.request.runId, cause: failure })
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationEffectExecutionError({
effectId: effect.id,
effectType: effect.request.type,
cause,
}),
),
);
},
execute: (effect) => {
switch (effect.request.type) {
case "provider-session.detach":
Expand Down Expand Up @@ -532,9 +555,12 @@ export const layerWithOptions = (
.succeed({ effectId: effect.id, workerId })
.pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)))
: effect.attemptCount >= maxAttempts
? yield* outbox
.fail({ effectId: effect.id, workerId, error })
.pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)))
? yield* Effect.gen(function* () {
yield* executor.terminalize?.(effect, error) ?? Effect.void;
return yield* outbox
.fail({ effectId: effect.id, workerId, error })
.pipe(Effect.onError((cause) => terminalizeClaim(effect, cause)));
})
: yield* outbox
.retry({
effectId: effect.id,
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5522,7 +5522,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: {
...state.preparationItem,
title: command.phase === "worktree" ? "Preparing worktree" : "Starting setup script",
title:
command.phase === "worktree"
? "Preparing worktree"
: command.phase === "verification"
? "Verifying prepared worktree"
: "Starting setup script",
updatedAt: now,
},
});
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/orchestration-v2/PreparedWorktreeVerifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as PreparedWorktreeVerifier from "./PreparedWorktreeVerifier.ts";

const checkout = {
repositoryRoot: process.cwd(),
gitCommonDir: process.cwd(),
worktreePath: process.cwd(),
branch: "prepared",
startingCommit: "abc123",
} as const;

function makeLayer(status = "") {
const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => {
const command = input.args.join(" ");
let stdout = "";
let exitCode = 0;
if (command === "rev-parse --show-toplevel") {
stdout =
input.cwd === checkout.repositoryRoot ? checkout.repositoryRoot : checkout.worktreePath;
} else if (command === "rev-parse --path-format=absolute --git-common-dir") {
stdout = checkout.gitCommonDir;
} else if (command === "symbolic-ref --quiet --short HEAD") {
stdout = checkout.branch;
} else if (command === "rev-parse HEAD") {
stdout = checkout.startingCommit;
} else if (command === "status --porcelain=v1 -z") {
stdout = status;
} else if (command === "worktree list --porcelain -z") {
stdout = [
`worktree ${checkout.worktreePath}`,
`HEAD ${checkout.startingCommit}`,
`branch refs/heads/${checkout.branch}`,
"",
].join("\0");
} else {
exitCode = 1;
}
return Effect.succeed({
exitCode: ChildProcessSpawner.ExitCode(exitCode),
stdout,
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
});
};
return PreparedWorktreeVerifier.layer.pipe(
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({ execute })),
Layer.provideMerge(NodeServices.layer),
);
}

it.effect("proves an exact clean registered worktree", () =>
Effect.gen(function* () {
const verifier = yield* PreparedWorktreeVerifier.PreparedWorktreeVerifier;
assert.deepEqual(yield* verifier.verify(checkout, checkout.repositoryRoot), checkout);
}).pipe(Effect.provide(makeLayer())),
);

it.effect("rejects a prepared worktree that became dirty", () =>
Effect.gen(function* () {
const verifier = yield* PreparedWorktreeVerifier.PreparedWorktreeVerifier;
const failure = yield* Effect.flip(verifier.verify(checkout, checkout.repositoryRoot));
assert.equal(failure.reason, "dirty_worktree");
}).pipe(Effect.provide(makeLayer("?? changed.txt\0"))),
);
Loading
Loading