@ricsam/r5d-worker 0.0.141 → 0.0.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.141",
3
+ "version": "0.0.143",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/main.mjs CHANGED
@@ -7,7 +7,7 @@ import { startManagerRpc } from "./runtime/releases/rpc-main.mjs";
7
7
  import { ManagerRpcConfig } from "./runtime/releases/rpc-protocol.mjs";
8
8
  const args = process.argv.slice(2);
9
9
  if (args.includes("--version")) {
10
- console.log(`r5d-worker ${true ? "0.0.141" : "development"}`);
10
+ console.log(`r5d-worker ${true ? "0.0.143" : "development"}`);
11
11
  } else if (!args.length || args.includes("--help")) {
12
12
  console.log(
13
13
  "Usage: r5d-worker start --label <label> [--root <dir>] [--base-url <url>] [--token <worker-token>]\n r5d-worker executor /absolute/private/config.json\n r5d-worker manager /absolute/private/config.json\n\nRun independently supervised durable runtime services. Provision configuration with r5dinfra."
@@ -15,7 +15,7 @@ if (args.includes("--version")) {
15
15
  } else if (args[0] === "start") {
16
16
  const runtime = await startPersonalWorker(
17
17
  parsePersonalWorkerOptions(args.slice(1)),
18
- true ? "0.0.141" : "development"
18
+ true ? "0.0.143" : "development"
19
19
  );
20
20
  console.log(`Worker connected: ${runtime.resourceId}`);
21
21
  let closing = false;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.141",
3
+ "version": "0.0.143",
4
4
  "type": "module"
5
5
  }
@@ -16,7 +16,7 @@ class PersonalActionScheduler {
16
16
  continue;
17
17
  }
18
18
  const request = action.request;
19
- const lane = request.kind === "tcp" && typeof request.command?.streamId === "string" ? `tcp:${request.sessionId}:${request.command.streamId}` : request.kind === "inspect" && ["processes", "status"].includes(request.command?.method ?? "") ? `inspect:${request.sessionId}` : "mutation";
19
+ const lane = request.kind === "tcp" && typeof request.command?.streamId === "string" ? `tcp:${request.sessionId}:${request.command.streamId}` : request.kind === "terminal" && ["poll", "processPoll"].includes(request.command?.method ?? "") ? `terminal-poll:${request.sessionId}:${request.command?.operationId ?? request.command?.runId ?? "unknown"}` : request.kind === "inspect" && ["processes", "status"].includes(request.command?.method ?? "") ? `inspect:${request.sessionId}` : "mutation";
20
20
  if (this.active.size >= this.limit || [...this.active.values()].some((value) => value.lane === lane)) continue;
21
21
  const task = Promise.resolve().then(() => this.execute(action)).catch(this.failed).finally(() => this.active.delete(action.id));
22
22
  this.active.set(action.id, { hash: action.requestHash, lane, task });
@@ -23,7 +23,15 @@ const PersonalWorkerGrant = z.object({
23
23
  workerHello: RuntimeHello,
24
24
  leaseExpiresAt: z.coerce.number().int().positive()
25
25
  }).passthrough();
26
- const Workbench = z.object({ id: StorageId, repositoryId: StorageId, branch: BranchName, sessionId: RuntimeId, sharedSessionId: RuntimeId.optional() }).strict();
26
+ const Workbench = z.object({
27
+ id: StorageId,
28
+ repositoryId: StorageId,
29
+ branch: BranchName,
30
+ sessionId: RuntimeId,
31
+ rootProfile: z.enum(["account", "project"]).default("project"),
32
+ namespace: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/).optional(),
33
+ projectName: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/).optional()
34
+ }).strict();
27
35
  const PersonalWorkspaceRequest = z.object({
28
36
  protocol: z.literal(1),
29
37
  userId: RuntimeId,
@@ -67,9 +75,10 @@ async function openPersonalWorkerRuntime(options) {
67
75
  privateDirectory(root);
68
76
  if (grant.workerFence.installationId !== grant.installationId || grant.workerHello.installationId !== grant.installationId || grant.workerHello.instanceId !== grant.workerFence.ownerId || grant.workerHello.role !== "worker-adapter")
69
77
  throw new Error("Invalid personal worker grant");
70
- const credentialsRoot = path.join(root, "credentials"), workspaceRoot = path.join(root, "workspace", grant.installationId);
78
+ const credentialsRoot = path.join(root, "credentials"), workspaceRoot = path.join(root, "workspace", grant.installationId), authorityRoot = path.join(root, "workspace-authority", grant.installationId), hostRoot = path.parse(workspaceRoot).root;
71
79
  privateDirectory(credentialsRoot);
72
80
  privateDirectory(workspaceRoot);
81
+ privateDirectory(authorityRoot);
73
82
  const token = await personalExecutorToken(root, grant), controllerId = `controller-${createHash("sha256").update(grant.instanceId).digest("hex").slice(0, 24)}`;
74
83
  const daemon = await startHostExecutor({
75
84
  installationId: grant.installationId,
@@ -86,7 +95,7 @@ async function openPersonalWorkerRuntime(options) {
86
95
  ownerId: controllerId,
87
96
  tokenHash: tokenHash(token),
88
97
  userIds: [grant.userId],
89
- cwdRoots: [workspaceRoot],
98
+ cwdRoots: [hostRoot],
90
99
  lanes: ["general", "utility", "control"],
91
100
  credentialIds: []
92
101
  }
@@ -123,13 +132,14 @@ exec ${quote(executable)} ${quote(cli)} "$@"
123
132
  timeoutMs: 1e4
124
133
  });
125
134
  let current = grant, installed = false, retirementPending = false;
126
- const manifests = /* @__PURE__ */ new Map();
135
+ const manifests = /* @__PURE__ */ new Map(), routes = /* @__PURE__ */ new Map();
127
136
  const manifestFile = path.join(root, "workbenches.json");
128
137
  try {
129
138
  const rows = z.array(ApprovedWorkbench).parse(readPrivateJson(manifestFile));
130
139
  for (const row of rows) {
131
- if (row.userId !== grant.userId || !row.cwd.startsWith(workspaceRoot + path.sep)) throw new Error("Invalid retained workbench");
132
- manifests.set(row.sessionId, row);
140
+ if (row.userId !== grant.userId || row.cwd !== workspaceRoot && !row.cwd.startsWith(workspaceRoot + path.sep))
141
+ throw new Error("Invalid retained workbench");
142
+ manifests.set(row.id, row);
133
143
  }
134
144
  } catch (error) {
135
145
  if (error.code !== "ENOENT") throw error;
@@ -160,7 +170,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
160
170
  ownerId: next.workerHello.instanceId,
161
171
  tokenHash: tokenHash(token),
162
172
  userIds: [grant.userId],
163
- cwdRoots: [workspaceRoot],
173
+ cwdRoots: [hostRoot],
164
174
  lanes: ["general", "utility", "control"],
165
175
  credentialIds: []
166
176
  }
@@ -185,22 +195,27 @@ exec ${quote(executable)} ${quote(cli)} "$@"
185
195
  }
186
196
  await renew(grant);
187
197
  const authority = await WorkspaceAuthority.open({
188
- config: { installationId: grant.installationId, root: workspaceRoot, dynamicWorkbenches: true, workbenches: [] },
198
+ config: { installationId: grant.installationId, root: authorityRoot, workspaceRoot, dynamicWorkbenches: true, workbenches: [] },
189
199
  executor: async () => ({ client, workerFence: current.workerFence }),
190
- resolveWorkbench: async (identity) => identity.userId === grant.userId ? manifests.get(identity.sessionId) ?? null : null,
200
+ resolveWorkbench: async (identity) => identity.userId === grant.userId ? manifests.get(routes.get(identity.sessionId) ?? "") ?? null : null,
191
201
  storage: async (identity) => new WorkspaceStorageClient(grant.installationId, options.storage(identity.sessionId), async () => current.workerFence)
192
202
  });
193
203
  async function approve(input) {
204
+ const cwd = input.rootProfile === "account" ? workspaceRoot : input.namespace && input.projectName ? path.join(workspaceRoot, "projects", input.namespace, input.projectName, ...input.branch.split("/")) : path.join(workspaceRoot, "workbenches", input.id);
194
205
  const row = ApprovedWorkbench.parse({
195
206
  ...input,
196
207
  userId: grant.userId,
197
- cwd: path.join(workspaceRoot, "workbenches", input.id),
208
+ cwd,
198
209
  nonmutatingArgv: []
199
210
  });
200
- const previous = manifests.get(input.sessionId);
201
- if (previous && canonicalJson(previous) !== canonicalJson(row)) throw new Error("Personal workbench identity changed");
211
+ const previous = manifests.get(input.id);
212
+ const stable = (value) => {
213
+ const { sessionId: _sessionId, sharedSessionId: _sharedSessionId, ...binding } = value;
214
+ return binding;
215
+ };
216
+ if (previous && canonicalJson(stable(previous)) !== canonicalJson(stable(row))) throw new Error("Personal workbench identity changed");
202
217
  if (!previous) {
203
- manifests.set(input.sessionId, row);
218
+ manifests.set(input.id, row);
204
219
  const tmp = `${manifestFile}.${randomBytes(8).toString("hex")}.next`;
205
220
  const handle = await fs.open(tmp, "wx", 384);
206
221
  try {
@@ -211,6 +226,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
211
226
  }
212
227
  await fs.rename(tmp, manifestFile);
213
228
  }
229
+ routes.set(input.sessionId, input.id);
214
230
  }
215
231
  async function installCredentials(values) {
216
232
  for (const source of values) {
@@ -234,7 +250,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
234
250
  const tcp = new PersonalTcpManager(path.join(root, "tcp"));
235
251
  async function dispatch(raw) {
236
252
  const request = PersonalWorkspaceRequest.parse(raw);
237
- if (request.userId !== grant.userId || request.workbench.sessionId !== request.sessionId)
253
+ if (request.userId !== grant.userId)
238
254
  throw new Error("Wrong personal worker principal");
239
255
  if (request.kind === "tcp") return tcp.dispatch(request.sessionId, request.command);
240
256
  for (const parent of request.sharedWorkbenches ?? []) await approve(parent);
@@ -268,7 +284,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
268
284
  fence: current.workerFence,
269
285
  payload: {
270
286
  argv,
271
- cwd: manifests.get(identity.sessionId).cwd,
287
+ cwd: manifests.get(routes.get(identity.sessionId)).cwd,
272
288
  ...command.method === "open" ? { interactive: true, pty: { cols: command.cols, rows: command.rows } } : {},
273
289
  credentials: (request.credentials ?? []).map(({ id, generation }) => ({ id, generation }))
274
290
  }
@@ -321,7 +337,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
321
337
  fence: current.workerFence,
322
338
  payload: {
323
339
  ...operation.payload,
324
- cwd: manifests.get(identity.sessionId).cwd,
340
+ cwd: manifests.get(routes.get(identity.sessionId)).cwd,
325
341
  credentials: (request.credentials ?? []).map(({ id, generation }) => ({ id, generation }))
326
342
  }
327
343
  });
@@ -19,19 +19,19 @@ const SessionArtifactChunk = z.object({
19
19
  const RESERVED_ARTIFACT_ENV = ["R5D_ROOT", "R5D_SESSION_ID"];
20
20
  const sha = (value) => createHash("sha256").update(value).digest("hex");
21
21
  class SessionArtifactStore {
22
- constructor(authorityRoot) {
23
- this.authorityRoot = authorityRoot;
22
+ constructor(workspaceRoot) {
23
+ this.workspaceRoot = workspaceRoot;
24
24
  }
25
- authorityRoot;
25
+ workspaceRoot;
26
26
  async environment(identity) {
27
27
  const scope = { userId: RuntimeId.parse(identity.userId), sessionId: RuntimeId.parse(identity.sessionId) };
28
- const base = path.join(this.authorityRoot, "session-artifacts"), root = path.join(base, sha(canonicalJson(scope)));
28
+ const root = this.workspaceRoot, incoming = path.join(root, ".incoming", sha(canonicalJson(scope)));
29
29
  for (const directory of [
30
- base,
31
30
  root,
32
31
  path.join(root, "artifacts"),
33
32
  path.join(root, "artifacts", identity.sessionId),
34
- path.join(root, ".incoming")
33
+ path.join(root, ".incoming"),
34
+ incoming
35
35
  ]) {
36
36
  await fs.mkdir(directory, { mode: 448 }).catch((error) => {
37
37
  if (error.code !== "EEXIST") throw error;
@@ -44,7 +44,7 @@ class SessionArtifactStore {
44
44
  const chunk = SessionArtifactChunk.parse(input), bytes = Buffer.from(chunk.base64, "base64");
45
45
  if (bytes.length > 64 * 1024 || bytes.toString("base64") !== chunk.base64 || chunk.offset + bytes.length > chunk.size)
46
46
  throw new WorkspaceError("invalid_artifact_chunk", "Invalid bounded artifact bytes");
47
- const env = await this.environment(identity), destination = path.join(env.R5D_ROOT, "artifacts", identity.sessionId, chunk.filename), incoming = path.join(env.R5D_ROOT, ".incoming", sha(chunk.filename));
47
+ const env = await this.environment(identity), destination = path.join(env.R5D_ROOT, "artifacts", identity.sessionId, chunk.filename), incoming = path.join(env.R5D_ROOT, ".incoming", sha(canonicalJson({ userId: identity.userId, sessionId: identity.sessionId })), sha(chunk.filename));
48
48
  const binding = { filename: chunk.filename, sha256: chunk.sha256, size: chunk.size }, manifest = `${incoming}.json`, temporary = `${incoming}.part`;
49
49
  const old = await readRegular(manifest, 1024).catch((error) => {
50
50
  if (error.code === "ENOENT") return null;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import path from "node:path";
4
+ import os from "node:os";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
  import { canonicalJson, OperationEnvelope, operationPayloadHash } from "@ricsam/r5d-api/runtime-protocol";
6
7
  import { STORAGE_LIMITS, GitOid, BranchName, safeTreePath } from "./storage-wire.mjs";
@@ -21,6 +22,7 @@ import {
21
22
  git,
22
23
  noSymlinkAncestors,
23
24
  privateRoot,
25
+ readHostRegular,
24
26
  readRegular,
25
27
  selectedTree,
26
28
  sha256,
@@ -42,7 +44,61 @@ class WorkspaceAuthority {
42
44
  closed = false;
43
45
  closeTask;
44
46
  actions = /* @__PURE__ */ new Set();
47
+ repositoryInitializations = /* @__PURE__ */ new Map();
45
48
  pending = 0;
49
+ expectedCwd(config) {
50
+ const workspaceRoot = this.config.workspaceRoot ?? this.config.root;
51
+ if (config.rootProfile === "account") return workspaceRoot;
52
+ if (config.namespace && config.projectName)
53
+ return path.join(workspaceRoot, "projects", config.namespace, config.projectName, ...config.branch.split("/"));
54
+ return path.join(workspaceRoot, "workbenches", config.id);
55
+ }
56
+ stableBinding(config) {
57
+ const { sessionId: _sessionId, sharedSessionId: _sharedSessionId, ...binding } = config;
58
+ return binding;
59
+ }
60
+ repositoryPath(config, directory) {
61
+ return config.namespace && config.projectName ? path.join(this.config.root, "repositories", config.repositoryId) : path.join(directory, "repo");
62
+ }
63
+ async ensureRepository(config, directory) {
64
+ const repo = this.repositoryPath(config, directory);
65
+ let task = this.repositoryInitializations.get(repo);
66
+ if (!task) {
67
+ task = (async () => {
68
+ const exists = await fs.lstat(repo).then(() => true, (error) => {
69
+ if (error.code === "ENOENT") return false;
70
+ throw error;
71
+ });
72
+ if (!exists) {
73
+ await fs.mkdir(repo, { recursive: true, mode: 448 });
74
+ await git(repo, ["init", "--bare", "--template=", "."]);
75
+ }
76
+ await ensureAuthorityGitRepositoryLayout(repo);
77
+ return repo;
78
+ })();
79
+ this.repositoryInitializations.set(repo, task);
80
+ void task.finally(() => this.repositoryInitializations.delete(repo)).catch(() => {
81
+ });
82
+ }
83
+ return task;
84
+ }
85
+ assertNonOverlappingCheckout(config) {
86
+ if (config.rootProfile !== "project") return;
87
+ for (const bench of this.benches.values()) {
88
+ if (bench.config.rootProfile !== "project" || bench.config.id === config.id || bench.state.blocked?.code === "archived") continue;
89
+ const relative = path.relative(bench.config.cwd, config.cwd), reverse = path.relative(config.cwd, bench.config.cwd);
90
+ if (!relative.startsWith("..") && !path.isAbsolute(relative) || !reverse.startsWith("..") && !path.isAbsolute(reverse))
91
+ throw new WorkspaceError("overlapping_branch_path", "Branch worktree paths may not contain one another");
92
+ }
93
+ }
94
+ linkedWorkbench(b) {
95
+ return Boolean(b.config.namespace && b.config.projectName);
96
+ }
97
+ resolveHostPath(value) {
98
+ const expanded = value === "~" ? os.homedir() : value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
99
+ if (!path.isAbsolute(expanded) || expanded.includes("\0")) throw new WorkspaceError("unsafe_path", "Host path must be absolute or home-relative");
100
+ return path.normalize(expanded);
101
+ }
46
102
  static async open(options) {
47
103
  const authority = new WorkspaceAuthority(options);
48
104
  const { config } = authority;
@@ -52,10 +108,8 @@ class WorkspaceAuthority {
52
108
  if (Boolean(config.dynamicWorkbenches) !== Boolean(options.resolveWorkbench)) throw new WorkspaceError("invalid_config", "Dynamic workspace requires catalog authorization");
53
109
  if (new Set(config.workbenches.map((b) => b.id)).size !== config.workbenches.length || new Set(config.workbenches.map((b) => `${b.userId}:${b.sessionId}`)).size !== config.workbenches.length)
54
110
  throw new WorkspaceError("invalid_config", "Duplicate workbench or session route");
55
- for (const b of config.workbenches) {
56
- if (b.cwd !== path.join(config.root, "workbenches", b.id))
57
- throw new WorkspaceError("unsafe_root", "Approved cwd must equal root/workbenches/id; arbitrary and legacy roots are forbidden");
58
- }
111
+ for (const b of config.workbenches)
112
+ if (b.cwd !== authority.expectedCwd(b)) throw new WorkspaceError("unsafe_root", "Approved cwd does not match its managed worktree path");
59
113
  if (contents.length) {
60
114
  const existing = JSON.parse((await readRegular(path.join(config.root, "installation.json"), 128 * 1024)).toString());
61
115
  if (canonicalJson(existing) !== canonicalJson(binding))
@@ -72,13 +126,13 @@ class WorkspaceAuthority {
72
126
  await durableJson(path.join(config.root, "installation.json"), binding);
73
127
  await fs.mkdir(path.join(config.root, "workbenches"), { mode: 448 });
74
128
  await fs.mkdir(path.join(config.root, "state"), { mode: 448 });
129
+ await fs.mkdir(path.join(config.root, "repositories"), { mode: 448 });
75
130
  for (const b of config.workbenches) {
76
- await fs.mkdir(b.cwd, { mode: 448 });
131
+ await fs.mkdir(b.cwd, { recursive: true, mode: 448 });
77
132
  const directory = path.join(config.root, "state", b.id);
78
133
  await fs.mkdir(directory, { mode: 448 });
79
- await fs.mkdir(path.join(directory, "repo"), { mode: 448 });
80
- await git(path.join(directory, "repo"), ["init", "--bare", "--template=", "."]);
81
- await durableJson(path.join(directory, "state.json"), { initialized: false, head: null, blocked: null, runs: {} });
134
+ await authority.ensureRepository(b, directory);
135
+ await durableJson(path.join(directory, "state.json"), { initialized: b.rootProfile === "account", head: null, blocked: null, runs: {} });
82
136
  }
83
137
  }
84
138
  for (const b of config.workbenches) {
@@ -87,8 +141,7 @@ class WorkspaceAuthority {
87
141
  const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
88
142
  if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success))
89
143
  throw new WorkspaceError("invalid_state", "Invalid durable workspace state; maintenance required");
90
- const repo = path.join(directory, "repo");
91
- await ensureAuthorityGitRepositoryLayout(repo);
144
+ const repo = await authority.ensureRepository(b, directory);
92
145
  authority.benches.set(b.id, { config: b, state, directory, repo });
93
146
  }
94
147
  if (config.dynamicWorkbenches) {
@@ -97,12 +150,11 @@ class WorkspaceAuthority {
97
150
  const directory = path.join(config.root, "state", id);
98
151
  await noSymlinkAncestors(directory);
99
152
  const b = ApprovedWorkbench.parse(JSON.parse((await readRegular(path.join(directory, "workbench.json"), 128 * 1024)).toString()));
100
- if (b.id !== id || b.cwd !== path.join(config.root, "workbenches", id)) throw new WorkspaceError("wrong_binding", "Invalid durable dynamic workbench");
153
+ if (b.id !== id || b.cwd !== authority.expectedCwd(b)) throw new WorkspaceError("wrong_binding", "Invalid durable dynamic workbench");
101
154
  await noSymlinkAncestors(b.cwd);
102
155
  const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
103
156
  if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success)) throw new WorkspaceError("invalid_state", "Invalid dynamic workspace state");
104
- const repo = path.join(directory, "repo");
105
- await ensureAuthorityGitRepositoryLayout(repo);
157
+ const repo = await authority.ensureRepository(b, directory);
106
158
  authority.benches.set(id, { config: b, state, directory, repo });
107
159
  }
108
160
  }
@@ -115,7 +167,7 @@ class WorkspaceAuthority {
115
167
  const approved = fixed ?? await this.options.resolveWorkbench?.(identity);
116
168
  if (!approved) throw new WorkspaceError("forbidden", "No approved workbench for authenticated user/session");
117
169
  const config = ApprovedWorkbench.parse(approved);
118
- if (config.userId !== identity.userId || config.sessionId !== identity.sessionId || config.cwd !== path.join(this.config.root, "workbenches", config.id))
170
+ if (config.userId !== identity.userId || config.cwd !== this.expectedCwd(config))
119
171
  throw new WorkspaceError("unsafe_root", "Catalog returned an invalid workspace identity");
120
172
  if (config.sharedSessionId) {
121
173
  const parent = await this.bench({ userId: identity.userId, sessionId: config.sharedSessionId }, /* @__PURE__ */ new Set([...visited, identity.sessionId]));
@@ -124,9 +176,11 @@ class WorkspaceAuthority {
124
176
  }
125
177
  const existing = this.benches.get(config.id);
126
178
  if (existing) {
127
- if (canonicalJson(existing.config) !== canonicalJson(config)) throw new WorkspaceError("wrong_binding", "Workbench identity is immutable");
179
+ if (canonicalJson(this.stableBinding(existing.config)) !== canonicalJson(this.stableBinding(config)))
180
+ throw new WorkspaceError("wrong_binding", "Workbench identity is immutable");
128
181
  return existing;
129
182
  }
183
+ this.assertNonOverlappingCheckout(config);
130
184
  let task = this.registrations.get(config.id);
131
185
  if (!task) {
132
186
  task = (async () => {
@@ -138,20 +192,20 @@ class WorkspaceAuthority {
138
192
  throw error;
139
193
  });
140
194
  if (stored) {
141
- if (canonicalJson(JSON.parse(stored)) !== canonicalJson(config)) throw new WorkspaceError("wrong_binding", "Persisted workbench identity changed");
195
+ const persisted = ApprovedWorkbench.parse(JSON.parse(stored));
196
+ if (canonicalJson(this.stableBinding(persisted)) !== canonicalJson(this.stableBinding(config)))
197
+ throw new WorkspaceError("wrong_binding", "Persisted workbench identity changed");
142
198
  } else {
143
199
  await fs.mkdir(directory, { mode: 448 });
144
200
  await durableJson(manifest, config);
145
- await fs.mkdir(config.cwd, { mode: 448 });
146
- await fs.mkdir(path.join(directory, "repo"), { mode: 448 });
147
- await git(path.join(directory, "repo"), ["init", "--bare", "--template=", "."]);
148
- await durableJson(path.join(directory, "state.json"), { initialized: false, head: null, blocked: null, runs: {} });
201
+ await fs.mkdir(config.cwd, { recursive: true, mode: 448 });
202
+ await this.ensureRepository(config, directory);
203
+ await durableJson(path.join(directory, "state.json"), { initialized: config.rootProfile === "account", head: null, blocked: null, runs: {} });
149
204
  }
150
205
  await noSymlinkAncestors(config.cwd);
151
206
  const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
152
207
  if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success)) throw new WorkspaceError("invalid_state", "Invalid workspace state");
153
- const repo = path.join(directory, "repo");
154
- await ensureAuthorityGitRepositoryLayout(repo);
208
+ const repo = await this.ensureRepository(config, directory);
155
209
  const bench = { config, state, directory, repo };
156
210
  this.benches.set(config.id, bench);
157
211
  return bench;
@@ -165,6 +219,7 @@ class WorkspaceAuthority {
165
219
  hydrations = /* @__PURE__ */ new Map();
166
220
  async ensureHydrated(identity) {
167
221
  const b = await this.bench(identity);
222
+ if (b.config.rootProfile === "account") return;
168
223
  if (b.state.initialized) return;
169
224
  let task = this.hydrations.get(b.config.id);
170
225
  if (!task) {
@@ -186,7 +241,7 @@ class WorkspaceAuthority {
186
241
  return { path: file, content: bytes.toString("utf8"), bytes: bytes.length };
187
242
  }
188
243
  async processes(identity) {
189
- const b = await this.bench(identity), route = await this.route(b);
244
+ const b = await this.bench(identity), route = await this.route(b, identity);
190
245
  const result = [];
191
246
  for (const [operationId, metadata] of Object.entries(b.state.runs)) {
192
247
  if (metadata.state === "rejected_capacity" || (metadata.sessionId ?? b.config.sessionId) !== identity.sessionId) continue;
@@ -214,18 +269,22 @@ class WorkspaceAuthority {
214
269
  /** Bounded file browsing through the same catalog-authorized workbench as tools. */
215
270
  async inspectFiles(identity, command) {
216
271
  const b = await this.bench(identity);
217
- const relative = command.path.replace(/^\/+/, "").replace(/\/+$/, "");
218
- if (relative) safeTreePath(relative);
219
- if (!relative && command.method === "raw") throw new WorkspaceError("unsafe_path", "A file path is required");
220
- const target = path.join(b.config.cwd, relative);
221
- await noSymlinkAncestors(target).catch((error) => {
272
+ const relative = command.hostPath ? "" : command.path.replace(/^\/+/, "").replace(/\/+$/, "");
273
+ if (!command.hostPath && relative) safeTreePath(relative);
274
+ if (!command.hostPath && !relative && command.method === "raw") throw new WorkspaceError("unsafe_path", "A file path is required");
275
+ const target = command.hostPath ? this.resolveHostPath(command.path) : path.join(b.config.cwd, relative);
276
+ const display = command.hostPath ? target : `/${relative}`;
277
+ const inspect = command.hostPath ? fs.lstat(target).then((stat2) => {
278
+ if (stat2.isSymbolicLink()) throw new WorkspaceError("unsafe_path", "Final symlink paths are not inspected");
279
+ }) : noSymlinkAncestors(target);
280
+ await inspect.catch((error) => {
222
281
  if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new WorkspaceError("not_found", "File or directory not found");
223
282
  throw error;
224
283
  });
225
284
  const stat = await fs.stat(target);
226
285
  if (command.method === "raw") {
227
- const bytes = await readRegular(target, 8 * 1024 * 1024);
228
- return { path: `/${relative}`, base64: bytes.toString("base64"), size: bytes.length, modifiedAt: stat.mtime.toISOString() };
286
+ const bytes = await (command.hostPath ? readHostRegular : readRegular)(target, 8 * 1024 * 1024);
287
+ return { path: display, base64: bytes.toString("base64"), size: bytes.length, modifiedAt: stat.mtime.toISOString() };
229
288
  }
230
289
  if (!stat.isDirectory()) throw new WorkspaceError("not_found", "Directory not found");
231
290
  const names = await fs.readdir(target, { withFileTypes: true });
@@ -234,13 +293,13 @@ class WorkspaceAuthority {
234
293
  const nested = await fs.stat(path.join(target, entry.name));
235
294
  return {
236
295
  name: entry.name,
237
- path: `/${path.posix.join(relative, entry.name)}`,
296
+ path: command.hostPath ? path.join(target, entry.name) : `/${path.posix.join(relative, entry.name)}`,
238
297
  type: entry.isDirectory() ? "directory" : "file",
239
298
  size: entry.isDirectory() ? null : nested.size,
240
299
  modifiedAt: nested.mtime.toISOString()
241
300
  };
242
301
  }));
243
- return { directory: `/${relative}`, entries };
302
+ return { directory: display, entries };
244
303
  }
245
304
  async inspectGit(identity, command) {
246
305
  const b = await this.bench(identity);
@@ -299,15 +358,16 @@ class WorkspaceAuthority {
299
358
  if (this.closing && !admitted) return Promise.reject(new WorkspaceError("authority_closed", "Workspace authority is closing"));
300
359
  if (this.pending >= 32) return Promise.reject(new WorkspaceError("busy", "Workspace request budget reached"));
301
360
  this.pending++;
302
- const task = (this.queues.get(b.config.id) ?? Promise.resolve()).catch(() => {
361
+ const queueKey = this.linkedWorkbench(b) ? b.repo : b.config.id;
362
+ const task = (this.queues.get(queueKey) ?? Promise.resolve()).catch(() => {
303
363
  }).then(async () => {
304
364
  await this.owned(admitted);
305
365
  return fn();
306
366
  });
307
- this.queues.set(b.config.id, task);
367
+ this.queues.set(queueKey, task);
308
368
  void task.finally(() => {
309
369
  this.pending--;
310
- if (this.queues.get(b.config.id) === task) this.queues.delete(b.config.id);
370
+ if (this.queues.get(queueKey) === task) this.queues.delete(queueKey);
311
371
  }).catch(() => {
312
372
  });
313
373
  return task;
@@ -318,8 +378,8 @@ class WorkspaceAuthority {
318
378
  throw new WorkspaceError("wrong_installation", "Executor route is for another installation");
319
379
  return route;
320
380
  }
321
- async storage(b) {
322
- const storage = await this.options.storage(b.config);
381
+ async storage(b, identity) {
382
+ const storage = await this.options.storage(identity);
323
383
  if (storage.installationId !== this.config.installationId)
324
384
  throw new WorkspaceError("wrong_installation", "Storage client is for another installation");
325
385
  return storage;
@@ -365,7 +425,7 @@ class WorkspaceAuthority {
365
425
  } catch {
366
426
  }
367
427
  }
368
- return { workbench: b.config, ...structuredClone(b.state), files };
428
+ return { workbench: { ...b.config, sessionId: identity.sessionId }, ...structuredClone(b.state), files };
369
429
  });
370
430
  }
371
431
  async cleanRefreshBase(b, expectedBase) {
@@ -374,6 +434,12 @@ class WorkspaceAuthority {
374
434
  const expectedTree = (await git(b.repo, ["rev-parse", `${expectedBase}^{tree}`])).toString().trim();
375
435
  if (await snapshotTree(b.repo, b.config.cwd, expectedBase) !== expectedTree)
376
436
  throw new WorkspaceError("dirty_workbench", "Source differs from its canonical base; preserve and publish or reconcile edits first");
437
+ if (this.linkedWorkbench(b)) {
438
+ const localHead2 = (await git(b.config.cwd, ["rev-parse", "HEAD"])).toString().trim();
439
+ if (localHead2 !== expectedBase || (await git(b.config.cwd, ["write-tree"])).toString().trim() !== expectedTree)
440
+ throw new WorkspaceError("dirty_workbench", "Local branch or index changed; preserve and reconcile it before refreshing");
441
+ return;
442
+ }
377
443
  const metadata = path.join(b.config.cwd, ".git");
378
444
  const ref = `refs/heads/${b.config.branch}`;
379
445
  const indexBytes = await readRegular(path.join(metadata, "index"), 8 * 1024 * 1024);
@@ -392,15 +458,16 @@ class WorkspaceAuthority {
392
458
  * idle workbench refresh; all replaced source/index/ref bytes are retained. */
393
459
  async hydrate(identity, expectedBase) {
394
460
  const b = await this.bench(identity);
395
- return this.serial(b, () => this.hydrateIdle(b, expectedBase));
461
+ if (b.config.rootProfile === "account") return { head: "", unchanged: true };
462
+ return this.serial(b, () => this.hydrateIdle(b, identity, expectedBase));
396
463
  }
397
- async hydrateIdle(b, expectedBase) {
464
+ async hydrateIdle(b, identity, expectedBase) {
398
465
  if (b.state.blocked?.code === "account_initialization_unknown") {
399
466
  await this.idle(b, true);
400
467
  await this.assertFreshAccountWorkbench(b, expectedBase);
401
468
  const operationId = `account-initialize-${sha256(b.config.userId)}`;
402
469
  if (b.state.blocked.operationId !== operationId) throw new WorkspaceError("wrong_binding", "Account initialization receipt identity changed");
403
- const storage2 = await this.storage(b);
470
+ const storage2 = await this.storage(b, identity);
404
471
  const receipt = await storage2.read({ method: "operation.get", lookupId: operationId });
405
472
  if (receipt.state !== "completed" || receipt.result?.repositoryId !== b.config.repositoryId || !GitOid.safeParse(receipt.result?.head).success)
406
473
  throw new WorkspaceError("account_initialization_unknown", "Original account initialization is not proven complete; preserve its receipt");
@@ -416,7 +483,7 @@ class WorkspaceAuthority {
416
483
  "nonempty_workbench",
417
484
  "Hydration requires initially empty approved workbench; preserve/import existing work explicitly"
418
485
  );
419
- const storage = await this.storage(b);
486
+ const storage = await this.storage(b, identity);
420
487
  const readHead = () => storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
421
488
  let canonical;
422
489
  const account = b.config.repositoryId === `user-workspace-${sha256(b.config.userId).slice(0, 40)}` && b.config.branch === "main";
@@ -492,8 +559,14 @@ class WorkspaceAuthority {
492
559
  if (expectedBase === void 0) {
493
560
  if ((await fs.readdir(b.config.cwd)).length)
494
561
  throw new WorkspaceError("nonempty_workbench", "Workbench changed during hydration; no overwrite");
495
- for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
562
+ if (this.linkedWorkbench(b)) await fs.rm(staged, { recursive: true });
563
+ else for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
496
564
  await this.installGitPolicy(b, head);
565
+ } else if (this.linkedWorkbench(b)) {
566
+ await this.cleanRefreshBase(b, expectedBase);
567
+ await git(b.repo, ["update-ref", `refs/heads/${b.config.branch}`, head, expectedBase]);
568
+ await git(b.config.cwd, ["reset", "--hard", head]);
569
+ await fs.rm(staged, { recursive: true });
497
570
  } else {
498
571
  await this.cleanRefreshBase(b, expectedBase);
499
572
  const original = await selectedTree(b.repo, expectedBase);
@@ -664,7 +737,7 @@ class WorkspaceAuthority {
664
737
  sourceBytes(Buffer.from(readme));
665
738
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
666
739
  throw new WorkspaceError("nonempty_workbench", "Seed only an initially empty approved workbench");
667
- const storage = await this.storage(b);
740
+ const storage = await this.storage(b, identity);
668
741
  const operationId = `ws-${randomUUID()}`;
669
742
  b.state.blocked = {
670
743
  code: "seed_unknown",
@@ -684,13 +757,34 @@ class WorkspaceAuthority {
684
757
  b.state.initialized = true;
685
758
  b.state.blocked = null;
686
759
  await this.save(b);
687
- const result = await this.publishIdle(b);
760
+ const result = await this.publishIdle(b, identity);
761
+ if (this.linkedWorkbench(b)) {
762
+ await fs.unlink(path.join(b.config.cwd, ".gitignore"));
763
+ await fs.unlink(path.join(b.config.cwd, "README.md"));
764
+ }
688
765
  await this.installGitPolicy(b, result.head);
689
766
  return result;
690
767
  });
691
768
  }
692
769
  async installGitPolicy(b, head) {
693
770
  delete b.state.publishedMetadata;
771
+ if (this.linkedWorkbench(b)) {
772
+ const destination2 = path.join(b.config.cwd, ".git");
773
+ const exists = await fs.lstat(destination2).then(() => true, (error) => {
774
+ if (error.code === "ENOENT") return false;
775
+ throw error;
776
+ });
777
+ await git(b.repo, ["update-ref", `refs/heads/${b.config.branch}`, head]);
778
+ if (!exists) {
779
+ if ((await fs.readdir(b.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Linked worktree destination must be empty");
780
+ await git(b.repo, ["worktree", "add", "--force", b.config.cwd, b.config.branch]);
781
+ }
782
+ const remote2 = `r5d-canonical://${this.config.installationId}/${b.config.userId}/${b.config.repositoryId}`;
783
+ await git(b.repo, ["config", "remote.canonical.url", remote2]);
784
+ await git(b.repo, ["config", "remote.canonical.pushurl", remote2]);
785
+ await git(b.repo, ["config", "protocol.allow", "never"]);
786
+ return;
787
+ }
694
788
  const destination = path.join(b.config.cwd, ".git");
695
789
  await git(b.repo, ["read-tree", head]);
696
790
  await fs.mkdir(destination, { mode: 448 });
@@ -742,22 +836,23 @@ class WorkspaceAuthority {
742
836
  const input = WorkspaceFileWrite.parse(raw), bytes = Buffer.from(input.base64, "base64");
743
837
  if (bytes.toString("base64") !== input.base64 || bytes.length > 768 * 1024)
744
838
  throw new WorkspaceError("invalid_input", "Expected canonical base64 for at most 768 KiB", true);
745
- const b = await this.bench(identity), target = path.join(b.config.cwd, input.path);
839
+ const b = await this.bench(identity), target = input.hostPath ? this.resolveHostPath(input.path) : path.join(b.config.cwd, input.path);
746
840
  const fingerprint = { ...input, userId: identity.userId, sessionId: identity.sessionId };
747
841
  let mode = 420;
748
842
  const check = async () => {
749
- await this.idle(b);
843
+ if (b.state.blocked) throw new WorkspaceError(b.state.blocked.code, b.state.blocked.message);
750
844
  if (!b.state.initialized) throw new WorkspaceError("uninitialized", "Hydrate this workspace before writing files");
751
- let current = b.config.cwd;
752
- for (const part of input.path.split("/").slice(0, -1)) {
845
+ let current = input.hostPath ? path.parse(target).root : b.config.cwd;
846
+ const parentParts = (input.hostPath ? target.slice(current.length) : input.path).split(path.sep).filter(Boolean).slice(0, -1);
847
+ for (const part of parentParts) {
753
848
  current = path.join(current, part);
754
- const stat = await fs.lstat(current).catch((error) => {
849
+ const stat = await (input.hostPath ? fs.stat(current) : fs.lstat(current)).catch((error) => {
755
850
  if (error.code === "ENOENT") return null;
756
851
  throw error;
757
852
  });
758
- if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new WorkspaceError("unsafe_path", "File parent is not a real directory");
853
+ if (stat && (!stat.isDirectory() || !input.hostPath && stat.isSymbolicLink())) throw new WorkspaceError("unsafe_path", "File parent is not a directory");
759
854
  }
760
- const previous = await readRegular(target, 8 * 1024 * 1024).catch((error) => {
855
+ const previous = await (input.hostPath ? readHostRegular : readRegular)(target, 8 * 1024 * 1024).catch((error) => {
761
856
  if (error.code === "ENOENT") return null;
762
857
  throw error;
763
858
  });
@@ -770,7 +865,7 @@ class WorkspaceAuthority {
770
865
  b.state.blocked = { code: "file_write_unknown", operationId: input.id, message: "Inspect the original file write receipt before changing this workbench" };
771
866
  await this.save(b);
772
867
  await fs.mkdir(path.dirname(target), { recursive: true, mode: 493 });
773
- await noSymlinkAncestors(path.dirname(target));
868
+ if (!input.hostPath) await noSymlinkAncestors(path.dirname(target));
774
869
  const temporary = path.join(path.dirname(target), `.r5d-file-${randomUUID()}`);
775
870
  const file = await fs.open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, mode);
776
871
  try {
@@ -779,7 +874,7 @@ class WorkspaceAuthority {
779
874
  } finally {
780
875
  await file.close();
781
876
  }
782
- const current = await readRegular(target, 8 * 1024 * 1024).catch((error) => {
877
+ const current = await (input.hostPath ? readHostRegular : readRegular)(target, 8 * 1024 * 1024).catch((error) => {
783
878
  if (error.code === "ENOENT") return null;
784
879
  throw error;
785
880
  });
@@ -851,7 +946,7 @@ class WorkspaceAuthority {
851
946
  return this.serial(b, () => this.productAction(b, input.id, { method: "import", url: input.url, branch: input.defaultBranch }, async () => {
852
947
  await git(b.repo, ["-c", "protocol.https.allow=always", "fetch", "--no-tags", "--no-recurse-submodules", "--", input.url, `refs/heads/${input.defaultBranch}`], void 0, void 0, { token: input.token });
853
948
  const head = GitOid.parse((await git(b.repo, ["rev-parse", "FETCH_HEAD"])).toString().trim());
854
- return this.importCommit(b, head, input.id);
949
+ return this.importCommit(b, head, input.id, identity);
855
950
  }, async () => {
856
951
  await this.idle(b);
857
952
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Import requires a new empty workbench");
@@ -893,13 +988,13 @@ class WorkspaceAuthority {
893
988
  const file = path.join(target.directory, `clone-${randomUUID()}.bundle`);
894
989
  await fs.writeFile(file, prepared.bytes, { mode: 384, flag: "wx" });
895
990
  await git(target.repo, ["bundle", "unbundle", file]);
896
- return this.importCommit(target, prepared.head, input.id);
991
+ return this.importCommit(target, prepared.head, input.id, identity);
897
992
  }, async () => {
898
993
  await this.idle(target);
899
994
  if (target.state.initialized || (await fs.readdir(target.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Branch already has a workbench");
900
995
  }));
901
996
  }
902
- async importCommit(b, head, id) {
997
+ async importCommit(b, head, id, identity) {
903
998
  const entries = await selectedTree(b.repo, head);
904
999
  const staged = path.join(b.directory, `import-${randomUUID()}`);
905
1000
  await fs.mkdir(staged, { mode: 448 });
@@ -927,7 +1022,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
927
1022
  }
928
1023
  const tree = await snapshotTree(b.repo, staged, head);
929
1024
  if (tree !== (await git(b.repo, ["rev-parse", `${head}^{tree}`])).toString().trim()) head = (await git(b.repo, ["commit-tree", tree, "-p", head], "Configure workspace exclusions\n")).toString().trim();
930
- const storage = await this.storage(b), operationId = `product-${sha256(id).slice(0, 48)}`;
1025
+ const storage = await this.storage(b, identity), operationId = `product-${sha256(id).slice(0, 48)}`;
931
1026
  let existing;
932
1027
  try {
933
1028
  existing = await storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
@@ -949,7 +1044,8 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
949
1044
  }
950
1045
  const result = await storage.mutate({ method: "repository.publish", operationId, repositoryId: b.config.repositoryId, branch: b.config.branch, expectedHead: null, commit: head, bundleId: blobId });
951
1046
  if (result.head !== head) throw new WorkspaceError("invalid_receipt", "Import receipt does not match selected commit");
952
- for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
1047
+ if (this.linkedWorkbench(b)) await fs.rm(staged, { recursive: true });
1048
+ else for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
953
1049
  await this.installGitPolicy(b, head);
954
1050
  b.state.initialized = true;
955
1051
  b.state.head = head;
@@ -961,7 +1057,11 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
961
1057
  const b = await this.bench(identity);
962
1058
  if (!input.message.trim() || input.message.length > 1e4) throw new WorkspaceError("invalid_input", "Commit message is required");
963
1059
  return this.serial(b, () => this.productAction(b, input.id, { method: "commit", expectedHead: input.expectedHead, message: input.message }, async () => {
964
- const result = await this.publishIdle(b, input.message);
1060
+ const result = await this.publishIdle(b, identity, input.message);
1061
+ if (this.linkedWorkbench(b)) {
1062
+ await git(b.config.cwd, ["reset", "--mixed", result.head]);
1063
+ return { ...result, treeHash: (await git(b.repo, ["rev-parse", `${result.head}^{tree}`])).toString().trim() };
1064
+ }
965
1065
  const metadata = path.join(b.config.cwd, ".git");
966
1066
  await noSymlinkAncestors(metadata);
967
1067
  const retained = path.join(b.directory, `commit-retained-${randomUUID()}`);
@@ -992,9 +1092,16 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
992
1092
  async archiveBranch(identity, input) {
993
1093
  const b = await this.bench(identity);
994
1094
  return this.serial(b, () => this.productAction(b, input.id, { method: "archive" }, async () => {
995
- const storage = await this.storage(b);
1095
+ const storage = await this.storage(b, identity);
996
1096
  const current = await storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
997
1097
  const result = await storage.mutate({ method: "repository.archive", operationId: `archive-${sha256(input.id).slice(0, 48)}`, repositoryId: b.config.repositoryId, branch: b.config.branch, expectedHead: current.head });
1098
+ if (this.linkedWorkbench(b)) {
1099
+ const retained = path.join(b.directory, `archive-retained-${randomUUID()}`, "source");
1100
+ await fs.mkdir(path.dirname(retained), { recursive: true, mode: 448 });
1101
+ await fs.cp(b.config.cwd, retained, { recursive: true, filter: (source) => path.basename(source) !== ".git" });
1102
+ await git(b.repo, ["worktree", "remove", "--force", b.config.cwd]);
1103
+ await git(b.repo, ["branch", "-D", b.config.branch]);
1104
+ }
998
1105
  b.state.blocked = { code: "archived", message: "This branch incarnation is archived; its workbench and source are retained" };
999
1106
  await this.save(b);
1000
1107
  return { removed: true, receipt: result };
@@ -1007,7 +1114,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1007
1114
  await this.idle(b, true);
1008
1115
  if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench base changed before reset");
1009
1116
  if (b.state.blocked?.code === "archived") throw new WorkspaceError("archived", "A deleted branch cannot be reset");
1010
- const storage = await this.storage(b);
1117
+ const storage = await this.storage(b, identity);
1011
1118
  if (b.state.blocked?.operationId) {
1012
1119
  const receipt = await storage.read({ method: "operation.get", lookupId: b.state.blocked.operationId });
1013
1120
  if (receipt.state !== "completed" || b.state.blocked.commit && receipt.result?.head !== b.state.blocked.commit) throw new WorkspaceError("publication_unknown", "Reconcile the original publication before resetting");
@@ -1020,18 +1127,23 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1020
1127
  b.state.blocked = { code: "reset_incomplete", message: "Reset source retained; complete hydration after inspection" };
1021
1128
  await this.save(b);
1022
1129
  await noSymlinkAncestors(b.config.cwd);
1023
- await fs.rename(b.config.cwd, path.join(retained, "source"));
1130
+ if (this.linkedWorkbench(b)) {
1131
+ await fs.cp(b.config.cwd, path.join(retained, "source"), { recursive: true, filter: (source) => path.basename(source) !== ".git" });
1132
+ await git(b.repo, ["worktree", "remove", "--force", b.config.cwd]);
1133
+ await git(b.repo, ["branch", "-D", b.config.branch]);
1134
+ } else await fs.rename(b.config.cwd, path.join(retained, "source"));
1024
1135
  await fs.mkdir(b.config.cwd, { mode: 448 });
1025
1136
  b.state.initialized = false;
1026
1137
  b.state.head = null;
1027
1138
  b.state.blocked = null;
1028
1139
  await this.save(b);
1029
- const result = await this.hydrateIdle(b);
1140
+ const result = await this.hydrateIdle(b, identity);
1030
1141
  return { ...result, reset: true, retained: true };
1031
1142
  }));
1032
1143
  }
1033
1144
  async publish(identity) {
1034
1145
  const b = await this.bench(identity);
1146
+ if (b.config.rootProfile === "account") return { head: "", unchanged: true };
1035
1147
  return this.serial(b, async () => {
1036
1148
  try {
1037
1149
  await this.idle(b);
@@ -1039,12 +1151,12 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1039
1151
  if (!(error instanceof WorkspaceError) || error.code !== "workbench_busy") throw error;
1040
1152
  throw new WorkspacePublicationNotAdmitted("workbench_busy", error.message);
1041
1153
  }
1042
- return this.publishIdle(b);
1154
+ return this.publishIdle(b, identity);
1043
1155
  });
1044
1156
  }
1045
- async publishIdle(b, message = "Destination workspace snapshot") {
1157
+ async publishIdle(b, identity, message = "Destination workspace snapshot") {
1046
1158
  if (!b.state.initialized) throw new WorkspaceError("not_initialized", "Hydrate or explicitly seed first");
1047
- const storage = await this.storage(b);
1159
+ const storage = await this.storage(b, identity);
1048
1160
  const latest = await storage.read({
1049
1161
  method: "repository.get",
1050
1162
  repositoryId: b.config.repositoryId,
@@ -1069,6 +1181,10 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1069
1181
  const bytes = await readRegular(bundleFile, STORAGE_LIMITS.blobBytes);
1070
1182
  const operationId = `ws-${randomUUID()}`, blobId = `ws-${randomUUID()}`;
1071
1183
  try {
1184
+ if (this.linkedWorkbench(b)) {
1185
+ delete b.state.publishedMetadata;
1186
+ throw new Error("linked_worktree_metadata_is_owned_by_git");
1187
+ }
1072
1188
  const metadata = path.join(b.config.cwd, ".git"), ref = `refs/heads/${b.config.branch}`;
1073
1189
  const localHead = (await readRegular(path.join(metadata, ref), 1024)).toString().trim();
1074
1190
  const index = await readRegular(path.join(metadata, "index"), 8 * 1024 * 1024);
@@ -1115,6 +1231,10 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1115
1231
  });
1116
1232
  if (result.head !== commit)
1117
1233
  throw new WorkspaceError("invalid_response", "Publication receipt head mismatch; inspect original operation");
1234
+ if (this.linkedWorkbench(b) && await fs.lstat(path.join(b.config.cwd, ".git")).then(() => true, (error) => {
1235
+ if (error.code === "ENOENT") return false;
1236
+ throw error;
1237
+ })) await git(b.config.cwd, ["reset", "--mixed", commit]);
1118
1238
  b.state.head = commit;
1119
1239
  b.state.blocked = null;
1120
1240
  await this.save(b);
@@ -1127,7 +1247,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1127
1247
  const blocked = b.state.blocked;
1128
1248
  if (blocked?.code !== "publication_unknown" || !blocked.operationId || !blocked.commit)
1129
1249
  throw new WorkspaceError("not_reconcilable", "Only a verified completed publication receipt can resolve automatically");
1130
- const storage = await this.storage(b);
1250
+ const storage = await this.storage(b, identity);
1131
1251
  const receipt = await storage.read({
1132
1252
  method: "operation.get",
1133
1253
  lookupId: blocked.operationId
@@ -1172,7 +1292,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1172
1292
  }
1173
1293
  async materializeArtifact(identity, input) {
1174
1294
  const b = await this.bench(identity);
1175
- return this.serial(b, () => new SessionArtifactStore(this.config.root).materialize(identity, input));
1295
+ return this.serial(b, () => new SessionArtifactStore(this.config.workspaceRoot ?? this.config.root).materialize(identity, input));
1176
1296
  }
1177
1297
  /** Default arbitrary shell is mutating. Exact trusted config approval is the sole exemption. */
1178
1298
  async start(identity, input) {
@@ -1187,7 +1307,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1187
1307
  if (RESERVED_ARTIFACT_ENV.some((name) => name in payload.env)) throw new WorkspaceError("reserved_environment", "R5D_ROOT and R5D_SESSION_ID are set by the workspace authority");
1188
1308
  const requestedEnv = payload.env, prior = b.state.runs[operation.operationId];
1189
1309
  if (!prior || prior.artifactEnvironment) {
1190
- payload.env = { ...payload.env, ...await new SessionArtifactStore(this.config.root).environment(identity) };
1310
+ payload.env = { ...payload.env, ...await new SessionArtifactStore(this.config.workspaceRoot ?? this.config.root).environment(identity) };
1191
1311
  operation.payload = payload;
1192
1312
  }
1193
1313
  if (payload.cwd !== b.config.cwd) throw new WorkspaceError("forbidden_cwd", "Shell cwd must be exact approved workbench");
@@ -1211,18 +1331,6 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1211
1331
  await this.save(b);
1212
1332
  return receipt;
1213
1333
  }
1214
- try {
1215
- await this.idle(b);
1216
- } catch (error) {
1217
- if (!(error instanceof WorkspaceError) || error.code !== "workbench_busy") throw error;
1218
- b.state.runs[operation.operationId] = { artifactEnvironment: true, sessionId: identity.sessionId, payloadHash: hash, mutating: false, state: "rejected_capacity" };
1219
- await this.save(b);
1220
- throw new ExecutorError(
1221
- "capacity_busy",
1222
- "Workspace is busy; poll the original command until terminal before choosing new work",
1223
- true
1224
- );
1225
- }
1226
1334
  const nonmutating = Object.keys(requestedEnv).length === 0 && payload.credentials.length === 0 && b.config.nonmutatingArgv.some((argv) => canonicalJson(argv) === canonicalJson(payload.argv));
1227
1335
  b.state.runs[operation.operationId] = { artifactEnvironment: true, sessionId: identity.sessionId, payloadHash: hash, mutating: !nonmutating, state: "unknown", argv: payload.argv, cwd: payload.cwd, startedAt: (/* @__PURE__ */ new Date()).toISOString(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1228
1336
  await this.save(b);
@@ -10,6 +10,9 @@ const ApprovedWorkbench = z.object({
10
10
  repositoryId: StorageId,
11
11
  branch: BranchName,
12
12
  cwd: z.string().refine(path.isAbsolute),
13
+ rootProfile: z.enum(["account", "project"]).default("project"),
14
+ namespace: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/).optional(),
15
+ projectName: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/).optional(),
13
16
  // Supervisor-owned exact argv approvals, NOT an agent-supplied read-only flag.
14
17
  // Approved servers must keep generated/cache files ignored and never write source.
15
18
  nonmutatingArgv: z.array(z.array(z.string()).min(1)).max(20).default([])
@@ -17,6 +20,7 @@ const ApprovedWorkbench = z.object({
17
20
  const WorkspaceConfig = z.object({
18
21
  installationId: RuntimeId,
19
22
  root: z.string().refine(path.isAbsolute),
23
+ workspaceRoot: z.string().refine(path.isAbsolute).optional(),
20
24
  workbenches: z.array(ApprovedWorkbench).max(100),
21
25
  dynamicWorkbenches: z.boolean().optional()
22
26
  }).strict();
@@ -1,21 +1,27 @@
1
1
  import { z } from "zod";
2
2
  import { RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
3
3
  import { safeTreePath } from "./storage-wire.mjs";
4
+ import path from "node:path";
4
5
  const WorkspaceFileWrite = z.object({
5
6
  method: z.literal("fileWrite"),
6
7
  id: RuntimeId,
7
- path: z.string().min(1).max(4096).refine((value) => {
8
- try {
9
- safeTreePath(value);
10
- return true;
11
- } catch {
12
- return false;
13
- }
14
- }),
8
+ path: z.string().min(1).max(4096),
9
+ hostPath: z.boolean().optional(),
15
10
  expectedSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
16
11
  intentHash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
17
12
  base64: z.string().max(1048576)
18
- }).strict();
13
+ }).strict().superRefine((value, ctx) => {
14
+ if (value.hostPath) {
15
+ if (!path.isAbsolute(value.path) && !value.path.startsWith("~/") || value.path.includes("\0"))
16
+ ctx.addIssue({ code: "custom", path: ["path"], message: "Host paths must be absolute or home-relative" });
17
+ return;
18
+ }
19
+ try {
20
+ safeTreePath(value.path);
21
+ } catch {
22
+ ctx.addIssue({ code: "custom", path: ["path"], message: "Workspace paths must be safe and relative" });
23
+ }
24
+ });
19
25
  const WorkspaceFileWriteLookup = z.object({ method: z.literal("fileWriteResult"), id: RuntimeId }).strict();
20
26
  export {
21
27
  WorkspaceFileWrite,
@@ -23,6 +23,12 @@ async function noSymlinkAncestors(file) {
23
23
  }
24
24
  async function readRegular(file, limit = 32 * 1024 * 1024) {
25
25
  await noSymlinkAncestors(file);
26
+ return readRegularFile(file, limit);
27
+ }
28
+ async function readHostRegular(file, limit = 32 * 1024 * 1024) {
29
+ return readRegularFile(file, limit);
30
+ }
31
+ async function readRegularFile(file, limit) {
26
32
  const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
27
33
  try {
28
34
  const st = await handle.stat();
@@ -263,9 +269,19 @@ async function snapshotTree(repo, cwd, canonicalHead) {
263
269
  }
264
270
  )) {
265
271
  await noSymlinkAncestors(metadata);
266
- if (!(await fs.lstat(metadata)).isDirectory()) throw new WorkspaceError("unsafe_git", "External worktree metadata unsupported");
272
+ const metadataStat = await fs.lstat(metadata);
273
+ let gitDirectory = metadata;
274
+ if (metadataStat.isFile()) {
275
+ const marker = (await readRegular(metadata, 4096)).toString("utf8").trim();
276
+ const match = /^gitdir: (.+)$/.exec(marker);
277
+ if (!match || !path.isAbsolute(match[1])) throw new WorkspaceError("unsafe_git", "Invalid linked worktree metadata");
278
+ gitDirectory = path.normalize(match[1]);
279
+ const worktrees = path.join(repo, "worktrees") + path.sep;
280
+ if (!gitDirectory.startsWith(worktrees)) throw new WorkspaceError("unsafe_git", "Linked worktree belongs to another repository");
281
+ await noSymlinkAncestors(gitDirectory);
282
+ } else if (!metadataStat.isDirectory()) throw new WorkspaceError("unsafe_git", "Invalid worktree metadata");
267
283
  for (const name of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "rebase-merge", "rebase-apply", "index.lock"]) {
268
- if (await fs.lstat(path.join(metadata, name)).then(
284
+ if (await fs.lstat(path.join(gitDirectory, name)).then(
269
285
  () => true,
270
286
  (e) => {
271
287
  if (e.code === "ENOENT") return false;
@@ -277,7 +293,7 @@ async function snapshotTree(repo, cwd, canonicalHead) {
277
293
  "Git merge/rebase/index operation remains in progress; preserve and resolve it before publication"
278
294
  );
279
295
  }
280
- const index = path.join(metadata, "index");
296
+ const index = path.join(gitDirectory, "index");
281
297
  if (await fs.lstat(index).then(
282
298
  () => true,
283
299
  (e) => {
@@ -326,6 +342,7 @@ export {
326
342
  git,
327
343
  noSymlinkAncestors,
328
344
  privateRoot,
345
+ readHostRegular,
329
346
  readRegular,
330
347
  selectedTree,
331
348
  sha256,
@@ -7,6 +7,8 @@ export type PersonalQueuedAction = {
7
7
  command?: {
8
8
  method?: string;
9
9
  streamId?: string;
10
+ operationId?: string;
11
+ runId?: string;
10
12
  };
11
13
  };
12
14
  };
@@ -46,21 +46,36 @@ export declare const PersonalWorkspaceRequest: z.ZodObject<{
46
46
  repositoryId: z.ZodString;
47
47
  branch: z.ZodString;
48
48
  sessionId: z.ZodString;
49
- sharedSessionId: z.ZodOptional<z.ZodString>;
49
+ rootProfile: z.ZodDefault<z.ZodEnum<{
50
+ account: "account";
51
+ project: "project";
52
+ }>>;
53
+ namespace: z.ZodOptional<z.ZodString>;
54
+ projectName: z.ZodOptional<z.ZodString>;
50
55
  }, z.core.$strict>;
51
56
  sourceWorkbench: z.ZodOptional<z.ZodObject<{
52
57
  id: z.ZodString;
53
58
  repositoryId: z.ZodString;
54
59
  branch: z.ZodString;
55
60
  sessionId: z.ZodString;
56
- sharedSessionId: z.ZodOptional<z.ZodString>;
61
+ rootProfile: z.ZodDefault<z.ZodEnum<{
62
+ account: "account";
63
+ project: "project";
64
+ }>>;
65
+ namespace: z.ZodOptional<z.ZodString>;
66
+ projectName: z.ZodOptional<z.ZodString>;
57
67
  }, z.core.$strict>>;
58
68
  sharedWorkbenches: z.ZodOptional<z.ZodArray<z.ZodObject<{
59
69
  id: z.ZodString;
60
70
  repositoryId: z.ZodString;
61
71
  branch: z.ZodString;
62
72
  sessionId: z.ZodString;
63
- sharedSessionId: z.ZodOptional<z.ZodString>;
73
+ rootProfile: z.ZodDefault<z.ZodEnum<{
74
+ account: "account";
75
+ project: "project";
76
+ }>>;
77
+ namespace: z.ZodOptional<z.ZodString>;
78
+ projectName: z.ZodOptional<z.ZodString>;
64
79
  }, z.core.$strict>>>;
65
80
  kind: z.ZodEnum<{
66
81
  inspect: "inspect";
@@ -14,8 +14,8 @@ export type SessionArtifactChunk = z.infer<typeof SessionArtifactChunk>;
14
14
  export declare const RESERVED_ARTIFACT_ENV: readonly ["R5D_ROOT", "R5D_SESSION_ID"];
15
15
  /** Session artifacts never join repository snapshots or another session's env. */
16
16
  export declare class SessionArtifactStore {
17
- readonly authorityRoot: string;
18
- constructor(authorityRoot: string);
17
+ readonly workspaceRoot: string;
18
+ constructor(workspaceRoot: string);
19
19
  environment(identity: WorkspaceIdentity): Promise<{
20
20
  R5D_ROOT: string;
21
21
  R5D_SESSION_ID: string;
@@ -24,8 +24,16 @@ export declare class WorkspaceAuthority {
24
24
  private closed;
25
25
  private closeTask?;
26
26
  private readonly actions;
27
+ private readonly repositoryInitializations;
27
28
  private pending;
28
29
  private constructor();
30
+ private expectedCwd;
31
+ private stableBinding;
32
+ private repositoryPath;
33
+ private ensureRepository;
34
+ private assertNonOverlappingCheckout;
35
+ private linkedWorkbench;
36
+ private resolveHostPath;
29
37
  static open(options: WorkspaceAuthorityOptions): Promise<WorkspaceAuthority>;
30
38
  private readonly registrations;
31
39
  private bench;
@@ -68,6 +76,7 @@ export declare class WorkspaceAuthority {
68
76
  inspectFiles(identity: WorkspaceIdentity, command: {
69
77
  method: "directory" | "raw";
70
78
  path: string;
79
+ hostPath?: boolean;
71
80
  }): Promise<{
72
81
  path: string;
73
82
  base64: string;
@@ -198,14 +207,17 @@ export declare class WorkspaceAuthority {
198
207
  completedAt?: string;
199
208
  }>;
200
209
  workbench: {
210
+ sessionId: string;
201
211
  id: string;
202
212
  userId: string;
203
- sessionId: string;
204
213
  repositoryId: string;
205
214
  branch: string;
206
215
  cwd: string;
216
+ rootProfile: "account" | "project";
207
217
  nonmutatingArgv: string[][];
208
218
  sharedSessionId?: string | undefined;
219
+ namespace?: string | undefined;
220
+ projectName?: string | undefined;
209
221
  };
210
222
  }>;
211
223
  private cleanRefreshBase;
@@ -213,6 +225,9 @@ export declare class WorkspaceAuthority {
213
225
  * idle workbench refresh; all replaced source/index/ref bytes are retained. */
214
226
  hydrate(identity: WorkspaceIdentity, expectedBase?: string): Promise<{
215
227
  head: string;
228
+ } | {
229
+ head: string;
230
+ unchanged: boolean;
216
231
  }>;
217
232
  private hydrateIdle;
218
233
  private assertFreshAccountWorkbench;
@@ -9,12 +9,19 @@ export declare const ApprovedWorkbench: z.ZodObject<{
9
9
  repositoryId: z.ZodString;
10
10
  branch: z.ZodString;
11
11
  cwd: z.ZodString;
12
+ rootProfile: z.ZodDefault<z.ZodEnum<{
13
+ account: "account";
14
+ project: "project";
15
+ }>>;
16
+ namespace: z.ZodOptional<z.ZodString>;
17
+ projectName: z.ZodOptional<z.ZodString>;
12
18
  nonmutatingArgv: z.ZodDefault<z.ZodArray<z.ZodArray<z.ZodString>>>;
13
19
  }, z.core.$strict>;
14
20
  export type ApprovedWorkbench = z.input<typeof ApprovedWorkbench>;
15
21
  export declare const WorkspaceConfig: z.ZodObject<{
16
22
  installationId: z.ZodString;
17
23
  root: z.ZodString;
24
+ workspaceRoot: z.ZodOptional<z.ZodString>;
18
25
  workbenches: z.ZodArray<z.ZodObject<{
19
26
  id: z.ZodString;
20
27
  userId: z.ZodString;
@@ -23,6 +30,12 @@ export declare const WorkspaceConfig: z.ZodObject<{
23
30
  repositoryId: z.ZodString;
24
31
  branch: z.ZodString;
25
32
  cwd: z.ZodString;
33
+ rootProfile: z.ZodDefault<z.ZodEnum<{
34
+ account: "account";
35
+ project: "project";
36
+ }>>;
37
+ namespace: z.ZodOptional<z.ZodString>;
38
+ projectName: z.ZodOptional<z.ZodString>;
26
39
  nonmutatingArgv: z.ZodDefault<z.ZodArray<z.ZodArray<z.ZodString>>>;
27
40
  }, z.core.$strict>>;
28
41
  dynamicWorkbenches: z.ZodOptional<z.ZodBoolean>;
@@ -3,6 +3,7 @@ export declare const WorkspaceFileWrite: z.ZodObject<{
3
3
  method: z.ZodLiteral<"fileWrite">;
4
4
  id: z.ZodString;
5
5
  path: z.ZodString;
6
+ hostPath: z.ZodOptional<z.ZodBoolean>;
6
7
  expectedSha256: z.ZodNullable<z.ZodString>;
7
8
  intentHash: z.ZodOptional<z.ZodString>;
8
9
  base64: z.ZodString;
@@ -2,6 +2,9 @@ export declare const sha256: (bytes: Buffer | string) => string;
2
2
  export declare function privateRoot(root: string, installationId: string): Promise<void>;
3
3
  export declare function noSymlinkAncestors(file: string): Promise<void>;
4
4
  export declare function readRegular(file: string, limit?: number): Promise<Buffer>;
5
+ /** Host-wide tools follow ordinary directory symlinks while refusing a final
6
+ * symlink and retaining the same bounded, single-link regular-file contract. */
7
+ export declare function readHostRegular(file: string, limit?: number): Promise<Buffer>;
5
8
  /** File-only migration capsules can omit Git's empty structural directories.
6
9
  * Restore only that directory skeleton after confirming the authority-owned
7
10
  * repository markers; refs, objects, indexes and configuration are untouched. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.141",
3
+ "version": "0.0.143",
4
4
  "type": "module",
5
5
  "main": "./dist/mjs/main.mjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -21,8 +21,8 @@
21
21
  "r5d-worker": "dist/mjs/main.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@ricsam/r5d-api": "^0.0.141",
25
- "@ricsam/r5dctl": "0.0.141",
24
+ "@ricsam/r5d-api": "^0.0.143",
25
+ "@ricsam/r5dctl": "0.0.143",
26
26
  "node-pty": "1.1.0",
27
27
  "zod": "^4.1.13",
28
28
  "picomatch": "^4.0.3"