pi-microsandbox 0.1.1 → 0.3.0

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,332 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { isAbsolute } from "node:path";
3
-
4
- import {
5
- LABEL_KEYS,
6
- STATE_SCHEMA_VERSION,
7
- volumeNameFor,
8
- } from "./types.ts";
9
- import type {
10
- Config,
11
- GitRepoInfo,
12
- GitSeedBundle,
13
- GitVolumePlan,
14
- PersistedSandboxState,
15
- SandboxTransport,
16
- StoragePlan,
17
- VolumeRecord,
18
- } from "./types.ts";
19
-
20
- const BUNDLE_SEED_REF = "refs/pi-msb/seed";
21
-
22
- /**
23
- * Build the path-preserving storage description used by the sandbox builder.
24
- * The guest path is deliberately the same absolute path as the host path; in
25
- * in particular, a git volume is mounted at the repository root rather than at
26
- * a synthetic guest-only source path.
27
- */
28
- export function buildStoragePlan(input: {
29
- cwd: string;
30
- sessionId: string;
31
- config: Config;
32
- git: GitRepoInfo;
33
- restored?: PersistedSandboxState | null;
34
- }): StoragePlan {
35
- assertAbsolute(input.cwd, "cwd");
36
-
37
- const selectedMode = selectMode(input.config.mode);
38
- if (selectedMode === "direct") {
39
- return {
40
- kind: "direct-mount",
41
- hostPath: input.git.hostCwd ?? input.cwd,
42
- guestPath: input.cwd,
43
- workdir: input.cwd,
44
- };
45
- }
46
-
47
- if (selectedMode === "none") {
48
- return {
49
- kind: "none",
50
- guestPath: input.cwd,
51
- workdir: input.cwd,
52
- };
53
- }
54
-
55
- if (!input.git.isGitRepo || !input.git.repoRoot) {
56
- throw new Error("git storage requires a Git repository");
57
- }
58
- assertAbsolute(input.git.repoRoot, "git repository root");
59
- if (input.git.guestRepoRoot === null) {
60
- throw new Error("git storage cannot preserve the requested cwd namespace for this symlink topology");
61
- }
62
- const mountGuestPath = input.git.guestRepoRoot ?? input.git.repoRoot;
63
- assertAbsolute(mountGuestPath, "git guest repository root");
64
-
65
- const volumeName = volumeNameFor(input.sessionId);
66
- const restoredForThisPlan = isRestoredGitState(input.restored, {
67
- sessionId: input.sessionId,
68
- cwd: input.cwd,
69
- volumeName,
70
- });
71
-
72
- return {
73
- kind: "git-volume",
74
- sessionId: input.sessionId,
75
- volumeName,
76
- volumeQuotaMiB: input.config.volumeQuotaMiB,
77
- repoRoot: input.git.repoRoot,
78
- mountGuestPath,
79
- workdir: input.cwd,
80
- branch: input.git.branch,
81
- headSha: input.git.headSha,
82
- unborn: input.git.unborn,
83
- depth: input.config.cloneDepth,
84
- // A matching persisted entry means the manager is recovering a retained
85
- // volume. It must still validate the volume's labels before mounting it.
86
- seedRequired: !restoredForThisPlan,
87
- };
88
- }
89
-
90
- /**
91
- * Validate the identity labels before a named volume is mounted. A name alone
92
- * is not an ownership proof: names can collide after a copied/forked state
93
- * file or an operator-created resource.
94
- */
95
- export function validateReusableVolume(plan: GitVolumePlan, volume: VolumeRecord): boolean {
96
- if (plan.kind !== "git-volume" || volume.name !== plan.volumeName) return false;
97
-
98
- const labels = volume.labels;
99
- if (!labels || typeof labels !== "object") return false;
100
- if (labels[LABEL_KEYS.managed] !== "true") return false;
101
- if (labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION)) return false;
102
- if (labels[LABEL_KEYS.session] !== plan.sessionId) return false;
103
- if (labels[LABEL_KEYS.cwd] !== plan.workdir) return false;
104
- if (labels[LABEL_KEYS.mode] !== "git") return false;
105
- if (labels[LABEL_KEYS.keep] !== "true") return false;
106
-
107
- // A retained volume is trusted only after its complete managed identity
108
- // matches the current git plan. The mode label is required even though the
109
- // shared VolumeLabelInput contract predates that label.
110
- return true;
111
- }
112
-
113
- /**
114
- * Seed a newly-created git volume. This function owns cleanup of both the
115
- * temporary guest copy and the host bundle. Callers must only invoke it for a
116
- * plan with seedRequired=true.
117
- */
118
- export async function seedGitVolume(
119
- transport: SandboxTransport,
120
- plan: GitVolumePlan,
121
- bundle: GitSeedBundle | null,
122
- ): Promise<{ headSha: string | null }> {
123
- if (plan.kind !== "git-volume") {
124
- throw new Error("cannot seed non-git storage");
125
- }
126
- const guestBundlePath = `/tmp/pi-msb-seed-${randomUUID()}.bundle`;
127
- let operationError: unknown;
128
- let cleanupError: unknown;
129
- let headSha: string | null = null;
130
-
131
- try {
132
- if (!plan.unborn && (!bundle || !plan.headSha)) {
133
- throw new Error("a committed Git volume requires a seed bundle and HEAD SHA");
134
- }
135
- if (plan.unborn && bundle) {
136
- // An unborn repository has no committed object to bundle. Treating a
137
- // supplied bundle as authoritative would make the source state ambiguous.
138
- throw new Error("an unborn Git repository cannot be seeded from a bundle");
139
- }
140
- if (bundle && plan.headSha && bundle.headSha !== plan.headSha) {
141
- throw new Error(`seed bundle SHA mismatch: expected ${plan.headSha}, got ${bundle.headSha}`);
142
- }
143
- if (bundle && bundle.branch !== plan.branch) {
144
- throw new Error(
145
- `seed bundle branch mismatch: expected ${plan.branch ?? "detached"}, got ${bundle.branch ?? "detached"}`,
146
- );
147
- }
148
-
149
- if (bundle) {
150
- await copyBundle(transport, bundle.hostPath, guestBundlePath);
151
-
152
- // `git clone` only imports the bundle's branch refs. The seed is kept in
153
- // a private namespace, so initialize first and fetch that ref explicitly.
154
- // This also avoids configuring the temporary bundle as a remote.
155
- await runChecked(
156
- transport,
157
- "git",
158
- ["init", plan.mountGuestPath],
159
- plan.mountGuestPath,
160
- );
161
- await runChecked(
162
- transport,
163
- "git",
164
- ["fetch", "--no-tags", guestBundlePath, BUNDLE_SEED_REF],
165
- plan.mountGuestPath,
166
- );
167
-
168
- // Keep an immutable local ref for later diff/reference operations after
169
- // the temporary guest bundle is removed.
170
- await runChecked(
171
- transport,
172
- "git",
173
- ["update-ref", `refs/pi-msb/seed/${plan.headSha}`, plan.headSha!],
174
- plan.mountGuestPath,
175
- );
176
-
177
- if (plan.branch !== null) {
178
- await runChecked(
179
- transport,
180
- "git",
181
- ["checkout", "-B", plan.branch ?? "", plan.headSha!],
182
- plan.mountGuestPath,
183
- );
184
- } else {
185
- await runChecked(
186
- transport,
187
- "git",
188
- ["checkout", "--detach", plan.headSha!],
189
- plan.mountGuestPath,
190
- );
191
- }
192
-
193
- await verifySeed(transport, plan);
194
- headSha = plan.headSha;
195
- } else {
196
- const initArgs = plan.branch
197
- ? ["init", "-b", plan.branch, plan.mountGuestPath]
198
- : ["init", plan.mountGuestPath];
199
- await runChecked(transport, "git", initArgs, plan.mountGuestPath);
200
- await verifySeed(transport, plan);
201
- headSha = null;
202
- }
203
- } catch (error) {
204
- operationError = error;
205
- } finally {
206
- // Both bundle copies must be deleted. These cleanup operations are best
207
- // effort after an operation failure, but are still attempted independently
208
- // so a failed guest command cannot leak the host temporary bundle.
209
- try {
210
- await removeGuestBundle(transport, guestBundlePath);
211
- } catch (error) {
212
- cleanupError ??= error;
213
- }
214
- if (bundle) {
215
- try {
216
- await bundle.cleanup();
217
- } catch (error) {
218
- cleanupError ??= error;
219
- }
220
- }
221
- }
222
-
223
- if (operationError) throw operationError;
224
- if (cleanupError) throw cleanupError;
225
- return { headSha };
226
- }
227
-
228
- function selectMode(mode: Config["mode"]): "git" | "direct" | "none" {
229
- // Keep `auto` as a compatibility alias for the direct default. It must not
230
- // implicitly switch to Git isolation based on the current directory.
231
- if (mode === "auto") return "direct";
232
- return mode;
233
- }
234
-
235
- function assertAbsolute(value: string, label: string): void {
236
- if (!isAbsolute(value)) throw new Error(`${label} must be an absolute path`);
237
- }
238
-
239
- function isRestoredGitState(
240
- restored: PersistedSandboxState | null | undefined,
241
- expected: { sessionId: string; cwd: string; volumeName: string },
242
- ): boolean {
243
- return Boolean(
244
- restored &&
245
- restored.version === STATE_SCHEMA_VERSION &&
246
- restored.sessionId === expected.sessionId &&
247
- restored.mode === "git" &&
248
- restored.cwd === expected.cwd &&
249
- restored.volumeName === expected.volumeName,
250
- );
251
- }
252
-
253
- async function copyBundle(
254
- transport: SandboxTransport,
255
- hostPath: string,
256
- guestPath: string,
257
- ): Promise<void> {
258
- try {
259
- await transport.copyFromHost(hostPath, guestPath);
260
- } catch (error) {
261
- throw new Error(`copying Git seed bundle failed: ${errorMessage(error)}`, { cause: error });
262
- }
263
- }
264
-
265
- async function runChecked(
266
- transport: SandboxTransport,
267
- command: string,
268
- args: string[],
269
- cwd: string,
270
- ): Promise<{ stdout: Buffer; stderr: Buffer }> {
271
- const result = await transport.exec(command, args, { cwd });
272
- if (result.exitCode !== 0) {
273
- const stderr = result.stderr.toString("utf8").trim();
274
- const suffix = stderr ? `: ${stderr}` : "";
275
- throw new Error(`${command} ${args[0] ?? "command"} failed (exit ${result.exitCode})${suffix}`);
276
- }
277
- return { stdout: result.stdout, stderr: result.stderr };
278
- }
279
-
280
- async function verifySeed(transport: SandboxTransport, plan: GitVolumePlan): Promise<void> {
281
- const head = await transport.exec("git", ["rev-parse", "HEAD"], { cwd: plan.mountGuestPath });
282
- const actualHead = head.stdout.toString("utf8").trim();
283
- if (plan.unborn) {
284
- // An unborn repository has no object named HEAD yet. Git reports that
285
- // normal state with a nonzero rev-parse status.
286
- if (head.exitCode === 0) {
287
- throw new Error(`unborn Git seed unexpectedly has HEAD ${actualHead || "<empty>"}`);
288
- }
289
- } else {
290
- if (head.exitCode !== 0) {
291
- const stderr = head.stderr.toString("utf8").trim();
292
- throw new Error(`unable to read seed HEAD${stderr ? `: ${stderr}` : ""}`);
293
- }
294
- if (actualHead !== plan.headSha) {
295
- throw new Error(`seed HEAD mismatch: expected ${plan.headSha}, got ${actualHead || "<empty>"}`);
296
- }
297
- }
298
-
299
- const branch = await transport.exec(
300
- "git",
301
- ["symbolic-ref", "--quiet", "--short", "HEAD"],
302
- { cwd: plan.mountGuestPath },
303
- );
304
- const actualBranch = branch.stdout.toString("utf8").trim();
305
- if (plan.branch !== null) {
306
- if (branch.exitCode !== 0 || actualBranch !== plan.branch) {
307
- const stderr = branch.stderr.toString("utf8").trim();
308
- throw new Error(
309
- `seed branch mismatch: expected ${plan.branch}, got ${actualBranch || "detached"}${stderr ? `: ${stderr}` : ""}`,
310
- );
311
- }
312
- } else if (branch.exitCode === 0) {
313
- throw new Error(`seed branch mismatch: expected detached HEAD, got ${actualBranch}`);
314
- }
315
-
316
- const status = await runChecked(transport, "git", ["status", "--porcelain"], plan.mountGuestPath);
317
- if (status.stdout.toString("utf8") !== "") {
318
- throw new Error(`seed repository is not clean: ${status.stdout.toString("utf8").trim()}`);
319
- }
320
- }
321
-
322
- async function removeGuestBundle(transport: SandboxTransport, guestPath: string): Promise<void> {
323
- const result = await transport.exec("rm", ["-f", "--", guestPath]);
324
- if (result.exitCode !== 0) {
325
- const stderr = result.stderr.toString("utf8").trim();
326
- throw new Error(`removing Git seed bundle failed${stderr ? `: ${stderr}` : ""}`);
327
- }
328
- }
329
-
330
- function errorMessage(error: unknown): string {
331
- return error instanceof Error ? error.message : String(error);
332
- }