Skip to content

Commit 74ea82c

Browse files
committed
fix(core): the agent loop gives its turns the profile cache the parent bound reads
After the main merge the worker's turns resolve the parent run's bound (resolveParentBound) with SessionProfileCache, which the loop's runtime context no longer carried. It is now one of the loop's runtime services. A snapshot child of a path-confined parent stays bounded by the parent's scopes in the parent's cwd, so its file calls in its copy are refused: core has no copy relation, and moving relative entries into any cwd a create names would widen a confined run. A merged workspaces test reads the parent from the calling session, as Session.create names none.
1 parent f469990 commit 74ea82c

4 files changed

Lines changed: 77 additions & 3 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,12 @@ Do not rebuild business logic from inspection events. They are receipts, not inp
12531253
- Snapshot children: `delegate.start` takes `isolation` (`shared`, the
12541254
default, or `snapshot`). `@gent/workspaces` (`packages/extensions/src/workspaces.ts`)
12551255
owns the copies and uses only the public extension API, `runProcess` and
1256-
the platform services. Core has no workspace concept: the seam is
1256+
the platform services. A path-confined parent run bounds its snapshot
1257+
child by its scopes in the parent's cwd, so the child's file calls in its
1258+
copy are refused (decided in the narrow-only batch, Round 6, on least
1259+
authority): core has no copy relation, and moving a parent's relative
1260+
entries into whatever cwd a create names would let a confined run reach
1261+
any directory by naming it. Core has no workspace concept: the seam is
12571262
`Session.create({ cwd })`, and the child's profile is the copy's.
12581263
- The backend: `rift rpc` with `copyAll` (one snapshot, an exact copy) only
12591264
when the origin is a rift workspace on btrfs. Everywhere else, and for any

‎docs/extensions.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,12 @@ every tool of the child works there.
10991099
- A start is refused outside a git repository, and when less than 2 GB is
11001100
free where the copy goes or `df` cannot say; start the child with
11011101
`isolation: "shared"` then.
1102+
- A parent run with `paths` bounds its snapshot child by those paths as they
1103+
resolve in the parent's cwd, not in the copy: the copy lies outside them,
1104+
so the child's file tools refuse every path in its copy. gent does not move
1105+
the parent's paths into the copy, since core cannot tell a copy from any
1106+
other cwd a create names. Give such a child no `paths` parent, or use
1107+
`isolation: "shared"`.
11021108

11031109
A copy is not a sandbox. The child's `bash`, the cell and every other tool can
11041110
still reach your repository and every other path, as with `paths`. A snapshot

‎packages/core/src/runtime/agent-loop.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,8 +1610,14 @@ export const closeBranchGenerations = (retired: ReadonlyArray<BranchGeneration>)
16101610

16111611
// ── behavior ────────────────────────────────────────────────────────────────
16121612

1613+
/**
1614+
* What the loop's own fibers run with. `SessionProfileCache` is here for the
1615+
* turn's dispatch binding: it resolves the parent run's bound
1616+
* (`resolveParentBound`) at each turn, so the worker's turns need it.
1617+
*/
16131618
type AgentLoopRuntimeServices =
16141619
| SessionStorage
1620+
| SessionProfileCache
16151621
| RelationshipStorage
16161622
| ToolCallBindingStorage
16171623
| GentPlatform

‎packages/extensions/tests/workspaces.test.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,11 @@ const snapshotModel = () =>
10441044
})
10451045

10461046
/** The shipped extensions, with the workspaces extension on a rift that is not there. */
1047-
const harnessIn = (origin: string, home: string) =>
1047+
const harnessIn = (
1048+
origin: string,
1049+
home: string,
1050+
admission?: Parameters<typeof createRpcHarness>[0]["admission"],
1051+
) =>
10481052
createRpcHarness({
10491053
...e2ePreset,
10501054
extensionInputs: [
@@ -1056,6 +1060,7 @@ const harnessIn = (origin: string, home: string) =>
10561060
providerLayer: snapshotModel(),
10571061
cwd: origin,
10581062
home,
1063+
admission,
10591064
})
10601065

10611066
const completionOf = <
@@ -1083,6 +1088,8 @@ const WorkOfCompletion = Schema.Struct({
10831088
workspace: Schema.Struct({ path: Schema.String, branch: Schema.String }),
10841089
})
10851090

1091+
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
1092+
10861093
type CreateParams = Parameters<ReturnType<typeof testToolContext>["Session"]["create"]>[0]
10871094

10881095
describe("a snapshot child", () => {
@@ -1153,6 +1160,55 @@ describe("a snapshot child", () => {
11531160
30_000,
11541161
)
11551162

1163+
it.live(
1164+
"of a path-confined parent is refused file calls in its copy, which lies outside the parent's paths",
1165+
() =>
1166+
Effect.gen(function* () {
1167+
const origin = yield* dirtyRepository
1168+
const home = yield* makeTempDirectoryScoped("ws-home-")
1169+
// The parent run may write anywhere in its own cwd, and nowhere else.
1170+
const harness = yield* harnessIn(origin, home, {
1171+
runSpec: { overrides: { paths: [{ path: ".", access: "write" }] } },
1172+
})
1173+
yield* harness.client.message.send({
1174+
sessionId: harness.sessionId,
1175+
branchId: harness.branchId,
1176+
content: "delegate it in a copy",
1177+
})
1178+
yield* waitFor(
1179+
harness.client.session.getSnapshot({
1180+
sessionId: harness.sessionId,
1181+
branchId: harness.branchId,
1182+
}),
1183+
(current) =>
1184+
Predicate.isNotUndefined(completionOf(current.messages)) &&
1185+
current.runtime._tag === "Idle",
1186+
15_000,
1187+
"the child's completion woke the parent",
1188+
)
1189+
const child = (yield* harness.client.session.list()).find(
1190+
(session) => session.parentSessionId === harness.sessionId,
1191+
)
1192+
expect(child?.cwd).not.toBe(origin)
1193+
const copy = child?.cwd ?? ""
1194+
// The parent's scopes resolve against the parent's cwd, not the copy.
1195+
expect(yield* exists(`${copy}/child.txt`)).toBe(false)
1196+
expect(yield* exists(`${origin}/child.txt`)).toBe(false)
1197+
const branches = yield* harness.client.branch.list({
1198+
sessionId: child?.id ?? harness.sessionId,
1199+
})
1200+
const messages = yield* harness.client.message.list({
1201+
branchId: branches[0]?.id ?? harness.branchId,
1202+
})
1203+
const results = messages
1204+
.flatMap((message) => message.parts)
1205+
.filter((part) => part.type === "tool-result")
1206+
expect(results.map((part) => part.isFailure)).toEqual([true])
1207+
expect(encodeJson(results[0]?.result)).toContain("outside this agent's paths")
1208+
}).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.timeout("25 seconds")),
1209+
30_000,
1210+
)
1211+
11561212
it.live(
11571213
"whose session cannot be created leaves no copy behind",
11581214
() =>
@@ -1264,7 +1320,8 @@ describe("a snapshot child", () => {
12641320
const session = new Session({
12651321
id: sessionId,
12661322
cwd: params.cwd,
1267-
parentSessionId: params.parentSessionId,
1323+
// The calling session is always the parent (`Session.create` names none).
1324+
parentSessionId: parentA.sessionId,
12681325
parentBranchId: params.parentBranchId,
12691326
createdAt: now,
12701327
updatedAt: now,

0 commit comments

Comments
 (0)