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.
- package/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -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
|
-
}
|