agentlas 1.0.64 → 1.0.66

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.
Files changed (164) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +8 -6
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/bootstrap-schema.sql +4 -4
  43. package/engine/browser/cdp.cjs +315 -32
  44. package/engine/browser/vault.cjs +1 -0
  45. package/engine/cli-output.cjs +80 -28
  46. package/engine/cloud/auth.cjs +131 -13
  47. package/engine/cloud/hub-client.cjs +41 -16
  48. package/engine/cloud-assets/cas.cjs +18 -9
  49. package/engine/cloud-assets/commands.cjs +108 -30
  50. package/engine/cloud-assets/package.cjs +66 -3
  51. package/engine/cloud-assets/restore.cjs +12 -9
  52. package/engine/cloud-assets/state.cjs +308 -113
  53. package/engine/commands/acp.cjs +13 -2
  54. package/engine/commands/automation.cjs +75 -24
  55. package/engine/commands/billing.cjs +6 -1
  56. package/engine/commands/browser.cjs +67 -34
  57. package/engine/commands/build.cjs +42 -12
  58. package/engine/commands/call.cjs +1 -1
  59. package/engine/commands/cd.cjs +1 -1
  60. package/engine/commands/cloud.cjs +2 -1
  61. package/engine/commands/connect.cjs +38 -13
  62. package/engine/commands/creds.cjs +74 -29
  63. package/engine/commands/doctor.cjs +17 -1
  64. package/engine/commands/document.cjs +40 -12
  65. package/engine/commands/env.cjs +6 -1
  66. package/engine/commands/firm.cjs +42 -12
  67. package/engine/commands/graph.cjs +36 -32
  68. package/engine/commands/help.cjs +11 -1
  69. package/engine/commands/hep.cjs +1 -1
  70. package/engine/commands/import.cjs +5 -4
  71. package/engine/commands/index.cjs +1 -1
  72. package/engine/commands/install.cjs +6 -4
  73. package/engine/commands/list.cjs +56 -16
  74. package/engine/commands/login.cjs +5 -0
  75. package/engine/commands/logout.cjs +6 -1
  76. package/engine/commands/mcp.cjs +8 -2
  77. package/engine/commands/memory.cjs +3 -2
  78. package/engine/commands/multimodal.cjs +12 -0
  79. package/engine/commands/native.cjs +5 -1
  80. package/engine/commands/one.cjs +78 -33
  81. package/engine/commands/plugin.cjs +13 -2
  82. package/engine/commands/project.cjs +15 -12
  83. package/engine/commands/research.cjs +18 -7
  84. package/engine/commands/roles.cjs +104 -28
  85. package/engine/commands/route.cjs +2 -2
  86. package/engine/commands/run.cjs +42 -9
  87. package/engine/commands/search.cjs +23 -2
  88. package/engine/commands/setup.cjs +6 -1
  89. package/engine/commands/storm.cjs +44 -6
  90. package/engine/commands/swarm.cjs +47 -9
  91. package/engine/commands/telegram.cjs +6 -1
  92. package/engine/commands/uninstall.cjs +12 -6
  93. package/engine/commands/update.cjs +5 -0
  94. package/engine/commands/upload.cjs +2 -1
  95. package/engine/commands/usage.cjs +6 -1
  96. package/engine/commands/version.cjs +6 -1
  97. package/engine/commands/whoami.cjs +6 -1
  98. package/engine/commands/workforce.cjs +60 -16
  99. package/engine/core/capability-grants.cjs +7 -2
  100. package/engine/core/daemon-client.cjs +36 -7
  101. package/engine/core/db.cjs +10 -2
  102. package/engine/core/desktop-core-fetch.cjs +477 -77
  103. package/engine/core/schema-ensure.cjs +23 -11
  104. package/engine/experience/build.cjs +5 -1
  105. package/engine/experience/intents.cjs +29 -82
  106. package/engine/experience/runtime.cjs +7 -1
  107. package/engine/experience/variant.cjs +58 -4
  108. package/engine/firms/orchestrate.cjs +9 -6
  109. package/engine/graph/ask-model.cjs +22 -11
  110. package/engine/graph/interview.cjs +74 -23
  111. package/engine/graph/package.cjs +63 -8
  112. package/engine/hephaestus/local-core.cjs +18 -5
  113. package/engine/hephaestus/runtime.cjs +48 -43
  114. package/engine/hub/install.cjs +230 -61
  115. package/engine/hub/plugins.cjs +391 -29
  116. package/engine/mcp/consent.cjs +25 -17
  117. package/engine/mcp/contract.cjs +229 -24
  118. package/engine/mcp/inventory.cjs +15 -5
  119. package/engine/mcp/plan.cjs +5 -1
  120. package/engine/mcp/probe.cjs +24 -9
  121. package/engine/memory-cli/curate.cjs +74 -16
  122. package/engine/oberon/common.cjs +37 -12
  123. package/engine/oberon/manifest.cjs +25 -2
  124. package/engine/oberon/outputs.cjs +49 -19
  125. package/engine/oberon/render.cjs +665 -40
  126. package/engine/project/career-graph.cjs +15 -2
  127. package/engine/project/controller.cjs +133 -15
  128. package/engine/project/credentials.cjs +212 -34
  129. package/engine/project/env-file.cjs +93 -8
  130. package/engine/project/memory-context.cjs +11 -57
  131. package/engine/project/ontology.cjs +66 -25
  132. package/engine/project/paths.cjs +42 -8
  133. package/engine/project/seed.cjs +88 -51
  134. package/engine/project/state.cjs +41 -16
  135. package/engine/project/team.cjs +26 -1
  136. package/engine/runtime-refusal.cjs +4 -1
  137. package/engine/runtimes/acp-driver.cjs +56 -9
  138. package/engine/runtimes/auth-evidence.cjs +21 -2
  139. package/engine/runtimes/detect.cjs +28 -8
  140. package/engine/runtimes/kinds.cjs +12 -4
  141. package/engine/runtimes/overrides.cjs +4 -3
  142. package/engine/runtimes/resolve.cjs +8 -7
  143. package/engine/runtimes/roles.cjs +7 -5
  144. package/engine/sessions/apply-fences.cjs +396 -56
  145. package/engine/sessions/fences.cjs +111 -27
  146. package/engine/sessions/memory-turn.cjs +62 -3
  147. package/engine/sessions/orchestrator.cjs +11 -1
  148. package/engine/sessions/prompt.cjs +66 -9
  149. package/engine/sessions/session.cjs +70 -10
  150. package/engine/sessions/sink.cjs +78 -12
  151. package/engine/sessions/store.cjs +29 -7
  152. package/engine/storm/storm.cjs +26 -3
  153. package/engine/storm/swarm.cjs +31 -3
  154. package/engine/telegram/connect.cjs +187 -24
  155. package/engine/ui/commands-catalog.cjs +1 -1
  156. package/engine/ui/palette.cjs +12 -2
  157. package/engine/ui/repl.cjs +57 -9
  158. package/engine/ui/screens.cjs +2 -4
  159. package/engine/vendor/desktop-core.manifest.json +5 -5
  160. package/engine/workforce/capture.cjs +9 -27
  161. package/engine/workforce/deps.cjs +18 -6
  162. package/engine/workforce/local-core-transport.cjs +68 -5
  163. package/package.json +1 -1
  164. package/engine/commands/career-graph.cjs +0 -51
@@ -52,6 +52,7 @@ const cas = require("./cas.cjs");
52
52
  // 터미널 업로드가 그 파일들을 아예 열지 않았다.
53
53
  const CLOUD_TEXT_EXTS = new Set(SECRET_SCAN_TEXT_EXTENSIONS);
54
54
  const CLOUD_AGENT_FILES = new Set(UPLOAD_AGENT_DEFINITION_FILES);
55
+ const CLOUD_AGENT_FILE_KEYS = new Set([...CLOUD_AGENT_FILES].map((name) => name.toLowerCase()));
55
56
  const CLOUD_SKIP_DIRS = new Set(UPLOAD_SKIP_DIRECTORIES);
56
57
  const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
57
58
  const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
@@ -385,7 +386,11 @@ const CLOUD_BULK_DATA_RE = /\.(log|jsonl|csv|tsv|lock)$/;
385
386
  function cloudTrimRank(rel, bytes) {
386
387
  const lower = String(rel || "").toLowerCase();
387
388
  const base = lower.split("/").pop() || lower;
388
- if (CLOUD_AGENT_FILES.has(base)) return 0;
389
+ // The generated catalog intentionally carries case-distinct root names
390
+ // (`AGENTS.md`, `AGENT.md`, `agent.md`). Ranking lower-cases paths, so the
391
+ // lookup must lower-case the catalog as well. Otherwise a package at the
392
+ // byte limit can evict AGENTS.md while retaining an unrelated cache file.
393
+ if (CLOUD_AGENT_FILE_KEYS.has(base)) return 0;
389
394
  if (lower.startsWith(".agentlas/")) return 0;
390
395
  if (base === "agentlas.json" || base === "manifest.json" || base === "package.json") return 0;
391
396
  if (CLOUD_CAPABILITY_DIR_RE.test(lower)) return 0;
@@ -427,13 +432,16 @@ function scanCloudFolder(rootPath) {
427
432
  let freed = 0;
428
433
  while (totalBytes - freed + needBytes > CLOUD_MAX_TOTAL_BYTES) {
429
434
  let victimIndex = -1;
430
- let victimRank = 0;
435
+ let victimRank = Number.POSITIVE_INFINITY;
431
436
  let victimBytes = -1;
432
437
  for (let index = 0; index < included.length; index += 1) {
433
438
  const candidate = included[index];
434
439
  const rank = cloudTrimRank(candidate.path, candidate.bytes);
435
440
  if (rank === 0) continue;
436
- if (rank > victimRank || (rank === victimRank && candidate.bytes > victimBytes)) {
441
+ // Rank 1 is disposable build/cache output; larger numbers are closer
442
+ // to ordinary authored content. Desktop's canonical trimmer removes
443
+ // the smallest positive rank first, then the largest file in that tier.
444
+ if (rank < victimRank || (rank === victimRank && candidate.bytes > victimBytes)) {
437
445
  victimIndex = index;
438
446
  victimRank = rank;
439
447
  victimBytes = candidate.bytes;
@@ -709,6 +717,60 @@ function scanCloudFolder(rootPath) {
709
717
  return { files, included, findings, omissions, totalBytes, localPackageMarker };
710
718
  }
711
719
 
720
+ /**
721
+ * Generated metadata is added after the stable source scan. Re-apply the
722
+ * public package limits at that exact boundary so injecting agentlas.json (or
723
+ * replacing a redacted public card) cannot turn a valid 400-file scan into a
724
+ * 401-file bundle that the server will reject.
725
+ */
726
+ function cloudFitGeneratedMetadataToLimits(scan) {
727
+ const removeVictim = () => {
728
+ let victimIndex = -1;
729
+ let victimRank = Number.POSITIVE_INFINITY;
730
+ let victimBytes = -1;
731
+ for (let index = 0; index < scan.included.length; index += 1) {
732
+ const candidate = scan.included[index];
733
+ const rank = cloudTrimRank(candidate.path, candidate.bytes);
734
+ if (rank === 0) continue;
735
+ if (rank < victimRank || (rank === victimRank && candidate.bytes > victimBytes)) {
736
+ victimIndex = index;
737
+ victimRank = rank;
738
+ victimBytes = candidate.bytes;
739
+ }
740
+ }
741
+ if (victimIndex < 0) return false;
742
+ const victim = scan.included.splice(victimIndex, 1)[0];
743
+ scan.omissions.push({
744
+ path: victim.path,
745
+ reason: "trimmed-for-package-limits",
746
+ sourceBytes: victim.bytes,
747
+ sourceSha256: victim.sha256,
748
+ sourceHashKind: "content",
749
+ });
750
+ const record = scan.files.find((item) => item.path === victim.path && item.included);
751
+ if (record) {
752
+ record.included = false;
753
+ record.reason = "trimmed-for-package-limits";
754
+ }
755
+ return true;
756
+ };
757
+
758
+ scan.totalBytes = scan.included.reduce((sum, file) => sum + file.bytes, 0);
759
+ while (scan.included.length > CLOUD_MAX_FILES || scan.totalBytes > CLOUD_MAX_TOTAL_BYTES) {
760
+ if (!removeVictim()) {
761
+ const error = new Error(
762
+ "Agent Cloud package cannot fit required agent metadata within the package limits. " +
763
+ "Reduce the number or size of capability files and retry.",
764
+ );
765
+ error.code = "AGENTLAS_CLOUD_PACKAGE_LIMITS";
766
+ throw error;
767
+ }
768
+ scan.totalBytes = scan.included.reduce((sum, file) => sum + file.bytes, 0);
769
+ }
770
+ scan.included.sort(cloudCodePointPathOrder);
771
+ scan.omissions.sort(cloudCodePointPathOrder);
772
+ }
773
+
712
774
  function cloudOmissionReceipt(omissions) {
713
775
  if (!Array.isArray(omissions) || omissions.length === 0) return undefined;
714
776
  const entries = omissions.map((entry) => ({
@@ -1074,6 +1136,7 @@ async function packageCloudAgent(db, root, opts = {}) {
1074
1136
  }
1075
1137
  // 신원은 해시 계산 전에 채운다 — 클라우드 해시는 agentlas.json 을 포함한다.
1076
1138
  cloudEnsureAgentIdentity(scan, snapshot, rootPath.split("/").pop());
1139
+ cloudFitGeneratedMetadataToLimits(scan);
1077
1140
  snapshot = cloudPackageSnapshot(scan.included);
1078
1141
  const routingCard = isPublicHubPublish ? readCloudRoutingCard(snapshot) : {};
1079
1142
  if (routingCard.finding) scan.findings.push(routingCard.finding);
@@ -30,15 +30,18 @@ async function listOwnedCloudAgents(limit = 100) {
30
30
  };
31
31
  if (!Array.isArray(result.results)) throw new Error("Agent Cloud list returned an invalid results contract.");
32
32
  if (result.results.length) {
33
- const stateValue = state.readCloudAssetState();
34
- for (const raw of result.results) {
35
- const descriptor = normalizeCloudAssetDescriptor(raw, "Agent Cloud list result");
36
- const key = state.cloudDescriptorKey(descriptor);
37
- const previous = stateValue.assets[key];
38
- const preserveRoots = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
39
- stateValue.assets[key] = { descriptor, sourceRoots: preserveRoots ? previous.sourceRoots : [] };
40
- }
41
- state.writeCloudAssetState(stateValue);
33
+ const descriptors = result.results.map((raw) => normalizeCloudAssetDescriptor(raw, "Agent Cloud list result"));
34
+ state.updateCloudAssetState((stateValue) => {
35
+ for (const descriptor of descriptors) {
36
+ const key = state.cloudDescriptorKey(descriptor);
37
+ const previous = stateValue.assets[key];
38
+ // A list response may race a save that completed after the server made
39
+ // the list snapshot. Never regress a newer locally observed receipt.
40
+ if (previous && Date.parse(previous.descriptor.updatedAt) > Date.parse(descriptor.updatedAt)) continue;
41
+ const preserveRoots = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
42
+ stateValue.assets[key] = { descriptor, sourceRoots: preserveRoots ? previous.sourceRoots : [] };
43
+ }
44
+ });
42
45
  }
43
46
  return result;
44
47
  }
@@ -30,6 +30,132 @@ const {
30
30
  } = require("../hub/install.cjs");
31
31
 
32
32
  const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
33
+ const CLOUD_ASSET_STATE_MAX_BYTES = 1024 * 1024;
34
+ const CLOUD_ASSET_LOCK_STALE_MS = 30_000;
35
+ const CLOUD_ASSET_LOCK_WAIT_MS = 10_000;
36
+
37
+ function waitSync(milliseconds) {
38
+ const signal = new Int32Array(new SharedArrayBuffer(4));
39
+ Atomics.wait(signal, 0, 0, milliseconds);
40
+ }
41
+
42
+ function processIsAlive(pid) {
43
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch (error) {
48
+ if (error && error.code === "ESRCH") return false;
49
+ // EPERM means the process exists but belongs to another user. Unknown
50
+ // failures also stay fail-safe: never steal a lock from a possibly live owner.
51
+ return true;
52
+ }
53
+ }
54
+
55
+ function readLockOwner(lockPath) {
56
+ const ownerPath = path.join(lockPath, "owner.json");
57
+ let fd;
58
+ try {
59
+ fd = fs.openSync(ownerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
60
+ const before = fs.fstatSync(fd);
61
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > 512) {
62
+ throw new Error("lock owner is not a bounded private file");
63
+ }
64
+ const raw = fs.readFileSync(fd, "utf8");
65
+ const after = fs.fstatSync(fd);
66
+ if (
67
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
68
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
69
+ ) {
70
+ throw new Error("lock owner changed while it was read");
71
+ }
72
+ const parsed = JSON.parse(raw);
73
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{32}$/.test(parsed.nonce)) {
74
+ throw new Error("lock owner identity is invalid");
75
+ }
76
+ return parsed;
77
+ } catch (error) {
78
+ if (error && error.code === "ENOENT") return null;
79
+ throw error;
80
+ } finally {
81
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Directory rename is the lock hand-off boundary. A stale lock is moved to a
87
+ * unique quarantine path before cleanup, so a new owner's lock can never be
88
+ * removed by the old cleanup path.
89
+ */
90
+ function withCloudAssetLock(targetPath, label, action) {
91
+ const lockPath = `${targetPath}.lock`;
92
+ const lockParent = path.dirname(lockPath);
93
+ fs.mkdirSync(lockParent, { recursive: true, mode: 0o700 });
94
+ try { fs.chmodSync(lockParent, 0o700); } catch { /* best effort */ }
95
+ const deadline = Date.now() + CLOUD_ASSET_LOCK_WAIT_MS;
96
+ let acquired = false;
97
+ while (!acquired) {
98
+ try {
99
+ fs.mkdirSync(lockPath, { mode: 0o700 });
100
+ } catch (error) {
101
+ if (!error || (error.code !== "EEXIST" && error.code !== "ENOENT")) throw error;
102
+ if (error.code === "ENOENT") continue;
103
+ let stat;
104
+ try { stat = fs.lstatSync(lockPath); } catch (statError) {
105
+ if (statError && statError.code === "ENOENT") continue;
106
+ throw statError;
107
+ }
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} lock is unsafe`);
109
+ let owner = null;
110
+ try { owner = readLockOwner(lockPath); } catch { owner = null; }
111
+ if (Date.now() - stat.mtimeMs > CLOUD_ASSET_LOCK_STALE_MS && (!owner || !processIsAlive(owner.pid))) {
112
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
113
+ try {
114
+ fs.renameSync(lockPath, quarantine);
115
+ fs.rmSync(quarantine, { recursive: true, force: true });
116
+ continue;
117
+ } catch (reclaimError) {
118
+ if (reclaimError && reclaimError.code === "ENOENT") continue;
119
+ throw reclaimError;
120
+ }
121
+ }
122
+ if (Date.now() >= deadline) throw new Error(`${label} is busy; retry after the active operation finishes`);
123
+ waitSync(25);
124
+ continue;
125
+ }
126
+ try {
127
+ const owner = {
128
+ pid: process.pid,
129
+ nonce: crypto.randomBytes(16).toString("hex"),
130
+ createdAt: new Date().toISOString(),
131
+ };
132
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner) + "\n", {
133
+ encoding: "utf8",
134
+ mode: 0o600,
135
+ flag: "wx",
136
+ });
137
+ acquired = true;
138
+ } catch (error) {
139
+ const abandoned = `${lockPath}.abandoned-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
140
+ try {
141
+ fs.renameSync(lockPath, abandoned);
142
+ fs.rmSync(abandoned, { recursive: true, force: true });
143
+ } catch { /* original owner-write error remains authoritative */ }
144
+ throw error;
145
+ }
146
+ }
147
+ try {
148
+ return action();
149
+ } finally {
150
+ const cleanup = `${lockPath}.done-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
151
+ try {
152
+ fs.renameSync(lockPath, cleanup);
153
+ fs.rmSync(cleanup, { recursive: true, force: true });
154
+ } catch (error) {
155
+ if (!error || error.code !== "ENOENT") throw error;
156
+ }
157
+ }
158
+ }
33
159
 
34
160
  function normalizeCloudScopeFlag(value) {
35
161
  if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
@@ -45,45 +171,68 @@ function cloudAssetStatePath() {
45
171
  return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
46
172
  }
47
173
 
174
+ function normalizeCloudAssetState(parsed) {
175
+ if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
176
+ throw new Error("state schema is invalid");
177
+ }
178
+ const assets = {};
179
+ for (const [key, raw] of Object.entries(parsed.assets)) {
180
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
181
+ const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
182
+ if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
183
+ if (!Array.isArray(raw.sourceRoots)) throw new Error(`state entry ${key} sourceRoots is invalid`);
184
+ for (const item of raw.sourceRoots) {
185
+ if (typeof item !== "string" || !path.isAbsolute(item)) throw new Error(`state entry ${key} sourceRoots is invalid`);
186
+ }
187
+ const sourceRoots = [...new Set(raw.sourceRoots.map((item) => path.resolve(item)))].slice(0, 32);
188
+ assets[key] = { descriptor, sourceRoots };
189
+ }
190
+ if (!Array.isArray(parsed.deletedBases)) throw new Error("state deletedBases is invalid");
191
+ const deletedBases = parsed.deletedBases.map((item, index) => {
192
+ if (
193
+ !item || typeof item !== "object" || Array.isArray(item) ||
194
+ typeof item.rootPath !== "string" || !path.isAbsolute(item.rootPath) ||
195
+ typeof item.slug !== "string" || cloudSlug(item.slug) !== item.slug ||
196
+ !CLOUD_ASSET_SCOPES.has(item.scope) || !/^[A-Za-z0-9_-]{8,128}$/.test(String(item.cloudId || "")) ||
197
+ typeof item.revision !== "string" || !item.revision || item.revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(item.revision)
198
+ ) {
199
+ throw new Error(`state deletedBases[${index}] is invalid`);
200
+ }
201
+ return {
202
+ rootPath: path.resolve(item.rootPath),
203
+ slug: item.slug,
204
+ scope: item.scope,
205
+ cloudId: item.cloudId,
206
+ revision: item.revision,
207
+ };
208
+ }).slice(-256);
209
+ return { schemaVersion: 1, assets, deletedBases };
210
+ }
211
+
48
212
  function readCloudAssetState() {
49
213
  const statePath = cloudAssetStatePath();
50
- if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
51
214
  let fd;
52
215
  try {
53
216
  // O_NOFOLLOW: 상태 파일이 심링크로 바꿔치기되면 읽지 않는다 (로컬 상태 위조 방어).
54
- fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
55
- const stat = fs.fstatSync(fd);
56
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("state file is not a bounded regular file");
57
- const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
58
- if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
59
- throw new Error("state schema is invalid");
217
+ try {
218
+ fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
219
+ } catch (error) {
220
+ if (error && error.code === "ENOENT") return { schemaVersion: 1, assets: {}, deletedBases: [] };
221
+ throw error;
60
222
  }
61
- const assets = {};
62
- for (const [key, raw] of Object.entries(parsed.assets)) {
63
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
64
- const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
65
- if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
66
- const sourceRoots = Array.isArray(raw.sourceRoots)
67
- ? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
68
- : [];
69
- assets[key] = { descriptor, sourceRoots };
223
+ const before = fs.fstatSync(fd);
224
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
225
+ throw new Error("state file is not a bounded private file");
70
226
  }
71
- const deletedBases = Array.isArray(parsed.deletedBases)
72
- ? parsed.deletedBases.filter((item) =>
73
- item && typeof item === "object" && !Array.isArray(item) &&
74
- typeof item.rootPath === "string" && path.isAbsolute(item.rootPath) &&
75
- typeof item.slug === "string" && cloudSlug(item.slug) === item.slug &&
76
- CLOUD_ASSET_SCOPES.has(item.scope) && typeof item.cloudId === "string" &&
77
- typeof item.revision === "string"
78
- ).map((item) => ({
79
- rootPath: path.resolve(item.rootPath),
80
- slug: item.slug,
81
- scope: item.scope,
82
- cloudId: item.cloudId,
83
- revision: item.revision,
84
- })).slice(-256)
85
- : [];
86
- return { schemaVersion: 1, assets, deletedBases };
227
+ const raw = fs.readFileSync(fd, "utf8");
228
+ const after = fs.fstatSync(fd);
229
+ if (
230
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
231
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
232
+ ) {
233
+ throw new Error("state file changed while it was read");
234
+ }
235
+ return normalizeCloudAssetState(JSON.parse(raw));
87
236
  } catch (error) {
88
237
  // 깨진 상태 파일을 빈 상태로 위장하면 stale 베이스로 원격 리비전을 덮어쓸 수 있다.
89
238
  throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
@@ -92,40 +241,62 @@ function readCloudAssetState() {
92
241
  }
93
242
  }
94
243
 
95
- function writeCloudAssetState(state) {
244
+ function writeCloudAssetStateUnlocked(state) {
96
245
  const statePath = cloudAssetStatePath();
97
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
246
+ const normalized = normalizeCloudAssetState(state);
247
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
248
+ try { fs.chmodSync(path.dirname(statePath), 0o700); } catch { /* best effort */ }
98
249
  const temp = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
99
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
250
+ let fd;
100
251
  try {
101
- fs.writeFileSync(fd, JSON.stringify(state, null, 2) + "\n", "utf8");
102
- fs.fsyncSync(fd);
252
+ try {
253
+ fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
254
+ fs.writeFileSync(fd, JSON.stringify(normalized, null, 2) + "\n", "utf8");
255
+ fs.fsyncSync(fd);
256
+ } finally {
257
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
258
+ }
259
+ fs.renameSync(temp, statePath);
260
+ cloudApplyPortableFileMode(statePath, 0o600);
261
+ cloudFsyncDirectory(path.dirname(statePath));
103
262
  } finally {
104
- fs.closeSync(fd);
263
+ try { fs.rmSync(temp, { force: true }); } catch { /* best effort */ }
105
264
  }
106
- fs.renameSync(temp, statePath);
107
- cloudApplyPortableFileMode(statePath, 0o600);
108
- cloudFsyncDirectory(path.dirname(statePath));
265
+ return normalized;
266
+ }
267
+
268
+ function writeCloudAssetState(state) {
269
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => writeCloudAssetStateUnlocked(state));
270
+ }
271
+
272
+ function updateCloudAssetState(mutator) {
273
+ if (typeof mutator !== "function") throw new TypeError("Cloud asset state mutator must be a function");
274
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => {
275
+ const state = readCloudAssetState();
276
+ const result = mutator(state);
277
+ writeCloudAssetStateUnlocked(state);
278
+ return result;
279
+ });
109
280
  }
110
281
 
111
282
  /** 서버가 준 리비전 영수증을 관측 상태로 승격. sourceRoot가 있으면 톰스톤도 해제한다. */
112
283
  function rememberCloudAssetDescriptor(value, options = {}) {
113
284
  const descriptor = normalizeCloudAssetDescriptor(value);
114
- const state = readCloudAssetState();
115
- const key = cloudDescriptorKey(descriptor);
116
- const previous = state.assets[key];
117
- const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
118
- const roots = sameRevision ? [...previous.sourceRoots] : [];
119
- if (options.sourceRoot) {
120
- const sourceRoot = path.resolve(options.sourceRoot);
121
- roots.push(sourceRoot);
122
- state.deletedBases = state.deletedBases.filter(
123
- (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
124
- );
125
- }
126
- state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
127
- writeCloudAssetState(state);
128
- return descriptor;
285
+ return updateCloudAssetState((state) => {
286
+ const key = cloudDescriptorKey(descriptor);
287
+ const previous = state.assets[key];
288
+ const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
289
+ const roots = sameRevision ? [...previous.sourceRoots] : [];
290
+ if (options.sourceRoot) {
291
+ const sourceRoot = path.resolve(options.sourceRoot);
292
+ roots.push(sourceRoot);
293
+ state.deletedBases = state.deletedBases.filter(
294
+ (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
295
+ );
296
+ }
297
+ state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
298
+ return descriptor;
299
+ });
129
300
  }
130
301
 
131
302
  function findCloudAssetDescriptor(slug, scope) {
@@ -193,18 +364,18 @@ function cloudUnboundDescriptorForSource(rootPath, slug, scope) {
193
364
 
194
365
  /** 이 소스 루트를 이미 관측된 클라우드 자산에 명시적으로 연결한다. */
195
366
  function bindCloudAssetSourceRoot(rootPath, slug, scope) {
196
- const state = readCloudAssetState();
197
- const key = `${scope}:${slug}`;
198
- const entry = state.assets[key];
199
- if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
200
- const normalizedRoot = path.resolve(rootPath);
201
- const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
202
- state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
203
- state.deletedBases = state.deletedBases.filter(
204
- (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
205
- );
206
- writeCloudAssetState(state);
207
- return entry.descriptor;
367
+ return updateCloudAssetState((state) => {
368
+ const key = `${scope}:${slug}`;
369
+ const entry = state.assets[key];
370
+ if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
371
+ const normalizedRoot = path.resolve(rootPath);
372
+ const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
373
+ state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
374
+ state.deletedBases = state.deletedBases.filter(
375
+ (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
376
+ );
377
+ return entry.descriptor;
378
+ });
208
379
  }
209
380
 
210
381
  function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
@@ -221,64 +392,87 @@ function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
221
392
  const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
222
393
  if (!markerDescriptor) return stateDescriptor;
223
394
  if (!stateDescriptor) return markerDescriptor;
224
- return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
395
+ return stateDescriptor.cloudId === markerDescriptor.cloudId && Date.parse(stateDescriptor.updatedAt) >= Date.parse(markerDescriptor.updatedAt)
225
396
  ? stateDescriptor
226
397
  : markerDescriptor;
227
398
  }
228
399
 
229
400
  function writeCloudSourceMarker(rootPath, scan, descriptor, options = {}) {
230
401
  const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
231
- if (fs.existsSync(markerPath)) {
232
- const stat = fs.lstatSync(markerPath);
233
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Agent Cloud revision marker is not a regular file");
234
- }
235
- const descriptors = cloudMarkerDescriptors(options.previousMarker);
236
- if (descriptor) descriptors[descriptor.scope] = descriptor;
237
- if (options.removeDescriptor) {
238
- const current = descriptors[options.removeDescriptor.scope];
239
- if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
240
- delete descriptors[options.removeDescriptor.scope];
402
+ const lockTarget = path.join(
403
+ userDataDir(),
404
+ "cloud-source-marker-locks",
405
+ crypto.createHash("sha256").update(path.resolve(rootPath)).digest("hex"),
406
+ );
407
+ return withCloudAssetLock(lockTarget, "Agent Cloud source marker", () => {
408
+ const previousMarker = readCloudSourceMarker(rootPath);
409
+ const descriptors = cloudMarkerDescriptors(previousMarker);
410
+ if (descriptor) descriptors[descriptor.scope] = descriptor;
411
+ if (options.removeDescriptor) {
412
+ const current = descriptors[options.removeDescriptor.scope];
413
+ if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
414
+ delete descriptors[options.removeDescriptor.scope];
415
+ }
241
416
  }
242
- }
243
- const latest = descriptor || Object.values(descriptors)[0] || null;
244
- const marker = {
245
- schemaVersion: 1,
246
- source: "agentlas-cloud",
247
- slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
248
- packageHash: descriptor?.packageHash || options.packageHash || options.previousMarker?.packageHash || "",
249
- packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || options.previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
250
- fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (options.previousMarker?.fileCount || 0),
251
- totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (options.previousMarker?.totalBytes || 0),
252
- executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : options.previousMarker?.executablePaths,
253
- cloudAssets: descriptors,
254
- ...(latest ? latest : {}),
255
- restoredAt: options.previousMarker?.restoredAt,
256
- savedAt: new Date().toISOString(),
257
- };
258
- for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
259
- const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
260
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
261
- try {
262
- fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
263
- fs.fsyncSync(fd);
264
- } finally {
265
- fs.closeSync(fd);
266
- }
267
- fs.renameSync(temp, markerPath);
268
- cloudApplyPortableFileMode(markerPath, 0o600);
269
- cloudFsyncDirectory(rootPath);
270
- return marker;
417
+ const latest = descriptor || Object.values(descriptors)[0] || null;
418
+ const marker = {
419
+ schemaVersion: 1,
420
+ source: "agentlas-cloud",
421
+ slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
422
+ packageHash: descriptor?.packageHash || options.packageHash || previousMarker?.packageHash || "",
423
+ packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
424
+ fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (previousMarker?.fileCount || 0),
425
+ totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (previousMarker?.totalBytes || 0),
426
+ executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : previousMarker?.executablePaths,
427
+ cloudAssets: descriptors,
428
+ ...(latest ? latest : {}),
429
+ restoredAt: previousMarker?.restoredAt,
430
+ savedAt: new Date().toISOString(),
431
+ };
432
+ for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
433
+ const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
434
+ let fd;
435
+ try {
436
+ try {
437
+ fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
438
+ fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
439
+ fs.fsyncSync(fd);
440
+ } finally {
441
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
442
+ }
443
+ fs.renameSync(temp, markerPath);
444
+ cloudApplyPortableFileMode(markerPath, 0o600);
445
+ cloudFsyncDirectory(rootPath);
446
+ } finally {
447
+ try { fs.rmSync(temp, { force: true }); } catch { /* best effort */ }
448
+ }
449
+ return marker;
450
+ });
271
451
  }
272
452
 
273
453
  function readCloudSourceMarker(rootPath) {
274
454
  const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
275
- if (!fs.existsSync(markerPath)) return null;
276
455
  let fd;
277
456
  try {
278
- fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
279
- const stat = fs.fstatSync(fd);
280
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("marker is not a bounded regular file");
281
- return JSON.parse(fs.readFileSync(fd, "utf8"));
457
+ try {
458
+ fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
459
+ } catch (error) {
460
+ if (error && error.code === "ENOENT") return null;
461
+ throw error;
462
+ }
463
+ const before = fs.fstatSync(fd);
464
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
465
+ throw new Error("marker is not a bounded private file");
466
+ }
467
+ const raw = fs.readFileSync(fd, "utf8");
468
+ const after = fs.fstatSync(fd);
469
+ if (
470
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
471
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
472
+ ) {
473
+ throw new Error("marker changed while it was read");
474
+ }
475
+ return JSON.parse(raw);
282
476
  } finally {
283
477
  if (fd !== undefined) fs.closeSync(fd);
284
478
  }
@@ -291,6 +485,7 @@ module.exports = {
291
485
  cloudAssetStatePath,
292
486
  readCloudAssetState,
293
487
  writeCloudAssetState,
488
+ updateCloudAssetState,
294
489
  rememberCloudAssetDescriptor,
295
490
  findCloudAssetDescriptor,
296
491
  cloudMarkerDescriptors,