pi-microsandbox 0.1.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.
@@ -0,0 +1,321 @@
1
+ /** Managed microsandbox labels and persisted session state. */
2
+ import {
3
+ LABEL_KEYS,
4
+ STATE_SCHEMA_VERSION,
5
+ type ManagedSandboxRecord,
6
+ type PersistedSandboxState,
7
+ type SandboxLabelInput,
8
+ type StorageMode,
9
+ type ValidatedManagedSandbox,
10
+ type VolumeLabelInput,
11
+ type VolumeRecord,
12
+ } from "./types.ts";
13
+
14
+ const STORAGE_MODES: readonly StorageMode[] = ["git", "direct", "none"];
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function isStorageMode(value: unknown): value is StorageMode {
21
+ return typeof value === "string" && STORAGE_MODES.includes(value as StorageMode);
22
+ }
23
+
24
+ function nonEmptyString(value: unknown): value is string {
25
+ return typeof value === "string" && value.length > 0;
26
+ }
27
+
28
+ function finiteNumber(value: unknown): value is number {
29
+ return typeof value === "number" && Number.isFinite(value);
30
+ }
31
+
32
+ /**
33
+ * SDK versions have returned labels both as maps and as entry/tuple arrays.
34
+ * Keep that tolerance in this module so callers never need to know the SDK shape.
35
+ */
36
+ function normalizeLabels(value: unknown): Record<string, string> | null {
37
+ const labels: Record<string, string> = {};
38
+
39
+ if (isRecord(value)) {
40
+ for (const [key, labelValue] of Object.entries(value)) {
41
+ if (!key || labelValue === null || labelValue === undefined) continue;
42
+ if (
43
+ typeof labelValue === "string" ||
44
+ typeof labelValue === "number" ||
45
+ typeof labelValue === "boolean"
46
+ ) {
47
+ labels[key] = String(labelValue);
48
+ }
49
+ }
50
+ return labels;
51
+ }
52
+
53
+ if (Array.isArray(value)) {
54
+ for (const entry of value) {
55
+ let key: unknown;
56
+ let labelValue: unknown;
57
+
58
+ if (Array.isArray(entry)) {
59
+ [key, labelValue] = entry;
60
+ } else if (isRecord(entry)) {
61
+ key = entry.key ?? entry.name;
62
+ labelValue = entry.value;
63
+ }
64
+
65
+ if (
66
+ typeof key !== "string" ||
67
+ key.length === 0 ||
68
+ (typeof labelValue !== "string" &&
69
+ typeof labelValue !== "number" &&
70
+ typeof labelValue !== "boolean")
71
+ ) {
72
+ // A bad label entry must not make otherwise useful SDK output unusable.
73
+ continue;
74
+ }
75
+ labels[key] = String(labelValue);
76
+ }
77
+ return labels;
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ function rawRecord(value: unknown): Record<string, unknown> | null {
84
+ if (isRecord(value)) return value;
85
+
86
+ // Some CLI/SDK adapters expose a record as [name, labels]. Accept this only
87
+ // at the normalization boundary; all public functions return object records.
88
+ if (
89
+ Array.isArray(value) &&
90
+ value.length >= 2 &&
91
+ typeof value[0] === "string"
92
+ ) {
93
+ return { name: value[0], labels: value[1] };
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ function labelsFromRaw(raw: Record<string, unknown>): Record<string, string> | null {
100
+ const direct = raw.labels;
101
+ if (direct !== undefined) return normalizeLabels(direct);
102
+
103
+ const metadata = raw.metadata;
104
+ if (isRecord(metadata) && metadata.labels !== undefined) {
105
+ return normalizeLabels(metadata.labels);
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function optionalString(
112
+ target: Record<string, unknown>,
113
+ key: string,
114
+ value: unknown,
115
+ ): void {
116
+ if (typeof value === "string") target[key] = value;
117
+ else delete target[key];
118
+ }
119
+
120
+ function optionalNumber(
121
+ target: Record<string, unknown>,
122
+ key: string,
123
+ value: unknown,
124
+ ): void {
125
+ if (finiteNumber(value)) target[key] = value;
126
+ else delete target[key];
127
+ }
128
+
129
+ export function buildSandboxLabels(input: SandboxLabelInput): Record<string, string> {
130
+ const labels: Record<string, string> = {
131
+ [LABEL_KEYS.managed]: "true",
132
+ [LABEL_KEYS.schema]: String(STATE_SCHEMA_VERSION),
133
+ [LABEL_KEYS.session]: input.sessionId,
134
+ [LABEL_KEYS.mode]: input.mode,
135
+ [LABEL_KEYS.cwd]: input.cwd,
136
+ [LABEL_KEYS.pid]: String(input.pid),
137
+ [LABEL_KEYS.image]: input.image,
138
+ [LABEL_KEYS.keep]: "true",
139
+ };
140
+
141
+ if (input.mode === "git") {
142
+ if (!nonEmptyString(input.volumeName)) {
143
+ throw new Error("git sandbox labels require a volume name");
144
+ }
145
+ labels[LABEL_KEYS.volume] = input.volumeName;
146
+ if (input.seedBranch !== undefined && input.seedBranch !== null) {
147
+ labels[LABEL_KEYS.seedBranch] = input.seedBranch;
148
+ }
149
+ if (input.seedSha !== undefined && input.seedSha !== null) {
150
+ labels[LABEL_KEYS.seedSha] = input.seedSha;
151
+ }
152
+ }
153
+
154
+ return labels;
155
+ }
156
+
157
+ export function buildVolumeLabels(input: VolumeLabelInput): Record<string, string> {
158
+ const labels: Record<string, string> = {
159
+ [LABEL_KEYS.managed]: "true",
160
+ [LABEL_KEYS.schema]: String(STATE_SCHEMA_VERSION),
161
+ [LABEL_KEYS.session]: input.sessionId,
162
+ [LABEL_KEYS.cwd]: input.cwd,
163
+ [LABEL_KEYS.keep]: "true",
164
+ };
165
+
166
+ if (input.seedBranch !== undefined && input.seedBranch !== null) {
167
+ labels[LABEL_KEYS.seedBranch] = input.seedBranch;
168
+ }
169
+ if (input.seedSha !== undefined && input.seedSha !== null) {
170
+ labels[LABEL_KEYS.seedSha] = input.seedSha;
171
+ }
172
+
173
+ return labels;
174
+ }
175
+
176
+ export function normalizeSandboxRecord(value: unknown): ManagedSandboxRecord | null {
177
+ const raw = rawRecord(value);
178
+ if (!raw) return null;
179
+
180
+ const labels = labelsFromRaw(raw);
181
+ const name = raw.name ?? raw.id ?? raw.sandboxName;
182
+ if (!labels || !nonEmptyString(name)) return null;
183
+
184
+ const normalized: Record<string, unknown> = { ...raw, name, labels };
185
+ optionalString(normalized, "status", raw.status ?? raw.state);
186
+ optionalNumber(normalized, "createdAt", raw.createdAt ?? raw.created_at);
187
+ return normalized as unknown as ManagedSandboxRecord;
188
+ }
189
+
190
+ export function normalizeVolumeRecord(value: unknown): VolumeRecord | null {
191
+ const raw = rawRecord(value);
192
+ if (!raw) return null;
193
+
194
+ const labels = labelsFromRaw(raw);
195
+ const name = raw.name ?? raw.id ?? raw.volumeName;
196
+ const hostPath = raw.hostPath ?? raw.path ?? raw.host_path;
197
+ if (!labels || !nonEmptyString(name) || !nonEmptyString(hostPath)) return null;
198
+
199
+ const normalized: Record<string, unknown> = { ...raw, name, hostPath, labels };
200
+ optionalString(normalized, "kind", raw.kind ?? raw.type);
201
+ optionalNumber(normalized, "usedBytes", raw.usedBytes ?? raw.used_bytes);
202
+ optionalNumber(normalized, "createdAt", raw.createdAt ?? raw.created_at);
203
+ return normalized as unknown as VolumeRecord;
204
+ }
205
+
206
+ export function parseCliSandboxList(json: string): ManagedSandboxRecord[] {
207
+ let parsed: unknown;
208
+ try {
209
+ parsed = JSON.parse(json);
210
+ } catch (error) {
211
+ throw new Error("invalid sandbox list JSON", { cause: error });
212
+ }
213
+
214
+ let rows: unknown[];
215
+ if (Array.isArray(parsed)) {
216
+ rows = parsed;
217
+ } else if (isRecord(parsed) && Array.isArray(parsed.sandboxes)) {
218
+ rows = parsed.sandboxes;
219
+ } else {
220
+ throw new Error("invalid sandbox list shape: expected an array or {sandboxes: []}");
221
+ }
222
+
223
+ return rows
224
+ .map((row) => normalizeSandboxRecord(row))
225
+ .filter((row): row is ManagedSandboxRecord => row !== null);
226
+ }
227
+
228
+ export function validateManagedSandbox(
229
+ record: ManagedSandboxRecord,
230
+ ): ValidatedManagedSandbox | null {
231
+ if (!isRecord(record) || !nonEmptyString(record.name)) return null;
232
+
233
+ const labels = normalizeLabels(record.labels);
234
+ if (!labels) return null;
235
+
236
+ const sessionId = labels[LABEL_KEYS.session];
237
+ const mode = labels[LABEL_KEYS.mode];
238
+ const cwd = labels[LABEL_KEYS.cwd];
239
+ const image = labels[LABEL_KEYS.image];
240
+ const pidText = labels[LABEL_KEYS.pid];
241
+ const volumeName = labels[LABEL_KEYS.volume];
242
+
243
+ if (
244
+ labels[LABEL_KEYS.managed] !== "true" ||
245
+ labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION) ||
246
+ labels[LABEL_KEYS.keep] !== "true" ||
247
+ !nonEmptyString(sessionId) ||
248
+ !isStorageMode(mode) ||
249
+ !nonEmptyString(cwd) ||
250
+ !nonEmptyString(image) ||
251
+ !pidText ||
252
+ !/^\d+$/.test(pidText) ||
253
+ !Number.isSafeInteger(Number(pidText))
254
+ ) {
255
+ return null;
256
+ }
257
+
258
+ if (mode === "git" && !nonEmptyString(volumeName)) return null;
259
+ if (mode !== "git" && volumeName !== undefined) return null;
260
+
261
+ const normalized: Record<string, unknown> = {
262
+ ...record,
263
+ labels,
264
+ name: record.name,
265
+ sessionId,
266
+ mode,
267
+ cwd,
268
+ };
269
+ if (volumeName !== undefined && volumeName.length > 0) {
270
+ normalized.volumeName = volumeName;
271
+ } else {
272
+ delete normalized.volumeName;
273
+ }
274
+ return normalized as unknown as ValidatedManagedSandbox;
275
+ }
276
+
277
+ function validOptionalString(value: unknown): boolean {
278
+ return value === undefined || typeof value === "string";
279
+ }
280
+
281
+ function validOptionalNullableString(value: unknown): boolean {
282
+ return value === undefined || value === null || typeof value === "string";
283
+ }
284
+
285
+ function validPersistedState(value: Record<string, unknown>): boolean {
286
+ return (
287
+ value.version === STATE_SCHEMA_VERSION &&
288
+ nonEmptyString(value.sessionId) &&
289
+ nonEmptyString(value.sandboxName) &&
290
+ isStorageMode(value.mode) &&
291
+ nonEmptyString(value.cwd) &&
292
+ nonEmptyString(value.image) &&
293
+ typeof value.enabled === "boolean" &&
294
+ finiteNumber(value.createdAt) &&
295
+ validOptionalString(value.volumeName) &&
296
+ validOptionalString(value.volumeHostPath) &&
297
+ validOptionalNullableString(value.seedBranch) &&
298
+ validOptionalNullableString(value.seedSha)
299
+ );
300
+ }
301
+
302
+ export function encodeSessionState(
303
+ state: PersistedSandboxState,
304
+ ): PersistedSandboxState {
305
+ // Do not mutate a caller-owned object. Encoding always stamps the current
306
+ // schema so a state written by this module can never be mistaken for an old
307
+ // version after a future schema change.
308
+ return { ...state, version: STATE_SCHEMA_VERSION };
309
+ }
310
+
311
+ export function decodeSessionState(
312
+ value: unknown,
313
+ currentSessionId: string,
314
+ ): PersistedSandboxState | null {
315
+ if (!isRecord(value) || value.sessionId !== currentSessionId) return null;
316
+ if (!validPersistedState(value)) return null;
317
+
318
+ // Preserve forward-compatible, unknown JSON fields while replacing the
319
+ // validated known fields with their exact runtime values.
320
+ return { ...value, version: STATE_SCHEMA_VERSION } as PersistedSandboxState;
321
+ }
@@ -0,0 +1,292 @@
1
+ import { chmod, mkdir, open, readFile } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ import { LOCKFILE_VERSION, resourceId } from "./types.ts";
5
+ import type { LockHandle, LockInfo, LocksPort } from "./types.ts";
6
+
7
+ /**
8
+ * The hooks are deliberately part of the options shape so unit tests can exercise
9
+ * the lifecycle without loading the native addon. Production callers leave them
10
+ * unset and use fs-ext below.
11
+ */
12
+ export type FlockFn = (fd: number, operation: "exnb" | "un") => Promise<void>;
13
+
14
+ export interface LocksOptions {
15
+ lockDir: string;
16
+ warn?: (message: string) => void;
17
+ flock?: FlockFn;
18
+ unlock?: (fd: number) => Promise<void>;
19
+ }
20
+
21
+ type FsExtCallback = (
22
+ fd: number,
23
+ operation: string,
24
+ callback: (error?: unknown) => void,
25
+ ) => void;
26
+ type FsExtUnlockCallback = (fd: number, callback: (error?: unknown) => void) => void;
27
+ type FsExtModule = {
28
+ flock?: FsExtCallback;
29
+ unlock?: FsExtUnlockCallback;
30
+ };
31
+
32
+ let fsExtPromise: Promise<FsExtModule> | undefined;
33
+
34
+ // Keep the native dependency genuinely lazy and let extension-load/type-only
35
+ // environments operate without resolving the optional native module.
36
+ function importNativeModule(specifier: string): Promise<unknown> {
37
+ return import(specifier);
38
+ }
39
+
40
+ function errorCode(error: unknown): string | undefined {
41
+ if (typeof error === "object" && error !== null && "code" in error) {
42
+ const code = (error as { code?: unknown }).code;
43
+ return typeof code === "string" ? code : undefined;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function errorMessage(error: unknown): string {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+
52
+ function unsupportedPlatformError(): Error {
53
+ return new Error(
54
+ "pi-microsandbox owner locks require POSIX flock(2) via fs-ext; this platform is unsupported (Windows LockFileEx is not implemented)",
55
+ );
56
+ }
57
+
58
+ async function loadFsExt(): Promise<FsExtModule> {
59
+ if (process.platform === "win32") throw unsupportedPlatformError();
60
+ fsExtPromise ??= importNativeModule("fs-ext").then((module) => {
61
+ const defaultExport = (module as unknown as { default?: unknown }).default;
62
+ const candidate = (defaultExport ?? module) as FsExtModule;
63
+ if (typeof candidate.flock !== "function") {
64
+ throw new Error("fs-ext loaded without flock(); refusing to use a racy PID fallback");
65
+ }
66
+ return candidate;
67
+ });
68
+ return fsExtPromise;
69
+ }
70
+
71
+ function callbackFlock(flock: FsExtCallback, fd: number, operation: string): Promise<void> {
72
+ return new Promise((resolve, reject) => {
73
+ try {
74
+ flock(fd, operation, (error) => (error ? reject(error) : resolve()));
75
+ } catch (error) {
76
+ reject(error);
77
+ }
78
+ });
79
+ }
80
+
81
+ function callbackUnlock(unlock: FsExtUnlockCallback, fd: number): Promise<void> {
82
+ return new Promise((resolve, reject) => {
83
+ try {
84
+ unlock(fd, (error) => (error ? reject(error) : resolve()));
85
+ } catch (error) {
86
+ reject(error);
87
+ }
88
+ });
89
+ }
90
+
91
+ interface LockOperations {
92
+ flock(fd: number): Promise<void>;
93
+ unlock(fd: number): Promise<void>;
94
+ }
95
+
96
+ async function lockOperations(opts: LocksOptions): Promise<LockOperations> {
97
+ if (opts.flock) {
98
+ return {
99
+ flock: (fd) => opts.flock!(fd, "exnb"),
100
+ unlock: (fd) => opts.unlock ? opts.unlock(fd) : opts.flock!(fd, "un"),
101
+ };
102
+ }
103
+
104
+ let module: FsExtModule;
105
+ try {
106
+ module = await loadFsExt();
107
+ } catch (error) {
108
+ throw new Error(
109
+ `Unable to load fs-ext for owner locks; refusing to use a racy PID fallback: ${errorMessage(error)}`,
110
+ { cause: error },
111
+ );
112
+ }
113
+ const flock = module.flock;
114
+ if (!flock) {
115
+ throw new Error("fs-ext does not provide flock(); refusing to use a racy PID fallback");
116
+ }
117
+ const unlock = module.unlock;
118
+ return {
119
+ flock: (fd) => callbackFlock(flock, fd, "exnb"),
120
+ unlock: (fd) => unlock
121
+ ? callbackUnlock(unlock, fd)
122
+ : callbackFlock(flock, fd, "un"),
123
+ };
124
+ }
125
+
126
+ function normalizeLockInfo(value: unknown): LockInfo | null {
127
+ if (typeof value !== "object" || value === null) return null;
128
+ const candidate = value as Partial<LockInfo>;
129
+ const { version, sessionId, sandboxName, volumeName, mode, cwd, pid, createdAt } = candidate;
130
+ if (version !== LOCKFILE_VERSION
131
+ || typeof sessionId !== "string" || sessionId.length === 0
132
+ || typeof sandboxName !== "string" || sandboxName.length === 0
133
+ || (volumeName !== undefined && (typeof volumeName !== "string" || volumeName.length === 0))
134
+ || (mode !== "git" && mode !== "direct" && mode !== "none")
135
+ || typeof cwd !== "string" || !isAbsolute(cwd)
136
+ || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0
137
+ || typeof createdAt !== "number" || !Number.isFinite(createdAt)) {
138
+ return null;
139
+ }
140
+ return { version, sessionId, sandboxName, volumeName, mode, cwd, pid, createdAt };
141
+ }
142
+
143
+ function validLockInfo(value: unknown): value is LockInfo {
144
+ return normalizeLockInfo(value) !== null;
145
+ }
146
+
147
+ function lockInfoJson(info: LockInfo): string {
148
+ // Serialize only the contract fields. This keeps the lockfile a small, validated
149
+ // ownership record even if a caller passes an object with accidental extra fields.
150
+ const value: Record<string, unknown> = {
151
+ version: LOCKFILE_VERSION,
152
+ sessionId: info.sessionId,
153
+ sandboxName: info.sandboxName,
154
+ mode: info.mode,
155
+ cwd: info.cwd,
156
+ pid: info.pid,
157
+ createdAt: info.createdAt,
158
+ };
159
+ if (info.volumeName !== undefined) value.volumeName = info.volumeName;
160
+ return `${JSON.stringify(value)}\n`;
161
+ }
162
+
163
+ async function ensureLockDir(lockDir: string): Promise<void> {
164
+ try {
165
+ await mkdir(lockDir, { recursive: true, mode: 0o700 });
166
+ // mkdir's mode is ignored when the directory already exists.
167
+ await chmod(lockDir, 0o700);
168
+ } catch (error) {
169
+ throw new Error(`Unable to prepare owner lock directory ${lockDir}: ${errorMessage(error)}`, { cause: error });
170
+ }
171
+ }
172
+
173
+ export function lockPathFor(lockDir: string, sessionId: string): string {
174
+ return join(lockDir, `pi-msb-${resourceId(sessionId)}.lock`);
175
+ }
176
+
177
+ async function acquireLock(
178
+ opts: LocksOptions,
179
+ path: string,
180
+ info?: LockInfo,
181
+ ): Promise<LockHandle | null> {
182
+ await ensureLockDir(opts.lockDir);
183
+ const operations = await lockOperations(opts);
184
+ const file = await open(path, "a+", 0o600);
185
+
186
+ try {
187
+ try {
188
+ await operations.flock(file.fd);
189
+ } catch (error) {
190
+ if (errorCode(error) === "EAGAIN" || errorCode(error) === "EWOULDBLOCK") {
191
+ await file.close();
192
+ return null;
193
+ }
194
+ throw new Error(
195
+ `Unable to acquire non-blocking owner flock for ${path}: ${errorMessage(error)}`,
196
+ { cause: error },
197
+ );
198
+ }
199
+
200
+ try {
201
+ await file.chmod(0o600);
202
+ if (info) {
203
+ if (!validLockInfo(info)) throw new TypeError("Invalid owner lock information");
204
+ await file.truncate(0);
205
+ await file.writeFile(lockInfoJson(info), "utf8");
206
+ await file.sync();
207
+ }
208
+ } catch (error) {
209
+ try {
210
+ await operations.unlock(file.fd);
211
+ } finally {
212
+ await file.close();
213
+ }
214
+ throw new Error(`Unable to write owner lock ${path}: ${errorMessage(error)}`, { cause: error });
215
+ }
216
+
217
+ let releasePromise: Promise<void> | undefined;
218
+ return {
219
+ path,
220
+ release: () => {
221
+ releasePromise ??= (async () => {
222
+ let firstError: unknown;
223
+ try {
224
+ await operations.unlock(file.fd);
225
+ } catch (error) {
226
+ firstError = error;
227
+ }
228
+ try {
229
+ await file.close();
230
+ } catch (error) {
231
+ firstError ??= error;
232
+ }
233
+ if (firstError) {
234
+ throw new Error(`Unable to release owner flock for ${path}: ${errorMessage(firstError)}`, {
235
+ cause: firstError,
236
+ });
237
+ }
238
+ })();
239
+ return releasePromise;
240
+ },
241
+ };
242
+ } catch (error) {
243
+ // The contention path closes the descriptor itself. All other failures must
244
+ // close it too; the persistent inode is intentionally never unlinked.
245
+ if (errorCode(error) !== "EAGAIN" && errorCode(error) !== "EWOULDBLOCK") {
246
+ try {
247
+ await file.close();
248
+ } catch {
249
+ // Preserve the actionable acquisition/write error.
250
+ }
251
+ }
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ export async function acquireOwnerLock(opts: LocksOptions, info: LockInfo): Promise<LockHandle | null> {
257
+ if (!validLockInfo(info)) throw new TypeError("Invalid owner lock information");
258
+ return acquireLock(opts, lockPathFor(opts.lockDir, info.sessionId), info);
259
+ }
260
+
261
+ export async function tryAcquireOrphanLock(
262
+ opts: LocksOptions,
263
+ sessionId: string,
264
+ ): Promise<LockHandle | null> {
265
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
266
+ throw new TypeError("sessionId must be a non-empty string");
267
+ }
268
+ return acquireLock(opts, lockPathFor(opts.lockDir, sessionId));
269
+ }
270
+
271
+ export async function readLockInfo(path: string): Promise<LockInfo | null> {
272
+ let text: string;
273
+ try {
274
+ text = await readFile(path, "utf8");
275
+ } catch (error) {
276
+ if (errorCode(error) === "ENOENT") return null;
277
+ throw error;
278
+ }
279
+
280
+ try {
281
+ const value: unknown = JSON.parse(text);
282
+ return validLockInfo(value) ? value : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ export function createLocksPort(opts: LocksOptions): LocksPort {
289
+ return {
290
+ tryAcquire: (sessionId) => tryAcquireOrphanLock(opts, sessionId),
291
+ };
292
+ }