@anhlp/alp-core 0.0.1-beta.3 → 0.0.1-beta.4

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/dist/backup.js CHANGED
@@ -13,10 +13,15 @@ import { STATE_SCHEMA, assertStateSupported, readStateSchema } from "./state-sch
13
13
  import { withTaskLock } from "./tasks.js";
14
14
  var BACKUP_FORMAT = 1;
15
15
  var BACKUP_EXTENSION = ".alpbackup";
16
- var STATE_UNITS = ["state/schema.json", "state/sessions", "state/timeline", "state/receipts", "state/recall.json", "state/pause.json", "state/trust.json", "state/web-workspaces.json"];
16
+ var RESULT_LEDGER = "state/assignment-results.json";
17
+ var RESULT_MARKER = `${RESULT_LEDGER}.initialized`;
18
+ var DESTINATIONS = "worktree-destinations.json";
19
+ var QUARANTINED_DESTINATIONS = "state/worktree-destinations.quarantined.json";
20
+ var STATE_UNITS = [RESULT_LEDGER, RESULT_MARKER, "state/schema.json", "state/sessions", "state/timeline", "state/receipts", "state/recall.json", "state/pause.json", "state/trust.json", "state/web-workspaces.json"];
17
21
  var CLEARED_UNITS = ["state/live.json", "state/migration.json"];
18
22
  var HOME_UNITS = [
19
23
  ...STATE_UNITS,
24
+ DESTINATIONS,
20
25
  "settings.json",
21
26
  "library.json",
22
27
  "role-skills.json",
@@ -378,6 +383,98 @@ async function holdTaskLocks(roots, work) {
378
383
  clearInterval(touch);
379
384
  }
380
385
  }
386
+ async function prepareWorkspaceRestore(home, root, units, warnings) {
387
+ const relevant = [RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/sessions", "state/web-workspaces.json"];
388
+ const restorable = units.filter((unit) => unit.path !== QUARANTINED_DESTINATIONS);
389
+ if (!units.some((unit) => relevant.includes(unit.path))) return restorable;
390
+ for (const base of [home, root]) {
391
+ const info = await lstat(path.join(base, "state")).catch((error) => {
392
+ if (error.code === "ENOENT") return void 0;
393
+ throw error;
394
+ });
395
+ if (info && !info.isDirectory()) throw invalid("workspace state must be a real directory");
396
+ }
397
+ const result = restorable.filter((unit) => ![RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/web-workspaces.json"].includes(unit.path));
398
+ async function regular(base, unit, declared) {
399
+ if (declared && declared.kind === "absent") return void 0;
400
+ const file = path.join(base, unit);
401
+ const info = await lstat(file).catch((error) => {
402
+ if (error.code === "ENOENT") return void 0;
403
+ throw error;
404
+ });
405
+ if (!info) {
406
+ if (declared) throw invalid(`${unit} is declared but missing from the backup`);
407
+ return void 0;
408
+ }
409
+ if (!info.isFile()) throw invalid(`${unit} must be a regular file for a workspace restore`);
410
+ return readFile(file, "utf8");
411
+ }
412
+ const incoming = async (unit) => {
413
+ const declared = units.find((entry) => entry.path === unit);
414
+ return declared ? regular(root, unit, declared) : void 0;
415
+ };
416
+ const owners = /* @__PURE__ */ new Map();
417
+ let hasLedger = false;
418
+ for (const text of [await incoming(RESULT_LEDGER), await regular(home, RESULT_LEDGER)]) {
419
+ if (text === void 0) continue;
420
+ let value;
421
+ try {
422
+ value = JSON.parse(text);
423
+ } catch {
424
+ throw invalid("assignment result ledger is not readable JSON; repair it before restoring");
425
+ }
426
+ const absolute = (value2) => typeof value2 === "string" && path.isAbsolute(value2);
427
+ if (value?.version !== 1 || !Array.isArray(value.workspaces) || !value.workspaces.every(absolute) || !Array.isArray(value.results) || !value.results.every((entry) => entry && typeof entry.assignmentId === "string" && absolute(entry.checkout) && (entry.worktree === void 0 || absolute(entry.worktree))))
428
+ throw invalid("assignment result ledger has an unsupported or invalid shape");
429
+ hasLedger = true;
430
+ for (const entry of value.results) {
431
+ const owner = { assignmentId: entry.assignmentId, checkout: entry.checkout, ...entry.worktree === void 0 ? {} : { worktree: entry.worktree } };
432
+ const previous = owners.get(owner.assignmentId);
433
+ if (previous && JSON.stringify(previous) !== JSON.stringify(owner)) throw invalid("assignment result ownership conflicts with current data; reconcile it before restoring");
434
+ owners.set(owner.assignmentId, owner);
435
+ }
436
+ }
437
+ const markers = [await incoming(RESULT_MARKER), await regular(home, RESULT_MARKER)];
438
+ if (markers.some((text) => text !== void 0 && text !== "1\n")) throw invalid("assignment result initialized marker is invalid");
439
+ await mkdir(path.join(root, "state"), { recursive: true, mode: 448 });
440
+ if (hasLedger) await writeAtomic(path.join(root, RESULT_LEDGER), JSON.stringify({ version: 1, workspaces: [], results: [...owners.values()] }) + "\n");
441
+ result.push({ path: RESULT_LEDGER, kind: hasLedger ? "file" : "absent" });
442
+ const initialized = markers.some((text) => text !== void 0);
443
+ if (initialized) await writeAtomic(path.join(root, RESULT_MARKER), "1\n");
444
+ result.push({ path: RESULT_MARKER, kind: initialized ? "file" : "absent" });
445
+ const mappings = await incoming(DESTINATIONS);
446
+ if (mappings !== void 0) {
447
+ await writeAtomic(path.join(root, QUARANTINED_DESTINATIONS), mappings);
448
+ result.push({ path: QUARANTINED_DESTINATIONS, kind: "file" });
449
+ }
450
+ result.push({ path: DESTINATIONS, kind: "absent" });
451
+ const registryUnit = "state/web-workspaces.json";
452
+ const registryDeclaration = units.find((unit) => unit.path === registryUnit);
453
+ const registryText = registryDeclaration ? await incoming(registryUnit) : await regular(home, registryUnit);
454
+ if (registryText !== void 0) {
455
+ let registry;
456
+ try {
457
+ registry = JSON.parse(registryText);
458
+ } catch {
459
+ throw invalid("workspace registry is not readable JSON; repair it before restoring");
460
+ }
461
+ const epoch = `restore-${randomBytes(16).toString("hex")}`;
462
+ let revoked = false;
463
+ const revoke = (value) => {
464
+ if (!value || typeof value !== "object") return;
465
+ if (Object.hasOwn(value, "grantEpoch")) {
466
+ value.grantEpoch = epoch;
467
+ revoked = true;
468
+ }
469
+ for (const child of Object.values(value)) revoke(child);
470
+ };
471
+ revoke(registry);
472
+ await writeAtomic(path.join(root, registryUnit), revoked ? JSON.stringify(registry) + "\n" : registryText);
473
+ result.push({ path: registryUnit, kind: "file" });
474
+ } else if (registryDeclaration) result.push({ path: registryUnit, kind: "absent" });
475
+ if (hasLedger || initialized || mappings !== void 0 || await exists(path.join(home, DESTINATIONS))) warnings.push("Workspace restore retains assignment owners but clears checkout birth certification; old targets require an audited migration. Worktree destination mappings are inactive and require explicit host approval; archived mapping evidence is in state/worktree-destinations.quarantined.json. Historical checkout grants remain revoked after reapproval.");
476
+ return result;
477
+ }
381
478
  async function restoreBackup({ home, file, projects = true, safety = true, clearLive = true, replaceNewer = false, by = "alp", beforeSwap }) {
382
479
  const interrupted = await recoverInterruptedRestore(home);
383
480
  try {
@@ -392,6 +489,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
392
489
  const warnings = [];
393
490
  const areaBase = (area) => area === "home" ? home : header.projects[Number(area.slice(8))].root;
394
491
  const directories = [];
492
+ let homeUnits;
395
493
  const journal = { version: 1, id, file: path.resolve(file), by, startedAt: (/* @__PURE__ */ new Date()).toISOString(), phase: "staging", units: [], staging: [], created: [], locks: [] };
396
494
  const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + "\n");
397
495
  const abandon = async () => {
@@ -443,6 +541,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
443
541
  };
444
542
  }
445
543
  });
544
+ homeUnits = await prepareWorkspaceRestore(home, staging.get("home"), header.units, warnings);
446
545
  for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 511 | 448).catch(() => {
447
546
  });
448
547
  } catch (error) {
@@ -463,8 +562,8 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
463
562
  const plan = [];
464
563
  for (const [area, root] of staging) {
465
564
  const base = areaBase(area);
466
- const units = area === "home" ? header.units : header.projects[Number(area.slice(8))].units;
467
- const known = area === "home" ? HOME_UNITS : PROJECT_UNITS;
565
+ const units = area === "home" ? homeUnits : header.projects[Number(area.slice(8))].units;
566
+ const known = area === "home" ? [...HOME_UNITS, QUARANTINED_DESTINATIONS] : PROJECT_UNITS;
468
567
  for (const unit of units) {
469
568
  if (!known.includes(unit.path)) {
470
569
  warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`);
@@ -489,7 +588,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
489
588
  let safetyFile;
490
589
  if (safety) {
491
590
  try {
492
- safetyFile = await automaticBackup(home, "pre-restore", { units: header.units.map((unit) => unit.path).filter((unit) => HOME_UNITS.includes(unit)), projects: roots, by });
591
+ safetyFile = await automaticBackup(home, "pre-restore", { units: [.../* @__PURE__ */ new Set([...header.units.map((unit) => unit.path), ...homeUnits.map((unit) => unit.path)])].filter((unit) => HOME_UNITS.includes(unit) || unit === QUARANTINED_DESTINATIONS), projects: roots, by });
493
592
  } catch (error) {
494
593
  await abandon();
495
594
  throw new AlpError("RESTORE_SAFETY_BACKUP_FAILED", `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
package/dist/index.d.ts CHANGED
@@ -28,3 +28,4 @@ export * as user_settings from "./user-settings.js";
28
28
  export * as validation from "./validation.js";
29
29
  export * as verify from "./verify.js";
30
30
  export * as workflow from "./workflow.js";
31
+ export * as user_worktree from "./user-worktree.js";
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ import * as user_settings from "./user-settings.js";
29
29
  import * as validation from "./validation.js";
30
30
  import * as verify from "./verify.js";
31
31
  import * as workflow from "./workflow.js";
32
+ import * as user_worktree from "./user-worktree.js";
32
33
  export {
33
34
  adapter,
34
35
  atomic,
@@ -57,6 +58,7 @@ export {
57
58
  trust,
58
59
  upgrade,
59
60
  user_settings,
61
+ user_worktree,
60
62
  validation,
61
63
  verify,
62
64
  workflow
@@ -78,7 +78,7 @@ async function migrateState(home, { by = "alp", migrations = MIGRATIONS, target
78
78
  throw new AlpError("STATE_MIGRATION_ROLLBACK_FAILED", `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
79
79
  }
80
80
  await rm(migrationFile(home), { force: true });
81
- throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error}; the state was put back from ${backup} and is unchanged. See docs/alp/data-recovery.md`, { cause: error });
81
+ throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error}; data was restored from ${backup} with workspace authority checks. See docs/alp/data-recovery.md`, { cause: error });
82
82
  }
83
83
  await writeStateSchema(home, step.to, by);
84
84
  await rm(migrationFile(home), { force: true });
@@ -0,0 +1,29 @@
1
+ export function repositoryIdentity(directory: any): Promise<{
2
+ gitDir: string;
3
+ commonDir: string;
4
+ branch: string | null;
5
+ }>;
6
+ /** User workspace primitives. Assignment cleanup's force/rm semantics must never be used here. */
7
+ export function userWorktreeGit({ executable, timeoutMs, maxBytes }?: {
8
+ executable?: string | undefined;
9
+ timeoutMs?: number | undefined;
10
+ maxBytes?: number | undefined;
11
+ }): {
12
+ facts: (source: any) => Promise<{
13
+ commonDir: string;
14
+ gitDir: string;
15
+ head: any;
16
+ branch: any;
17
+ }>;
18
+ inspect: (source: any, target: any, branch: any) => Promise<any>;
19
+ assertClean: (target: any) => Promise<void>;
20
+ checkoutFacts(directory: any): Promise<{
21
+ isGit: true;
22
+ repoRoot: any;
23
+ currentBranch: any;
24
+ remoteUrl: any;
25
+ isDirty: boolean;
26
+ }>;
27
+ create(source: any, parent: any, name: any, branch: any, head: any): Promise<string>;
28
+ remove(source: any, target: any, branch: any): Promise<void>;
29
+ };
@@ -0,0 +1,242 @@
1
+ // packages/core/src/user-worktree.js
2
+ import { spawn } from "node:child_process";
3
+ import { lstat, realpath, open } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { constants } from "node:fs";
6
+ async function control(file) {
7
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
8
+ try {
9
+ const info = await handle.stat();
10
+ if (!info.isFile() || info.nlink !== 1 || info.size > 4096) throw new Error("Invalid Git administrative file");
11
+ const bytes = Buffer.alloc(4097);
12
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
13
+ if (bytesRead > 4096) throw new Error("Git administrative file exceeds limit");
14
+ return bytes.subarray(0, bytesRead).toString("utf8").replace(/\r?\n$/, "");
15
+ } finally {
16
+ await handle.close();
17
+ }
18
+ }
19
+ async function repositoryIdentity(directory) {
20
+ if (await realpath(directory) !== directory) throw new Error("Repository root is not canonical");
21
+ const entry = path.join(directory, ".git");
22
+ const info = await lstat(entry);
23
+ let gitDir;
24
+ if (info.isDirectory()) gitDir = await realpath(entry);
25
+ else {
26
+ const value = await control(entry);
27
+ if (!value.startsWith("gitdir: ") || /[\r\n\0]/.test(value)) throw new Error("Invalid Git control link");
28
+ gitDir = await realpath(path.resolve(directory, value.slice(8)));
29
+ }
30
+ const commonEntry = path.join(gitDir, "commondir");
31
+ const hasCommon = await lstat(commonEntry).catch((error) => {
32
+ if (error.code === "ENOENT") return void 0;
33
+ throw error;
34
+ });
35
+ const commonDir = hasCommon ? await realpath(path.resolve(gitDir, await control(commonEntry))) : gitDir;
36
+ if (gitDir !== commonDir) {
37
+ if (path.dirname(gitDir) !== path.join(commonDir, "worktrees") || path.resolve(gitDir, await control(path.join(gitDir, "gitdir"))) !== entry)
38
+ throw new Error("Git worktree backlink changed");
39
+ }
40
+ const head = await control(path.join(gitDir, "HEAD"));
41
+ const branch = head.startsWith("ref: refs/heads/") ? head.slice(16) : null;
42
+ if (branch !== null && (!branch || /[\r\n\0]/.test(branch))) throw new Error("Invalid branch reference");
43
+ return { gitDir, commonDir, branch };
44
+ }
45
+ function userWorktreeGit({ executable = "/usr/bin/git", timeoutMs = 15e3, maxBytes = 2 * 1024 * 1024 } = {}) {
46
+ if (process.platform === "win32") throw new Error("User worktree operations are unavailable on this host");
47
+ if (!path.isAbsolute(executable)) throw new Error("Git executable must be host configured and absolute");
48
+ const disabled = [
49
+ "-c",
50
+ "core.hooksPath=/dev/null",
51
+ "-c",
52
+ "core.fsmonitor=false",
53
+ "-c",
54
+ "core.untrackedCache=false",
55
+ "-c",
56
+ "core.pager=cat",
57
+ "-c",
58
+ "maintenance.auto=false",
59
+ "-c",
60
+ "gc.auto=0",
61
+ "-c",
62
+ "protocol.allow=never",
63
+ "-c",
64
+ "submodule.recurse=false"
65
+ ];
66
+ const env = {
67
+ PATH: "/usr/bin:/bin",
68
+ GIT_CONFIG_NOSYSTEM: "1",
69
+ GIT_CONFIG_GLOBAL: "/dev/null",
70
+ GIT_TERMINAL_PROMPT: "0",
71
+ GIT_OPTIONAL_LOCKS: "0",
72
+ LANG: "C",
73
+ LC_ALL: "C"
74
+ };
75
+ async function raw(cwd, args, extra = []) {
76
+ return new Promise((resolve, reject) => {
77
+ const child = spawn(executable, [...disabled, ...extra, ...args], {
78
+ cwd,
79
+ env,
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ windowsHide: true,
82
+ detached: true
83
+ });
84
+ let size = 0;
85
+ let overflow = false;
86
+ const stdout = [];
87
+ const stderr = [];
88
+ const kill = () => {
89
+ try {
90
+ process.kill(-child.pid, "SIGKILL");
91
+ } catch {
92
+ child.kill("SIGKILL");
93
+ }
94
+ };
95
+ const timer = setTimeout(() => {
96
+ overflow = true;
97
+ kill();
98
+ }, timeoutMs);
99
+ const collect = (sink) => (chunk) => {
100
+ size += chunk.length;
101
+ if (size > maxBytes) {
102
+ overflow = true;
103
+ kill();
104
+ } else sink.push(chunk);
105
+ };
106
+ child.stdout.on("data", collect(stdout));
107
+ child.stderr.on("data", collect(stderr));
108
+ child.once("error", (error) => {
109
+ clearTimeout(timer);
110
+ reject(error);
111
+ });
112
+ child.once("close", (code) => {
113
+ clearTimeout(timer);
114
+ if (overflow) reject(new Error("Git operation exceeded its host limit"));
115
+ else
116
+ resolve({
117
+ code,
118
+ stdout: Buffer.concat(stdout).toString("utf8"),
119
+ stderr: Buffer.concat(stderr).toString("utf8")
120
+ });
121
+ });
122
+ });
123
+ }
124
+ async function filterOverrides(cwd) {
125
+ const filters = await raw(cwd, [
126
+ "config",
127
+ "--null",
128
+ "--name-only",
129
+ "--get-regexp",
130
+ "^filter\\..*\\.(smudge|clean|process|required)$"
131
+ ]);
132
+ if (filters.code !== 0 && filters.code !== 1) throw new Error("Cannot inspect repository filters");
133
+ const neutral = filters.stdout.split("\0").filter(Boolean).flatMap((key) => ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`]);
134
+ return neutral;
135
+ }
136
+ async function run(cwd, args, extra = []) {
137
+ return raw(cwd, args, [...await filterOverrides(cwd), ...extra]);
138
+ }
139
+ async function checked(cwd, args, extra = []) {
140
+ const result = await run(cwd, args, extra);
141
+ if (result.code !== 0) throw new Error("Git workspace operation refused");
142
+ return result.stdout.trim();
143
+ }
144
+ async function directory(value) {
145
+ const canonical = await realpath(value);
146
+ if (canonical !== value || !(await lstat(value)).isDirectory())
147
+ throw new Error("Workspace paths must be canonical directories");
148
+ return canonical;
149
+ }
150
+ async function facts(source) {
151
+ await directory(source);
152
+ const top = await checked(source, ["rev-parse", "--show-toplevel"]);
153
+ if (await realpath(top) !== source) throw new Error("Workspace must be a repository top level");
154
+ const commonDir = await realpath(
155
+ await checked(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
156
+ );
157
+ const identity = await repositoryIdentity(source);
158
+ if (identity.commonDir !== commonDir) throw new Error("Repository identity changed");
159
+ const head = await checked(source, ["rev-parse", "--verify", "HEAD"]);
160
+ const branch = await run(source, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
161
+ return { commonDir, gitDir: identity.gitDir, head, branch: branch.code === 0 ? branch.stdout.trim() : null };
162
+ }
163
+ async function list(source) {
164
+ const result = await checked(source, ["worktree", "list", "--porcelain", "-z"]);
165
+ return result.split("\0\0").filter(Boolean).map(
166
+ (block) => Object.fromEntries(
167
+ block.split("\0").filter(Boolean).map((line) => {
168
+ const i = line.indexOf(" ");
169
+ return i < 0 ? [line, true] : [line.slice(0, i), line.slice(i + 1)];
170
+ })
171
+ )
172
+ );
173
+ }
174
+ async function inspect(source, target, branch) {
175
+ const row = (await list(source)).find((row2) => row2.worktree === target);
176
+ if (!row) return void 0;
177
+ await directory(target);
178
+ if (row.branch !== `refs/heads/${branch}` || row.locked || row.prunable)
179
+ throw new Error("Managed worktree identity changed");
180
+ const origin = await facts(source);
181
+ const destination = await facts(target);
182
+ if (origin.commonDir !== destination.commonDir) throw new Error("Managed worktree repository changed");
183
+ return row;
184
+ }
185
+ async function assertClean(target) {
186
+ const flags = await checked(target, ["ls-files", "-v", "-z"]);
187
+ if (flags.split("\0").some((row) => row && (row[0] === "S" || /[a-z]/.test(row[0]))))
188
+ throw new Error("Worktree has hidden index state");
189
+ if ((await checked(target, ["ls-files", "--stage", "-z"])).split("\0").some((row) => row.startsWith("160000 ")))
190
+ throw new Error("Worktree has submodules");
191
+ if ((await checked(target, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"])).length)
192
+ throw new Error("Worktree contains uncommitted or ignored files");
193
+ }
194
+ return {
195
+ facts,
196
+ inspect,
197
+ assertClean,
198
+ async checkoutFacts(directory2) {
199
+ const canonical = await realpath(directory2);
200
+ const repoRoot = await checked(canonical, ["rev-parse", "--show-toplevel"]);
201
+ const branch = await run(canonical, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
202
+ const remote = await run(canonical, ["config", "--get", "remote.origin.url"]);
203
+ const status = await checked(canonical, ["status", "--porcelain=v1", "--untracked-files=all"]);
204
+ return {
205
+ isGit: (
206
+ /** @type {const} */
207
+ true
208
+ ),
209
+ repoRoot,
210
+ currentBranch: branch.code === 0 ? branch.stdout.trim() : null,
211
+ remoteUrl: remote.code === 0 ? remote.stdout.trim() || null : null,
212
+ isDirty: !!status
213
+ };
214
+ },
215
+ async create(source, parent, name, branch, head) {
216
+ await directory(parent);
217
+ if (!/^wks_[0-9a-f]{16}$/.test(name) || branch !== `alp/workspace/${name}` || !/^[0-9a-f]{40,64}$/.test(head))
218
+ throw new Error("Invalid generated worktree identity");
219
+ const target = path.join(parent, name);
220
+ if (await lstat(target).catch((error) => {
221
+ if (error.code === "ENOENT") return void 0;
222
+ throw error;
223
+ }))
224
+ throw new Error("Worktree destination already exists");
225
+ await checked(source, ["worktree", "add", "--quiet", "--no-checkout", "-b", branch, target, head]);
226
+ await checked(target, ["reset", "--hard", "--no-recurse-submodules", head]);
227
+ if (!await inspect(source, target, branch)) throw new Error("Worktree creation needs reconciliation");
228
+ return target;
229
+ },
230
+ async remove(source, target, branch) {
231
+ if (!await inspect(source, target, branch)) throw new Error("Managed worktree is missing");
232
+ await assertClean(target);
233
+ await checked(source, ["worktree", "remove", target], await filterOverrides(target));
234
+ if ((await list(source)).some((row) => row.worktree === target))
235
+ throw new Error("Worktree removal needs reconciliation");
236
+ }
237
+ };
238
+ }
239
+ export {
240
+ repositoryIdentity,
241
+ userWorktreeGit
242
+ };
@@ -147,8 +147,9 @@ function validateUserSettings(settings, source) {
147
147
  }
148
148
  if (settings.web !== void 0) {
149
149
  check(object(settings.web), "web must be an object");
150
- const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay"].includes(field));
151
- check(!extra, `unsupported web field '${extra}'; use enabled, port or relay`);
150
+ const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay", "permissions"].includes(field));
151
+ check(!extra, `unsupported web field '${extra}'; use enabled, port, relay or permissions`);
152
+ if (settings.web.permissions !== void 0) check(Array.isArray(settings.web.permissions) && settings.web.permissions.every((entry) => typeof entry === "string" && /^[a-z][a-z.]*$/.test(entry)), "web.permissions must be a list of permission names, such as files.read");
152
153
  const relay = settings.web.relay;
153
154
  if (relay !== void 0) {
154
155
  check(object(relay), "web.relay must be an object");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anhlp/alp-core",
3
- "version": "0.0.1-beta.3",
3
+ "version": "0.0.1-beta.4",
4
4
  "type": "module",
5
5
  "description": "ALP core module",
6
6
  "files": [
@@ -284,7 +284,7 @@
284
284
  "node": ">=22.12"
285
285
  },
286
286
  "dependencies": {
287
- "@anhlp/alp-protocol": "0.0.1-beta.3"
287
+ "@anhlp/alp-protocol": "0.0.1-beta.4"
288
288
  },
289
289
  "scripts": {
290
290
  "build": "node ../../scripts/build-workspace.mjs core",
package/src/backup.js CHANGED
@@ -34,7 +34,12 @@ export const BACKUP_FORMAT = 1;
34
34
  export const BACKUP_EXTENSION = '.alpbackup';
35
35
 
36
36
  /** alpd's state: sessions, their timelines and receipts, and the runtime's durable state. */
37
- export const STATE_UNITS = ['state/schema.json', 'state/sessions', 'state/timeline', 'state/receipts', 'state/recall.json', 'state/pause.json', 'state/trust.json', 'state/web-workspaces.json'];
37
+ const RESULT_LEDGER = 'state/assignment-results.json';
38
+ const RESULT_MARKER = `${RESULT_LEDGER}.initialized`;
39
+ const DESTINATIONS = 'worktree-destinations.json';
40
+ // Evidence only: the daemon never loads mapping authority from this path.
41
+ const QUARANTINED_DESTINATIONS = 'state/worktree-destinations.quarantined.json';
42
+ export const STATE_UNITS = [RESULT_LEDGER, RESULT_MARKER, 'state/schema.json', 'state/sessions', 'state/timeline', 'state/receipts', 'state/recall.json', 'state/pause.json', 'state/trust.json', 'state/web-workspaces.json'];
38
43
  /**
39
44
  * Live work (running assignments alpd continues after a restart) and the journal of an
40
45
  * unfinished migration are never backed up: a restore of the state removes them, so restored
@@ -43,7 +48,7 @@ export const STATE_UNITS = ['state/schema.json', 'state/sessions', 'state/timeli
43
48
  export const CLEARED_UNITS = ['state/live.json', 'state/migration.json'];
44
49
  /** Everything a backup of $ALP_HOME holds: the state, the user's settings and library, boards and assignment logs. */
45
50
  export const HOME_UNITS = [
46
- ...STATE_UNITS,
51
+ ...STATE_UNITS, DESTINATIONS,
47
52
  'settings.json', 'library.json', 'role-skills.json', 'lessons.md',
48
53
  'agents', 'skills', 'teams', 'hooks', 'mcp', 'providers', 'formulas',
49
54
  'boards', 'runs',
@@ -396,6 +401,94 @@ async function holdTaskLocks(roots, work) {
396
401
  finally { clearInterval(touch); }
397
402
  }
398
403
 
404
+ /**
405
+ * Restore ownership evidence, never checkout birth certification or host authority.
406
+ * Paths in an archive cannot establish the identity of an old or moved checkout. Keep
407
+ * every retained owner (including work after the snapshot), but require audited adoption
408
+ * for old targets. The host must explicitly approve mappings again; archived bytes are
409
+ * kept privately as evidence only. All replacements join the ordinary rollback journal.
410
+ */
411
+ async function prepareWorkspaceRestore(home, root, units, warnings) {
412
+ const relevant = [RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, 'state/sessions', 'state/web-workspaces.json'];
413
+ // Only the transform may stage quarantine; archive-declared copies are never activated.
414
+ const restorable = units.filter(unit => unit.path !== QUARANTINED_DESTINATIONS);
415
+ if (!units.some(unit => relevant.includes(unit.path))) return restorable;
416
+ for (const base of [home, root]) {
417
+ const info = await lstat(path.join(base, 'state')).catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
418
+ if (info && !info.isDirectory()) throw invalid('workspace state must be a real directory');
419
+ }
420
+ const result = restorable.filter(unit => ![RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, 'state/web-workspaces.json'].includes(unit.path));
421
+ async function regular(base, unit, declared) {
422
+ if (declared && declared.kind === 'absent') return undefined;
423
+ const file = path.join(base, unit);
424
+ const info = await lstat(file).catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
425
+ if (!info) {
426
+ if (declared) throw invalid(`${unit} is declared but missing from the backup`);
427
+ return undefined;
428
+ }
429
+ if (!info.isFile()) throw invalid(`${unit} must be a regular file for a workspace restore`);
430
+ return readFile(file, 'utf8');
431
+ }
432
+ const incoming = async unit => {
433
+ const declared = units.find(entry => entry.path === unit);
434
+ return declared ? regular(root, unit, declared) : undefined;
435
+ };
436
+ const owners = new Map();
437
+ let hasLedger = false;
438
+ for (const text of [await incoming(RESULT_LEDGER), await regular(home, RESULT_LEDGER)]) {
439
+ if (text === undefined) continue;
440
+ let value;
441
+ try { value = JSON.parse(text); } catch { throw invalid('assignment result ledger is not readable JSON; repair it before restoring'); }
442
+ const absolute = value => typeof value === 'string' && path.isAbsolute(value);
443
+ if (value?.version !== 1 || !Array.isArray(value.workspaces) || !value.workspaces.every(absolute) ||
444
+ !Array.isArray(value.results) || !value.results.every(entry => entry && typeof entry.assignmentId === 'string' &&
445
+ absolute(entry.checkout) && (entry.worktree === undefined || absolute(entry.worktree))))
446
+ throw invalid('assignment result ledger has an unsupported or invalid shape');
447
+ hasLedger = true;
448
+ for (const entry of value.results) {
449
+ const owner = { assignmentId: entry.assignmentId, checkout: entry.checkout, ...(entry.worktree === undefined ? {} : { worktree: entry.worktree }) };
450
+ const previous = owners.get(owner.assignmentId);
451
+ if (previous && JSON.stringify(previous) !== JSON.stringify(owner)) throw invalid('assignment result ownership conflicts with current data; reconcile it before restoring');
452
+ owners.set(owner.assignmentId, owner);
453
+ }
454
+ }
455
+ const markers = [await incoming(RESULT_MARKER), await regular(home, RESULT_MARKER)];
456
+ if (markers.some(text => text !== undefined && text !== '1\n')) throw invalid('assignment result initialized marker is invalid');
457
+ await mkdir(path.join(root, 'state'), { recursive: true, mode: 0o700 });
458
+ if (hasLedger) await writeAtomic(path.join(root, RESULT_LEDGER), JSON.stringify({ version: 1, workspaces: [], results: [...owners.values()] }) + '\n');
459
+ result.push({ path: RESULT_LEDGER, kind: hasLedger ? 'file' : 'absent' });
460
+ const initialized = markers.some(text => text !== undefined);
461
+ if (initialized) await writeAtomic(path.join(root, RESULT_MARKER), '1\n');
462
+ result.push({ path: RESULT_MARKER, kind: initialized ? 'file' : 'absent' });
463
+ const mappings = await incoming(DESTINATIONS);
464
+ if (mappings !== undefined) {
465
+ await writeAtomic(path.join(root, QUARANTINED_DESTINATIONS), mappings);
466
+ result.push({ path: QUARANTINED_DESTINATIONS, kind: 'file' });
467
+ }
468
+ result.push({ path: DESTINATIONS, kind: 'absent' });
469
+ const registryUnit = 'state/web-workspaces.json';
470
+ const registryDeclaration = units.find(unit => unit.path === registryUnit);
471
+ const registryText = registryDeclaration ? await incoming(registryUnit) : await regular(home, registryUnit);
472
+ if (registryText !== undefined) {
473
+ let registry;
474
+ try { registry = JSON.parse(registryText); } catch { throw invalid('workspace registry is not readable JSON; repair it before restoring'); }
475
+ // Reapproval of the same mapping ID must not reactivate historical token grants.
476
+ // Rotate epochs in rows, pending native intents and cached receipt results alike.
477
+ const epoch = `restore-${randomBytes(16).toString('hex')}`;
478
+ let revoked = false;
479
+ const revoke = value => {
480
+ if (!value || typeof value !== 'object') return;
481
+ if (Object.hasOwn(value, 'grantEpoch')) { value.grantEpoch = epoch; revoked = true; }
482
+ for (const child of Object.values(value)) revoke(child);
483
+ };
484
+ revoke(registry);
485
+ await writeAtomic(path.join(root, registryUnit), revoked ? JSON.stringify(registry) + '\n' : registryText);
486
+ result.push({ path: registryUnit, kind: 'file' });
487
+ } else if (registryDeclaration) result.push({ path: registryUnit, kind: 'absent' });
488
+ if (hasLedger || initialized || mappings !== undefined || await exists(path.join(home, DESTINATIONS))) warnings.push('Workspace restore retains assignment owners but clears checkout birth certification; old targets require an audited migration. Worktree destination mappings are inactive and require explicit host approval; archived mapping evidence is in state/worktree-destinations.quarantined.json. Historical checkout grants remain revoked after reapproval.');
489
+ return result;
490
+ }
491
+
399
492
  /**
400
493
  * Restores a backup over `home` and, with `projects`, over the projects it holds whose
401
494
  * roots exist. The current data is checked first: a home in a state schema newer than
@@ -419,6 +512,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
419
512
  const warnings = [];
420
513
  const areaBase = area => area === 'home' ? home : header.projects[Number(area.slice(8))].root;
421
514
  const directories = [];
515
+ let homeUnits;
422
516
  // The journal exists from the first staged file on, so a crash at any point leaves nothing behind.
423
517
  const journal = { version: 1, id, file: path.resolve(file), by, startedAt: new Date().toISOString(), phase: 'staging', units: [], staging: [], created: [], locks: [] };
424
518
  const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + '\n');
@@ -452,6 +546,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
452
546
  };
453
547
  },
454
548
  });
549
+ homeUnits = await prepareWorkspaceRestore(home, staging.get('home'), header.units, warnings);
455
550
  // Directory modes last, deepest first, once nothing more is written into them.
456
551
  for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 0o777 | 0o700).catch(() => {});
457
552
  } catch (error) {
@@ -472,8 +567,8 @@ export async function restoreBackup({ home, file, projects = true, safety = true
472
567
  const plan = [];
473
568
  for (const [area, root] of staging) {
474
569
  const base = areaBase(area);
475
- const units = area === 'home' ? header.units : header.projects[Number(area.slice(8))].units;
476
- const known = area === 'home' ? HOME_UNITS : PROJECT_UNITS;
570
+ const units = area === 'home' ? homeUnits : header.projects[Number(area.slice(8))].units;
571
+ const known = area === 'home' ? [...HOME_UNITS, QUARANTINED_DESTINATIONS] : PROJECT_UNITS;
477
572
  for (const unit of units) {
478
573
  if (!known.includes(unit.path)) { warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`); continue; }
479
574
  const target = path.join(base, ...unit.path.split('/'));
@@ -497,7 +592,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
497
592
  let safetyFile;
498
593
  if (safety) {
499
594
  try {
500
- safetyFile = await automaticBackup(home, 'pre-restore', { units: header.units.map(unit => unit.path).filter(unit => HOME_UNITS.includes(unit)), projects: roots, by });
595
+ safetyFile = await automaticBackup(home, 'pre-restore', { units: [...new Set([...header.units.map(unit => unit.path), ...homeUnits.map(unit => unit.path)])].filter(unit => HOME_UNITS.includes(unit) || unit === QUARANTINED_DESTINATIONS), projects: roots, by });
501
596
  } catch (error) {
502
597
  await abandon();
503
598
  throw new AlpError('RESTORE_SAFETY_BACKUP_FAILED', `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
package/src/index.js CHANGED
@@ -29,3 +29,4 @@ export * as user_settings from './user-settings.js';
29
29
  export * as validation from './validation.js';
30
30
  export * as verify from './verify.js';
31
31
  export * as workflow from './workflow.js';
32
+ export * as user_worktree from './user-worktree.js';
package/src/migrations.js CHANGED
@@ -98,7 +98,7 @@ export async function migrateState(home, { by = 'alp', migrations = MIGRATIONS,
98
98
  throw new AlpError('STATE_MIGRATION_ROLLBACK_FAILED', `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
99
99
  }
100
100
  await rm(migrationFile(home), { force: true });
101
- throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error}; the state was put back from ${backup} and is unchanged. See docs/alp/data-recovery.md`, { cause: error });
101
+ throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error}; data was restored from ${backup} with workspace authority checks. See docs/alp/data-recovery.md`, { cause: error });
102
102
  }
103
103
  await writeStateSchema(home, step.to, by);
104
104
  await rm(migrationFile(home), { force: true });
@@ -0,0 +1,259 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lstat, realpath, open } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { constants } from 'node:fs';
5
+
6
+ /** Bounded, no-follow administrative reads; no Git process is needed to re-check a grant. */
7
+ async function control(file) {
8
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
9
+ try {
10
+ const info = await handle.stat();
11
+ if (!info.isFile() || info.nlink !== 1 || info.size > 4096) throw new Error('Invalid Git administrative file');
12
+ const bytes = Buffer.alloc(4097);
13
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
14
+ if (bytesRead > 4096) throw new Error('Git administrative file exceeds limit');
15
+ return bytes
16
+ .subarray(0, bytesRead)
17
+ .toString('utf8')
18
+ .replace(/\r?\n$/, '');
19
+ } finally {
20
+ await handle.close();
21
+ }
22
+ }
23
+ export async function repositoryIdentity(directory) {
24
+ if ((await realpath(directory)) !== directory) throw new Error('Repository root is not canonical');
25
+ const entry = path.join(directory, '.git');
26
+ const info = await lstat(entry);
27
+ let gitDir;
28
+ if (info.isDirectory()) gitDir = await realpath(entry);
29
+ else {
30
+ const value = await control(entry);
31
+ if (!value.startsWith('gitdir: ') || /[\r\n\0]/.test(value)) throw new Error('Invalid Git control link');
32
+ gitDir = await realpath(path.resolve(directory, value.slice(8)));
33
+ }
34
+ const commonEntry = path.join(gitDir, 'commondir');
35
+ const hasCommon = await lstat(commonEntry).catch((error) => {
36
+ if (error.code === 'ENOENT') return undefined;
37
+ throw error;
38
+ });
39
+ const commonDir = hasCommon ? await realpath(path.resolve(gitDir, await control(commonEntry))) : gitDir;
40
+ if (gitDir !== commonDir) {
41
+ if (
42
+ path.dirname(gitDir) !== path.join(commonDir, 'worktrees') ||
43
+ path.resolve(gitDir, await control(path.join(gitDir, 'gitdir'))) !== entry
44
+ )
45
+ throw new Error('Git worktree backlink changed');
46
+ }
47
+ const head = await control(path.join(gitDir, 'HEAD'));
48
+ const branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : null;
49
+ if (branch !== null && (!branch || /[\r\n\0]/.test(branch))) throw new Error('Invalid branch reference');
50
+ return { gitDir, commonDir, branch };
51
+ }
52
+
53
+ /** User workspace primitives. Assignment cleanup's force/rm semantics must never be used here. */
54
+ export function userWorktreeGit({ executable = '/usr/bin/git', timeoutMs = 15_000, maxBytes = 2 * 1024 * 1024 } = {}) {
55
+ if (process.platform === 'win32') throw new Error('User worktree operations are unavailable on this host');
56
+ if (!path.isAbsolute(executable)) throw new Error('Git executable must be host configured and absolute');
57
+ const disabled = [
58
+ '-c',
59
+ 'core.hooksPath=/dev/null',
60
+ '-c',
61
+ 'core.fsmonitor=false',
62
+ '-c',
63
+ 'core.untrackedCache=false',
64
+ '-c',
65
+ 'core.pager=cat',
66
+ '-c',
67
+ 'maintenance.auto=false',
68
+ '-c',
69
+ 'gc.auto=0',
70
+ '-c',
71
+ 'protocol.allow=never',
72
+ '-c',
73
+ 'submodule.recurse=false'
74
+ ];
75
+ // No inherited Git context, PATH or user/system config. Repository config is data, never a program.
76
+ const env = {
77
+ PATH: '/usr/bin:/bin',
78
+ GIT_CONFIG_NOSYSTEM: '1',
79
+ GIT_CONFIG_GLOBAL: '/dev/null',
80
+ GIT_TERMINAL_PROMPT: '0',
81
+ GIT_OPTIONAL_LOCKS: '0',
82
+ LANG: 'C',
83
+ LC_ALL: 'C'
84
+ };
85
+ async function raw(cwd, args, extra = []) {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn(executable, [...disabled, ...extra, ...args], {
88
+ cwd,
89
+ env,
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ windowsHide: true,
92
+ detached: true
93
+ });
94
+ let size = 0;
95
+ let overflow = false;
96
+ const stdout = [];
97
+ const stderr = [];
98
+ const kill = () => {
99
+ try {
100
+ process.kill(-child.pid, 'SIGKILL');
101
+ } catch {
102
+ child.kill('SIGKILL');
103
+ }
104
+ };
105
+ const timer = setTimeout(() => {
106
+ overflow = true;
107
+ kill();
108
+ }, timeoutMs);
109
+ const collect = (sink) => (chunk) => {
110
+ size += chunk.length;
111
+ if (size > maxBytes) {
112
+ overflow = true;
113
+ kill();
114
+ } else sink.push(chunk);
115
+ };
116
+ child.stdout.on('data', collect(stdout));
117
+ child.stderr.on('data', collect(stderr));
118
+ child.once('error', (error) => {
119
+ clearTimeout(timer);
120
+ reject(error);
121
+ });
122
+ child.once('close', (code) => {
123
+ clearTimeout(timer);
124
+ if (overflow) reject(new Error('Git operation exceeded its host limit'));
125
+ else
126
+ resolve({
127
+ code,
128
+ stdout: Buffer.concat(stdout).toString('utf8'),
129
+ stderr: Buffer.concat(stderr).toString('utf8')
130
+ });
131
+ });
132
+ });
133
+ }
134
+ async function filterOverrides(cwd) {
135
+ // Status/removal may run clean/process filters as well as creation's smudge filter.
136
+ const filters = await raw(cwd, [
137
+ 'config',
138
+ '--null',
139
+ '--name-only',
140
+ '--get-regexp',
141
+ '^filter\\..*\\.(smudge|clean|process|required)$'
142
+ ]);
143
+ if (filters.code !== 0 && filters.code !== 1) throw new Error('Cannot inspect repository filters');
144
+ const neutral = filters.stdout
145
+ .split('\0')
146
+ .filter(Boolean)
147
+ .flatMap((key) => ['-c', `${key}=${key.endsWith('.required') ? 'false' : ''}`]);
148
+ return neutral;
149
+ }
150
+ async function run(cwd, args, extra = []) {
151
+ return raw(cwd, args, [...(await filterOverrides(cwd)), ...extra]);
152
+ }
153
+ async function checked(cwd, args, extra = []) {
154
+ const result = await run(cwd, args, extra);
155
+ if (result.code !== 0) throw new Error('Git workspace operation refused');
156
+ return result.stdout.trim();
157
+ }
158
+ async function directory(value) {
159
+ const canonical = await realpath(value);
160
+ if (canonical !== value || !(await lstat(value)).isDirectory())
161
+ throw new Error('Workspace paths must be canonical directories');
162
+ return canonical;
163
+ }
164
+ async function facts(source) {
165
+ await directory(source);
166
+ const top = await checked(source, ['rev-parse', '--show-toplevel']);
167
+ if ((await realpath(top)) !== source) throw new Error('Workspace must be a repository top level');
168
+ const commonDir = await realpath(
169
+ await checked(source, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
170
+ );
171
+ const identity = await repositoryIdentity(source);
172
+ if (identity.commonDir !== commonDir) throw new Error('Repository identity changed');
173
+ const head = await checked(source, ['rev-parse', '--verify', 'HEAD']);
174
+ const branch = await run(source, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
175
+ return { commonDir, gitDir: identity.gitDir, head, branch: branch.code === 0 ? branch.stdout.trim() : null };
176
+ }
177
+ async function list(source) {
178
+ const result = await checked(source, ['worktree', 'list', '--porcelain', '-z']);
179
+ return result
180
+ .split('\0\0')
181
+ .filter(Boolean)
182
+ .map((block) =>
183
+ Object.fromEntries(
184
+ block
185
+ .split('\0')
186
+ .filter(Boolean)
187
+ .map((line) => {
188
+ const i = line.indexOf(' ');
189
+ return i < 0 ? [line, true] : [line.slice(0, i), line.slice(i + 1)];
190
+ })
191
+ )
192
+ );
193
+ }
194
+ async function inspect(source, target, branch) {
195
+ const row = (await list(source)).find((row) => row.worktree === target);
196
+ if (!row) return undefined;
197
+ await directory(target);
198
+ if (row.branch !== `refs/heads/${branch}` || row.locked || row.prunable)
199
+ throw new Error('Managed worktree identity changed');
200
+ const origin = await facts(source);
201
+ const destination = await facts(target);
202
+ if (origin.commonDir !== destination.commonDir) throw new Error('Managed worktree repository changed');
203
+ return row;
204
+ }
205
+ async function assertClean(target) {
206
+ const flags = await checked(target, ['ls-files', '-v', '-z']);
207
+ if (flags.split('\0').some((row) => row && (row[0] === 'S' || /[a-z]/.test(row[0]))))
208
+ throw new Error('Worktree has hidden index state');
209
+ if ((await checked(target, ['ls-files', '--stage', '-z'])).split('\0').some((row) => row.startsWith('160000 ')))
210
+ throw new Error('Worktree has submodules');
211
+ // Ignored data is user data too. Never force a delete or fall back to recursive rm.
212
+ if ((await checked(target, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored'])).length)
213
+ throw new Error('Worktree contains uncommitted or ignored files');
214
+ }
215
+ return {
216
+ facts,
217
+ inspect,
218
+ assertClean,
219
+ async checkoutFacts(directory) {
220
+ const canonical = await realpath(directory);
221
+ const repoRoot = await checked(canonical, ['rev-parse', '--show-toplevel']);
222
+ const branch = await run(canonical, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
223
+ const remote = await run(canonical, ['config', '--get', 'remote.origin.url']);
224
+ const status = await checked(canonical, ['status', '--porcelain=v1', '--untracked-files=all']);
225
+ return {
226
+ isGit: /** @type {const} */ (true),
227
+ repoRoot,
228
+ currentBranch: branch.code === 0 ? branch.stdout.trim() : null,
229
+ remoteUrl: remote.code === 0 ? remote.stdout.trim() || null : null,
230
+ isDirty: !!status
231
+ };
232
+ },
233
+ async create(source, parent, name, branch, head) {
234
+ await directory(parent);
235
+ if (!/^wks_[0-9a-f]{16}$/.test(name) || branch !== `alp/workspace/${name}` || !/^[0-9a-f]{40,64}$/.test(head))
236
+ throw new Error('Invalid generated worktree identity');
237
+ const target = path.join(parent, name);
238
+ if (
239
+ await lstat(target).catch((error) => {
240
+ if (error.code === 'ENOENT') return undefined;
241
+ throw error;
242
+ })
243
+ )
244
+ throw new Error('Worktree destination already exists');
245
+ await checked(source, ['worktree', 'add', '--quiet', '--no-checkout', '-b', branch, target, head]);
246
+ // Conditional gitdir/onbranch config is resolved in the new checkout before materialization.
247
+ await checked(target, ['reset', '--hard', '--no-recurse-submodules', head]);
248
+ if (!(await inspect(source, target, branch))) throw new Error('Worktree creation needs reconciliation');
249
+ return target;
250
+ },
251
+ async remove(source, target, branch) {
252
+ if (!(await inspect(source, target, branch))) throw new Error('Managed worktree is missing');
253
+ await assertClean(target);
254
+ await checked(source, ['worktree', 'remove', target], await filterOverrides(target));
255
+ if ((await list(source)).some((row) => row.worktree === target))
256
+ throw new Error('Worktree removal needs reconciliation');
257
+ }
258
+ };
259
+ }
package/src/validation.js CHANGED
@@ -167,8 +167,10 @@ export function validateUserSettings(settings, source) {
167
167
  }
168
168
  if (settings.web !== undefined) {
169
169
  check(object(settings.web), 'web must be an object');
170
- const extra = Object.keys(settings.web).find(field => !['enabled', 'port', 'relay'].includes(field));
171
- check(!extra, `unsupported web field '${extra}'; use enabled, port or relay`);
170
+ const extra = Object.keys(settings.web).find(field => !['enabled', 'port', 'relay', 'permissions'].includes(field));
171
+ check(!extra, `unsupported web field '${extra}'; use enabled, port, relay or permissions`);
172
+ // Gateway permissions `alp web` adds to its default grant; alp-web checks each name against the gateway's list.
173
+ if (settings.web.permissions !== undefined) check(Array.isArray(settings.web.permissions) && settings.web.permissions.every(entry => typeof entry === 'string' && /^[a-z][a-z.]*$/.test(entry)), 'web.permissions must be a list of permission names, such as files.read');
172
174
  // The relay that lets Paseo's app reach alpd from another device (ALPD §64).
173
175
  const relay = settings.web.relay;
174
176
  if (relay !== undefined) {