agentlas 1.0.66 → 1.0.67

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.
@@ -13,21 +13,112 @@
13
13
  const { userDataDir } = require("../core/paths.cjs");
14
14
  const { materializeTrustedSystemMcpServer } = require("../mcp/inventory.cjs");
15
15
  const { probeSystemMcpServerConnection } = require("../mcp/probe.cjs");
16
+ const {
17
+ DEFAULT_OPTIONS,
18
+ list: outputList,
19
+ render,
20
+ single,
21
+ parseOutputFlags,
22
+ displayWidth,
23
+ terminalTextOf,
24
+ } = require("../cli-output.cjs");
25
+
26
+ const OUTPUT_FLAGS = new Set(["--json", "--yaml", "--quiet", "-q", "--no-headers", "--no-color"]);
27
+
28
+ function commandError(message, code = "INVALID_ARGUMENT", details) {
29
+ const error = new Error(message);
30
+ error.code = code;
31
+ if (details !== undefined) error.details = details;
32
+ return error;
33
+ }
34
+
35
+ function withOutputFlags(ctx, args) {
36
+ if (!args.some((arg) => OUTPUT_FLAGS.has(arg))) return { ctx, args };
37
+ const parsed = parseOutputFlags(args);
38
+ return {
39
+ ctx: { ...ctx, output: { ...(ctx.output || DEFAULT_OPTIONS), ...parsed.options } },
40
+ args: parsed.rest,
41
+ };
42
+ }
43
+
44
+ function emit(ctx, result) {
45
+ if (typeof ctx.emit === "function") {
46
+ ctx.emit(result);
47
+ return;
48
+ }
49
+ ctx.out(render(result, ctx.output || DEFAULT_OPTIONS));
50
+ }
51
+
52
+ function ansi(options, code, value) {
53
+ const text = String(value);
54
+ return options?.noColor ? text : `\u001b[${code}m${text}\u001b[0m`;
55
+ }
56
+
57
+ function isMachineOutput(ctx) {
58
+ const output = ctx.output || DEFAULT_OPTIONS;
59
+ return output.quiet || output.format === "json" || output.format === "yaml";
60
+ }
61
+
62
+ function mcpListSchema(en) {
63
+ return Object.freeze({
64
+ idField: "id",
65
+ columns: [
66
+ { header: "id", field: "id" },
67
+ { header: en ? "name" : "이름", field: "name" },
68
+ ],
69
+ renderHuman(result, options = {}) {
70
+ const rows = Array.isArray(result.data) ? result.data : [];
71
+ if (!rows.length) {
72
+ return options.noHeaders ? "" : (en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.");
73
+ }
74
+ const lines = [];
75
+ if (!options.noHeaders) lines.push(ansi(options, 1, en ? "MCP servers" : "MCP 서버"));
76
+ for (const row of rows) {
77
+ const id = terminalTextOf(row.id, 256);
78
+ const name = terminalTextOf(row.name, 4096);
79
+ const padding = " ".repeat(Math.max(1, 28 - displayWidth(id)));
80
+ lines.push(` ${ansi(options, 36, id)}${padding}${name}`);
81
+ }
82
+ return lines.join("\n");
83
+ },
84
+ });
85
+ }
86
+
87
+ function mcpProbeSchema(en) {
88
+ return Object.freeze({
89
+ idField: "serverId",
90
+ columns: [
91
+ { header: en ? "server" : "서버", field: "serverId" },
92
+ { header: en ? "status" : "상태", field: "status" },
93
+ { header: en ? "tools" : "툴", field: "toolCount" },
94
+ ],
95
+ renderHuman(result, options = {}) {
96
+ const row = result.data || {};
97
+ const serverId = terminalTextOf(row.serverId, 256);
98
+ const toolCount = Number.isSafeInteger(row.toolCount) ? row.toolCount : 0;
99
+ const status = row.connected ? ansi(options, 32, en ? "connected" : "연결됨") : ansi(options, 31, en ? "failed" : "실패");
100
+ const toolLabel = en ? `${toolCount} tool(s) listed` : `툴 ${toolCount}개 확인`;
101
+ const note = en
102
+ ? "Preflight only: connection readiness does not imply tool-call success."
103
+ : "프리플라이트일 뿐입니다: 연결 준비됨 ≠ 툴 호출 성공.";
104
+ return [
105
+ `${status} ${ansi(options, 1, serverId)} · ${toolLabel}`,
106
+ ansi(options, 2, note),
107
+ ].join("\n");
108
+ },
109
+ });
110
+ }
16
111
 
17
112
  function list(ctx) {
18
113
  const en = ctx.lang === "en";
19
114
  const db = ctx.db();
20
- if (!ctx.tableExists(db, "mcp_servers")) {
21
- ctx.out(en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.");
22
- return 0;
23
- }
24
- const rows = db.prepare("SELECT id, name FROM mcp_servers ORDER BY name").all();
25
- if (!rows.length) {
26
- ctx.out(en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.");
27
- return 0;
28
- }
29
- ctx.out(ctx.ui.bold(en ? "MCP servers" : "MCP 서버"));
30
- for (const r of rows) ctx.out(` ${ctx.ui.accent(String(r.id).padEnd(28))} ${r.name || ""}`);
115
+ const rows = ctx.tableExists(db, "mcp_servers")
116
+ ? db.prepare("SELECT id, name FROM mcp_servers ORDER BY name").all().map((row) => ({
117
+ id: String(row.id || ""),
118
+ name: String(row.name || ""),
119
+ }))
120
+ : [];
121
+ emit(ctx, outputList(rows, mcpListSchema(en)));
31
122
  return 0;
32
123
  }
33
124
 
@@ -35,13 +126,11 @@ async function probe(ctx, args) {
35
126
  const en = ctx.lang === "en";
36
127
  const ref = String(args[0] || "").trim();
37
128
  if (!ref || args.length !== 1 || ref.startsWith("-")) {
38
- ctx.err(en ? "Usage: agentlas mcp probe <server-id|catalog-id>" : "사용법: agentlas mcp probe <server-id|catalog-id>");
39
- return 1;
129
+ throw commandError(en ? "Usage: agentlas mcp probe <server-id|catalog-id>" : "사용법: agentlas mcp probe <server-id|catalog-id>");
40
130
  }
41
131
  const db = ctx.db();
42
132
  if (!ctx.tableExists(db, "mcp_servers")) {
43
- ctx.err(en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.");
44
- return 1;
133
+ throw commandError(en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.", "MCP_SERVER_NOT_FOUND");
45
134
  }
46
135
  // catalog_id는 데스크탑 스키마 열 — 오래된 DB에는 없을 수 있어 방어적으로 조회.
47
136
  const byCatalog = ctx.columnExists(db, "mcp_servers", "catalog_id");
@@ -49,35 +138,55 @@ async function probe(ctx, args) {
49
138
  ? db.prepare("SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? OR catalog_id=? LIMIT 1").get(ref, ref)
50
139
  : db.prepare("SELECT id, NULL AS catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1").get(ref);
51
140
  if (!row) {
52
- ctx.err(en ? `MCP server not found: ${ref}` : `MCP 서버를 찾을 수 없습니다: ${ref}`);
53
- return 1;
141
+ throw commandError(
142
+ en ? `MCP server not found: ${ref}` : `MCP 서버를 찾을 수 없습니다: ${ref}`,
143
+ "MCP_SERVER_NOT_FOUND",
144
+ { serverId: ref },
145
+ );
54
146
  }
55
147
  const server = materializeTrustedSystemMcpServer(row, { userDataDir: userDataDir() });
56
148
  if (!server) {
57
149
  // 이유를 지어내지 않는다 — materialize는 비활성/비-stdio/안전하지 않은
58
150
  // 정의를 하나의 fail-closed로 접기 때문에 관찰 가능한 사실만 알린다.
59
- ctx.err(en
60
- ? `'${ref}' is not probe-eligible (disabled, non-stdio transport, or an unsafe runtime definition).`
61
- : `'${ref}' 은(는) probe 대상이 아닙니다 (비활성, stdio 아닌 transport, 또는 안전하지 않은 실행 정의).`);
62
- return 1;
151
+ throw commandError(
152
+ en
153
+ ? `'${ref}' is not probe-eligible (disabled, non-stdio transport, or an unsafe runtime definition).`
154
+ : `'${ref}' 은(는) probe 대상이 아닙니다 (비활성, stdio가 아닌 transport, 또는 안전하지 않은 실행 정의).`,
155
+ "MCP_PROBE_INELIGIBLE",
156
+ { serverId: ref },
157
+ );
158
+ }
159
+ if (!isMachineOutput(ctx)) {
160
+ const options = ctx.output || DEFAULT_OPTIONS;
161
+ ctx.out(ansi(options, 2, en
162
+ ? `Probing ${server.catalog_id} (isolated child env, handshake only)…`
163
+ : `${server.catalog_id} 연결 확인 중 (격리 자식 env, 핸드셰이크만)…`));
63
164
  }
64
- ctx.out(ctx.ui.dim(en
65
- ? `Probing ${server.catalog_id} (isolated child env, handshake only)…`
66
- : `${server.catalog_id} 연결 확인 중 (격리 자식 env, 핸드셰이크만)…`));
67
165
  const result = await probeSystemMcpServerConnection(server, { userDataDir: userDataDir(), cwd: process.cwd() });
68
- if (result.connected) {
69
- const toolCount = Array.isArray(result.tools) ? result.tools.length : 0;
70
- ctx.out(`${ctx.ui.green("connected")} ${ctx.ui.bold(server.catalog_id)} · ${en ? `${toolCount} tool(s) listed` : `툴 ${toolCount}개 확인`}`);
71
- ctx.out(ctx.ui.dim(en
72
- ? "Preflight only: connection readiness does not imply tool-call success."
73
- : "프리플라이트일 뿐입니다: 연결 준비됨 ≠ 툴 호출 성공."));
74
- return 0;
166
+ if (!result.connected) {
167
+ throw commandError(
168
+ en
169
+ ? `MCP probe failed: ${server.catalog_id} · ${result.reason || "connection_failed"}`
170
+ : `MCP 연결 확인 실패: ${server.catalog_id} · ${result.reason || "connection_failed"}`,
171
+ "MCP_PROBE_FAILED",
172
+ { serverId: server.catalog_id, reason: result.reason || "connection_failed" },
173
+ );
75
174
  }
76
- ctx.err(`${ctx.ui.red("failed")} ${ctx.ui.bold(server.catalog_id)} · ${result.reason}`);
77
- return 1;
175
+ const toolCount = Array.isArray(result.tools) ? result.tools.length : 0;
176
+ emit(ctx, single({
177
+ serverId: server.catalog_id,
178
+ status: "connected",
179
+ connected: true,
180
+ reason: result.reason || "connected",
181
+ toolCount,
182
+ }, mcpProbeSchema(en)));
183
+ return 0;
78
184
  }
79
185
 
80
186
  function run(ctx, args = []) {
187
+ const normalized = withOutputFlags(ctx, args);
188
+ ctx = normalized.ctx;
189
+ args = normalized.args;
81
190
  const [sub, ...rest] = args;
82
191
  if (!sub) return list(ctx);
83
192
  // 무인자 기본 동작이 목록인데 이름으로 부르면 거부되는 비대칭이 있었다
@@ -85,17 +194,15 @@ function run(ctx, args = []) {
85
194
  // "list를 붙이는" 습관이 여기서만 usage 오류가 됐다.
86
195
  if (sub === "list" || sub === "ls") {
87
196
  if (rest.length) {
88
- ctx.err(ctx.lang === "en" ? "Usage: agentlas mcp list" : "사용법: agentlas mcp list");
89
- return 1;
197
+ throw commandError(ctx.lang === "en" ? "Usage: agentlas mcp list" : "사용법: agentlas mcp list");
90
198
  }
91
199
  return list(ctx);
92
200
  }
93
201
  if (sub === "probe") return probe(ctx, rest);
94
202
  const en = ctx.lang === "en";
95
- ctx.err(en
203
+ throw commandError(en
96
204
  ? `unknown mcp subcommand: ${sub} (available: list · probe)`
97
205
  : `알 수 없는 mcp 하위 명령: ${sub} (사용 가능: list · probe)`);
98
- return 1;
99
206
  }
100
207
 
101
208
  module.exports = { run };
@@ -192,6 +192,12 @@ function validateExperiencePack(value) {
192
192
  assertUniqueIds(value.evidenceReceiptIds, "experience pack.evidenceReceiptIds");
193
193
  if (!Array.isArray(value.mcpRequirements) || value.mcpRequirements.length > 64) throw new Error("experience pack.mcpRequirements is invalid");
194
194
  value.mcpRequirements.forEach((requirement, index) => validateMcpRequirement(requirement, `experience pack.mcpRequirements[${index}]`));
195
+ const requirementIds = value.mcpRequirements.map((requirement) => requirement.requirementId);
196
+ if (new Set(requirementIds).size !== requirementIds.length) {
197
+ const error = new Error("experience pack.mcpRequirements requirementId values must be unique");
198
+ error.code = "duplicate_mcp_requirement_id";
199
+ throw error;
200
+ }
195
201
  // base 패키지 복사 반입 금지 — 참조만 허용(오너 결정, 완화 불가).
196
202
  if (value.containsBasePackageMaterial !== false) throw new Error("experience pack must reference the base release; copied base material is forbidden");
197
203
  if (!HASH_RE.test(String(value.contentHash || ""))) throw new Error("experience pack.contentHash is invalid");
@@ -255,20 +255,79 @@ function cloudApplyPortableFileMode(filePath, mode, platform = process.platform)
255
255
  ) throw new Error(`cloud restore file mode verification failed: ${filePath}`);
256
256
  }
257
257
 
258
+ function cloudDirectoryAnchor(target, label, { allowMissing = true, containedBy = null } = {}) {
259
+ let stat;
260
+ try { stat = fs.lstatSync(target); } catch (error) {
261
+ if (allowMissing && error && error.code === "ENOENT") return null;
262
+ throw error;
263
+ }
264
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
265
+ throw new Error(`${label} is not a safe managed directory`);
266
+ }
267
+ let realpath;
268
+ try { realpath = fs.realpathSync.native(target); }
269
+ catch (error) { throw new Error(`${label} could not be canonicalized: ${error.message}`); }
270
+ if (containedBy && !(
271
+ realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
272
+ )) {
273
+ throw new Error(`${label} escapes its managed root`);
274
+ }
275
+ return { path: target, realpath, dev: stat.dev, ino: stat.ino, nlink: stat.nlink, stat };
276
+ }
277
+
278
+ function cloudAssertDirectoryAnchor(anchor, label, containedBy = null) {
279
+ const current = cloudDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
280
+ if (
281
+ current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
282
+ current.ino !== anchor.ino || current.nlink !== anchor.nlink
283
+ ) {
284
+ throw new Error(`${label} changed while it was being used`);
285
+ }
286
+ return current;
287
+ }
288
+
289
+ function cloudRefreshDirectoryAnchor(anchor, label, containedBy = null) {
290
+ const current = cloudDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
291
+ if (
292
+ current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
293
+ current.ino !== anchor.ino
294
+ ) {
295
+ throw new Error(`${label} changed while it was being used`);
296
+ }
297
+ return current;
298
+ }
299
+
300
+ function cloudSameRegularFile(left, right) {
301
+ return Boolean(
302
+ left && right && left.isFile() && right.isFile() &&
303
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
304
+ left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink,
305
+ );
306
+ }
307
+
258
308
  function cloudEnsurePrivateSubdirectory(root, directory) {
259
309
  const relative = path.relative(root, directory);
260
310
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
261
311
  throw new Error("cloud restore subdirectory escapes staging");
262
312
  }
263
313
  let current = root;
264
- cloudManagedDirectoryState(root, "cloud restore staging", { allowMissing: false });
314
+ const rootAnchor = cloudDirectoryAnchor(root, "cloud restore staging", { allowMissing: false });
315
+ const managedPaths = [root];
265
316
  for (const part of relative.split(path.sep).filter(Boolean)) {
266
317
  current = path.join(current, part);
267
318
  try { fs.mkdirSync(current, { recursive: false, mode: 0o700 }); }
268
319
  catch (error) { if (!error || error.code !== "EEXIST") throw error; }
269
- cloudManagedDirectoryState(current, "cloud restore package directory", { allowMissing: false });
270
320
  cloudApplyPrivateDirectoryMode(current);
321
+ managedPaths.push(current);
271
322
  }
323
+ // Directory nlink changes when a child directory is added, so take every
324
+ // anchor after the complete path is present rather than before a descendant
325
+ // mkdir can legitimately change an ancestor's nlink.
326
+ return managedPaths.map((managedPath, index) => cloudDirectoryAnchor(
327
+ managedPath,
328
+ index === 0 ? "cloud restore staging" : "cloud restore package directory",
329
+ { allowMissing: false, ...(index === 0 ? {} : { containedBy: rootAnchor }) },
330
+ ));
272
331
  }
273
332
 
274
333
  // ── 스테이징 스냅샷 전수 검증 (심링크/특수 엔트리/모드/무결성) ──
@@ -387,18 +446,43 @@ function cloudManagedDirectoryState(target, label, { allowMissing = true } = {})
387
446
  return stat;
388
447
  }
389
448
 
390
- function cloudRemoveManagedDirectory(target, label) {
391
- if (!cloudManagedDirectoryState(target, label)) return false;
449
+ function cloudRemoveManagedDirectory(target, label, { anchor = null, containedBy = null } = {}) {
450
+ if (anchor) {
451
+ let current;
452
+ try {
453
+ current = cloudDirectoryAnchor(target, label, { allowMissing: true, containedBy });
454
+ } catch (error) {
455
+ if (error && error.code === "ENOENT") return false;
456
+ throw error;
457
+ }
458
+ if (!current) return false;
459
+ if (
460
+ current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
461
+ current.ino !== anchor.ino
462
+ ) throw new Error(`${label} changed while it was being removed`);
463
+ } else if (containedBy) {
464
+ if (!cloudDirectoryAnchor(target, label, { allowMissing: true, containedBy })) return false;
465
+ } else if (!cloudManagedDirectoryState(target, label)) return false;
392
466
  fs.rmSync(target, { recursive: true, force: false });
393
467
  return true;
394
468
  }
395
469
 
396
- function cloudRenameManagedDirectory(source, destination, label) {
397
- cloudManagedDirectoryState(source, `${label} source`, { allowMissing: false });
470
+ function cloudRenameManagedDirectory(source, destination, label, { sourceAnchor = null, parentAnchor = null } = {}) {
471
+ if (parentAnchor) cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
472
+ if (sourceAnchor) {
473
+ cloudRefreshDirectoryAnchor(sourceAnchor, `${label} source`, parentAnchor);
474
+ } else {
475
+ cloudManagedDirectoryState(source, `${label} source`, { allowMissing: false });
476
+ }
398
477
  if (cloudManagedDirectoryState(destination, `${label} destination`)) {
399
478
  throw new Error(`${label} destination already exists`);
400
479
  }
480
+ if (parentAnchor) cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
401
481
  fs.renameSync(source, destination);
482
+ if (parentAnchor) {
483
+ cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
484
+ cloudDirectoryAnchor(destination, `${label} destination`, { allowMissing: false, containedBy: parentAnchor });
485
+ }
402
486
  }
403
487
 
404
488
  function cloudReadInstallJournal(journalPath) {
@@ -472,12 +556,36 @@ function cloudFsyncDirectory(directory) {
472
556
  finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ } }
473
557
  }
474
558
 
475
- function rollbackCloudInstallSwap({ destination, staging, backup, movedExisting, installed }) {
476
- if (installed) cloudRemoveManagedDirectory(destination, "cloud install destination");
559
+ function rollbackCloudInstallSwap({
560
+ destination,
561
+ staging,
562
+ backup,
563
+ movedExisting,
564
+ installed,
565
+ parentAnchor = null,
566
+ stagingAnchor = null,
567
+ backupAnchor = null,
568
+ destinationAnchor = null,
569
+ }) {
570
+ const safeParent = parentAnchor
571
+ ? cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent rollback")
572
+ : null;
573
+ if (installed) {
574
+ cloudRemoveManagedDirectory(destination, "cloud install destination", {
575
+ anchor: destinationAnchor,
576
+ containedBy: safeParent,
577
+ });
578
+ }
477
579
  if (movedExisting && cloudManagedDirectoryState(backup, "cloud install backup")) {
478
- cloudRenameManagedDirectory(backup, destination, "cloud install rollback");
580
+ cloudRenameManagedDirectory(backup, destination, "cloud install rollback", {
581
+ sourceAnchor: backupAnchor,
582
+ parentAnchor: safeParent,
583
+ });
479
584
  }
480
- cloudRemoveManagedDirectory(staging, "cloud install staging");
585
+ cloudRemoveManagedDirectory(staging, "cloud install staging", {
586
+ anchor: stagingAnchor,
587
+ containedBy: safeParent,
588
+ });
481
589
  cloudFsyncDirectory(path.dirname(destination));
482
590
  }
483
591
 
@@ -660,17 +768,33 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
660
768
  if (pathConflict) throw new Error(pathConflict.message);
661
769
  const layout = cloudInstallLayout(slug, { createParent: true });
662
770
  const { destination: dir, parent, journalPath: journal } = layout;
771
+ let parentAnchor = cloudDirectoryAnchor(parent, "cloud install parent", { allowMissing: false });
663
772
  const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
664
773
  const staging = path.join(parent, `.${path.basename(dir)}.installing-${nonce}`);
665
774
  const backup = path.join(parent, `.${path.basename(dir)}.backup-${nonce}`);
775
+ const managedAnchors = new Map();
666
776
  const seen = new Set();
667
777
  const verifiedFiles = [];
668
778
  let verifiedTotalBytes = 0;
669
779
  let movedExisting = false;
670
780
  let installed = false;
781
+ let stagingAnchor = null;
782
+ let destinationAnchor = null;
783
+ let backupAnchor = null;
784
+ let installedDestinationAnchor = null;
671
785
  try {
786
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
672
787
  fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
788
+ // Creating the staging directory legitimately changes the parent's nlink;
789
+ // refresh that field while retaining the original dev/ino/realpath anchor.
790
+ parentAnchor = cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent");
673
791
  cloudApplyPrivateDirectoryMode(staging);
792
+ stagingAnchor = cloudDirectoryAnchor(staging, "cloud install staging", {
793
+ allowMissing: false,
794
+ containedBy: parentAnchor,
795
+ });
796
+ managedAnchors.set(staging, stagingAnchor);
797
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
674
798
  for (const file of pkg.files) {
675
799
  const target = resolveCloudInstallPath(staging, file.path);
676
800
  const normalizedPath = path.relative(staging, target).split(path.sep).join("/");
@@ -700,19 +824,62 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
700
824
  });
701
825
  verifiedTotalBytes += bytes.length;
702
826
  if (verifiedTotalBytes > CLOUD_MAX_TOTAL_BYTES) throw new Error("cloud package exceeds total byte limit");
703
- cloudEnsurePrivateSubdirectory(staging, path.dirname(target));
827
+ for (const anchor of cloudEnsurePrivateSubdirectory(staging, path.dirname(target))) {
828
+ managedAnchors.set(anchor.path, anchor);
829
+ }
830
+ stagingAnchor = managedAnchors.get(staging);
831
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
832
+ for (const anchor of managedAnchors.values()) {
833
+ cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
834
+ }
704
835
  const mode = packageHashVersion === CLOUD_PACKAGE_HASH_V2 && file.executable ? 0o700 : 0o600;
705
- const fileFd = fs.openSync(
706
- target,
707
- fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
708
- mode,
709
- );
836
+ let fileFd;
837
+ let fileWritten = false;
710
838
  try {
839
+ fileFd = fs.openSync(
840
+ target,
841
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
842
+ mode,
843
+ );
844
+ // A parent swap can happen during open. Do not write until the
845
+ // directory anchors and the opened file identity still agree.
846
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
847
+ // O_CREAT may legitimately change a directory's nlink on some
848
+ // filesystems, so refresh that field after the open while retaining
849
+ // the dev/ino/realpath identity and containment checks.
850
+ stagingAnchor = cloudRefreshDirectoryAnchor(stagingAnchor, "cloud install staging", parentAnchor);
851
+ managedAnchors.set(staging, stagingAnchor);
852
+ for (const [anchorPath, anchor] of managedAnchors.entries()) {
853
+ if (anchorPath === staging) continue;
854
+ managedAnchors.set(anchorPath, cloudRefreshDirectoryAnchor(
855
+ anchor,
856
+ "cloud install managed directory",
857
+ stagingAnchor,
858
+ ));
859
+ }
860
+ const opened = fs.fstatSync(fileFd);
861
+ const listed = fs.lstatSync(target);
862
+ if (!cloudSameRegularFile(opened, listed) || opened.size !== 0) {
863
+ throw new Error(`cloud package target changed while opening: ${file.path}`);
864
+ }
711
865
  fs.writeFileSync(fileFd, bytes);
712
866
  if (process.platform !== "win32") fs.fchmodSync(fileFd, mode);
713
867
  fs.fsyncSync(fileFd);
868
+ fileWritten = true;
714
869
  } finally {
715
- fs.closeSync(fileFd);
870
+ if (fileFd !== undefined) {
871
+ if (!fileWritten) {
872
+ // O_EXCL creates a zero-byte file before the final anchor check
873
+ // can reject a swapped parent. Remove it only when the path still
874
+ // names the exact descriptor we opened; never unlink a successor.
875
+ try {
876
+ const opened = fs.fstatSync(fileFd);
877
+ const listed = fs.lstatSync(target);
878
+ if (cloudSameRegularFile(opened, listed)) fs.unlinkSync(target);
879
+ } catch { /* outer rollback retains any unknown successor */ }
880
+ }
881
+ fs.closeSync(fileFd);
882
+ }
716
883
  }
717
884
  }
718
885
  const expectedPackageHash = String(pkg.packageHash || "").toLowerCase().replace(/^sha256:/, "");
@@ -732,12 +899,33 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
732
899
  if (verifiedTotalBytes !== pkg.totalBytes) throw new Error("cloud package total byte count does not match its files");
733
900
  const restoredAt = new Date().toISOString();
734
901
  const markerPath = path.join(staging, CLOUD_RESTORE_MARKER_PATH);
735
- const markerFd = fs.openSync(
736
- markerPath,
737
- fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
738
- 0o600,
739
- );
902
+ stagingAnchor = cloudDirectoryAnchor(staging, "cloud install staging", {
903
+ allowMissing: false,
904
+ containedBy: parentAnchor,
905
+ });
906
+ managedAnchors.set(staging, stagingAnchor);
907
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
908
+ for (const anchor of managedAnchors.values()) {
909
+ cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
910
+ }
911
+ let markerFd;
912
+ let markerWritten = false;
740
913
  try {
914
+ markerFd = fs.openSync(
915
+ markerPath,
916
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
917
+ 0o600,
918
+ );
919
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
920
+ // As with package files, creating the marker can update the staging
921
+ // directory nlink; refresh it before writing and keep identity strict.
922
+ stagingAnchor = cloudRefreshDirectoryAnchor(stagingAnchor, "cloud install staging", parentAnchor);
923
+ managedAnchors.set(staging, stagingAnchor);
924
+ const opened = fs.fstatSync(markerFd);
925
+ const listed = fs.lstatSync(markerPath);
926
+ if (!cloudSameRegularFile(opened, listed) || opened.size !== 0) {
927
+ throw new Error("cloud restore marker changed while opening");
928
+ }
741
929
  fs.writeFileSync(markerFd, JSON.stringify({
742
930
  schemaVersion: 1,
743
931
  source: "agentlas-cloud",
@@ -761,8 +949,22 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
761
949
  }, null, 2) + "\n", "utf8");
762
950
  if (process.platform !== "win32") fs.fchmodSync(markerFd, 0o600);
763
951
  fs.fsyncSync(markerFd);
952
+ markerWritten = true;
764
953
  } finally {
765
- fs.closeSync(markerFd);
954
+ if (markerFd !== undefined) {
955
+ if (!markerWritten) {
956
+ try {
957
+ const opened = fs.fstatSync(markerFd);
958
+ const listed = fs.lstatSync(markerPath);
959
+ if (cloudSameRegularFile(opened, listed)) fs.unlinkSync(markerPath);
960
+ } catch { /* outer rollback retains any unknown successor */ }
961
+ }
962
+ fs.closeSync(markerFd);
963
+ }
964
+ }
965
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
966
+ for (const anchor of managedAnchors.values()) {
967
+ cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
766
968
  }
767
969
  cloudVerifyRestoredSnapshot(staging, verifiedFiles, {
768
970
  slug,
@@ -773,6 +975,7 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
773
975
  });
774
976
 
775
977
  if (options.deferCommit) {
978
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
776
979
  writeCloudInstallJournal(journal, {
777
980
  schemaVersion: 1,
778
981
  slug,
@@ -783,15 +986,42 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
783
986
  hadExisting: Boolean(cloudManagedDirectoryState(dir, "cloud install destination")),
784
987
  dbExpected: options.dbExpected || {},
785
988
  });
989
+ // The recovery journal is a file in the managed parent and may update
990
+ // that directory's nlink; refresh the field before publication checks.
991
+ parentAnchor = cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent");
786
992
  }
787
993
 
788
994
  // A Cloud agent is an immutable asset snapshot. Replace the managed install
789
995
  // as a whole so removed files and local mutations cannot leak across versions.
996
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
997
+ for (const anchor of managedAnchors.values()) {
998
+ cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
999
+ }
790
1000
  if (cloudManagedDirectoryState(dir, "cloud install destination")) {
791
- cloudRenameManagedDirectory(dir, backup, "cloud install snapshot swap");
1001
+ destinationAnchor = cloudDirectoryAnchor(dir, "cloud install destination", {
1002
+ allowMissing: false,
1003
+ containedBy: parentAnchor,
1004
+ });
1005
+ cloudRenameManagedDirectory(dir, backup, "cloud install snapshot swap", {
1006
+ sourceAnchor: destinationAnchor,
1007
+ parentAnchor,
1008
+ });
792
1009
  movedExisting = true;
1010
+ backupAnchor = cloudDirectoryAnchor(backup, "cloud install backup", {
1011
+ allowMissing: false,
1012
+ containedBy: parentAnchor,
1013
+ });
793
1014
  }
794
- cloudRenameManagedDirectory(staging, dir, "cloud install staging swap");
1015
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
1016
+ cloudRenameManagedDirectory(staging, dir, "cloud install staging swap", {
1017
+ sourceAnchor: stagingAnchor,
1018
+ parentAnchor,
1019
+ });
1020
+ cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
1021
+ installedDestinationAnchor = cloudDirectoryAnchor(dir, "cloud install destination", {
1022
+ allowMissing: false,
1023
+ containedBy: parentAnchor,
1024
+ });
795
1025
  cloudFsyncDirectory(parent);
796
1026
  installed = true;
797
1027
  if (options.deferCommit) {
@@ -807,12 +1037,34 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
807
1037
  });
808
1038
  }
809
1039
  } catch (error) {
810
- rollbackCloudInstallSwap({ destination: dir, staging, backup, movedExisting, installed });
1040
+ rollbackCloudInstallSwap({
1041
+ destination: dir,
1042
+ staging,
1043
+ backup,
1044
+ movedExisting,
1045
+ installed,
1046
+ parentAnchor,
1047
+ stagingAnchor,
1048
+ backupAnchor,
1049
+ destinationAnchor: installedDestinationAnchor,
1050
+ });
811
1051
  try { if (fs.existsSync(journal)) cloudUnlinkInstallJournal(journal); } catch { /* best-effort */ }
812
1052
  throw error;
813
1053
  } finally {
814
- try { cloudRemoveManagedDirectory(staging, "cloud install staging"); } catch { /* best-effort */ }
815
- try { if (!options.deferCommit && installed) cloudRemoveManagedDirectory(backup, "cloud install backup"); } catch { /* best-effort */ }
1054
+ try {
1055
+ cloudRemoveManagedDirectory(staging, "cloud install staging", {
1056
+ anchor: stagingAnchor,
1057
+ containedBy: parentAnchor,
1058
+ });
1059
+ } catch { /* best-effort */ }
1060
+ try {
1061
+ if (!options.deferCommit && installed) {
1062
+ cloudRemoveManagedDirectory(backup, "cloud install backup", {
1063
+ anchor: backupAnchor,
1064
+ containedBy: parentAnchor,
1065
+ });
1066
+ }
1067
+ } catch { /* best-effort */ }
816
1068
  }
817
1069
  if (!options.deferCommit) return dir;
818
1070
  let settled = false;