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
@@ -23,6 +23,15 @@ const path = require("node:path");
23
23
  const { userDataDir } = require("../core/paths.cjs");
24
24
 
25
25
  const LABEL = "ai.agentlas.cli.automations";
26
+ const DIRECTORY_MODE = 0o700;
27
+ const FILE_MODE = 0o600;
28
+ const MAX_PLIST_BYTES = 64 * 1024;
29
+ const LAUNCHCTL_TIMEOUT_MS = 10_000;
30
+ const LAUNCHCTL_MAX_BUFFER = 64 * 1024;
31
+ const MIN_INTERVAL_SEC = 30;
32
+ const MAX_INTERVAL_SEC = 24 * 60 * 60;
33
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
34
+ const O_DIRECTORY = fs.constants.O_DIRECTORY || 0;
26
35
 
27
36
  function isSupported() { return process.platform === "darwin"; }
28
37
  function plistPath() { return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); }
@@ -31,18 +40,174 @@ function domainTarget() { return `gui/${os.userInfo().uid}`; }
31
40
  /** launchd 가 poke 할 CLI 진입점(절대경로). 전역 설치본이든 체크아웃이든 이 파일 기준으로 해석. */
32
41
  function cliEntry() { return path.resolve(__dirname, "..", "..", "bin", "agentlas.cjs"); }
33
42
 
43
+ function samePath(left, right) {
44
+ const a = path.normalize(String(left || ""));
45
+ const b = path.normalize(String(right || ""));
46
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
47
+ }
48
+
49
+ function sameDirectoryIdentity(left, right) {
50
+ return !!left && !!right
51
+ && left.isDirectory() && right.isDirectory()
52
+ && !left.isSymbolicLink() && !right.isSymbolicLink()
53
+ && left.dev === right.dev && left.ino === right.ino;
54
+ }
55
+
56
+ function sameFileIdentity(left, right) {
57
+ return !!left && !!right
58
+ && left.isFile() && right.isFile()
59
+ && !left.isSymbolicLink() && !right.isSymbolicLink()
60
+ && left.dev === right.dev && left.ino === right.ino;
61
+ }
62
+
63
+ function realPath(file) {
64
+ // Keep the normal Win32 spelling for plist paths; the non-native resolver is
65
+ // also available on older supported Node versions.
66
+ return fs.realpathSync(path.resolve(file));
67
+ }
68
+
69
+ function inspectDirectory(directory, label = "directory") {
70
+ const requestedPath = path.resolve(String(directory || ""));
71
+ const stat = fs.lstatSync(requestedPath);
72
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`refusing unsafe ${label}`);
73
+ const realpath = realPath(requestedPath);
74
+ const verified = fs.lstatSync(requestedPath);
75
+ if (!sameDirectoryIdentity(stat, verified)) throw new Error(`${label} changed during inspection`);
76
+ return { requestedPath, stat: verified, realpath };
77
+ }
78
+
79
+ function assertDirectory(info, label = "directory") {
80
+ const current = inspectDirectory(info.requestedPath, label);
81
+ if (!sameDirectoryIdentity(info.stat, current.stat) || !samePath(info.realpath, current.realpath)) {
82
+ throw new Error(`${label} changed during operation`);
83
+ }
84
+ return current;
85
+ }
86
+
87
+ function ensureDirectory(directory, { create = false, mode = DIRECTORY_MODE, restrict = false, label = "directory" } = {}) {
88
+ const requestedPath = path.resolve(String(directory || ""));
89
+ const parentPath = path.dirname(requestedPath);
90
+ const parent = samePath(parentPath, requestedPath)
91
+ ? null
92
+ : inspectDirectory(parentPath, `${label} parent`);
93
+ if (create) {
94
+ let exists = true;
95
+ try { fs.lstatSync(requestedPath); }
96
+ catch (error) {
97
+ if (error && error.code === "ENOENT") exists = false;
98
+ else throw error;
99
+ }
100
+ // Callers provide an already-anchored parent, so one-level creation cannot
101
+ // silently follow a newly swapped parent symlink.
102
+ if (parent) assertDirectory(parent, `${label} parent`);
103
+ if (!exists) fs.mkdirSync(requestedPath, { mode });
104
+ }
105
+ if (parent) assertDirectory(parent, `${label} parent`);
106
+ const before = inspectDirectory(requestedPath, label);
107
+ if (restrict) {
108
+ if (process.platform === "win32") {
109
+ try { fs.chmodSync(requestedPath, mode); } catch { /* Windows/best-effort */ }
110
+ } else {
111
+ let fd = null;
112
+ try {
113
+ fd = fs.openSync(requestedPath, fs.constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
114
+ const opened = fs.fstatSync(fd);
115
+ if (!sameDirectoryIdentity(before.stat, opened)) throw new Error(`${label} changed before chmod`);
116
+ fs.fchmodSync(fd, mode);
117
+ const changed = fs.fstatSync(fd);
118
+ if (!sameDirectoryIdentity(before.stat, changed)) throw new Error(`${label} changed during chmod`);
119
+ } catch { /* best-effort; the lstat/realpath checks below still gate use */ }
120
+ finally { if (fd !== null) { try { fs.closeSync(fd); } catch { /* preserve boundary checks */ } } }
121
+ }
122
+ }
123
+ if (parent) assertDirectory(parent, `${label} parent`);
124
+ const after = inspectDirectory(requestedPath, label);
125
+ if (!sameDirectoryIdentity(before.stat, after.stat) || !samePath(before.realpath, after.realpath)) {
126
+ throw new Error(`${label} changed during setup`);
127
+ }
128
+ return after;
129
+ }
130
+
131
+ function isStrictChild(parent, child) {
132
+ const relative = path.relative(parent, child);
133
+ return !!relative && !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
134
+ }
135
+
136
+ function launchAgentsDirectory({ create = false } = {}) {
137
+ const home = inspectDirectory(os.homedir(), "home directory");
138
+ const library = ensureDirectory(path.join(home.realpath, "Library"), {
139
+ create,
140
+ restrict: false,
141
+ label: "Library directory",
142
+ });
143
+ const launchAgents = ensureDirectory(path.join(library.realpath, "LaunchAgents"), {
144
+ create,
145
+ restrict: true,
146
+ label: "LaunchAgents directory",
147
+ });
148
+ if (!isStrictChild(home.realpath, library.realpath) || !isStrictChild(library.realpath, launchAgents.realpath)) {
149
+ throw new Error("LaunchAgents directory escaped the home boundary");
150
+ }
151
+ assertDirectory(home, "home directory");
152
+ assertDirectory(library, "Library directory");
153
+ assertDirectory(launchAgents, "LaunchAgents directory");
154
+ return { home, library, launchAgents };
155
+ }
156
+
157
+ function launchAgentsLocation(options = {}) {
158
+ const directories = launchAgentsDirectory(options);
159
+ const target = path.join(directories.launchAgents.realpath, `${LABEL}.plist`);
160
+ return { ...directories, target };
161
+ }
162
+
163
+ function inspectPlistTarget(target, { allowMissing = false, allowHardLinks = false } = {}) {
164
+ let stat;
165
+ try { stat = fs.lstatSync(target); }
166
+ catch (error) {
167
+ if (allowMissing && error && error.code === "ENOENT") return null;
168
+ throw error;
169
+ }
170
+ if (stat.isSymbolicLink()) throw new Error("refusing symlink LaunchAgent plist");
171
+ if (!stat.isFile()) throw new Error("refusing non-regular LaunchAgent plist");
172
+ if (!allowHardLinks && stat.nlink !== 1) throw new Error("refusing hard-linked LaunchAgent plist");
173
+ return stat;
174
+ }
175
+
176
+ function assertPlistTarget(target, expected) {
177
+ const current = inspectPlistTarget(target);
178
+ if (!sameFileIdentity(expected, current) || current.nlink !== 1) throw new Error("LaunchAgent plist changed during operation");
179
+ return current;
180
+ }
181
+
34
182
  function logPath() {
35
- const dir = path.join(userDataDir(), "logs");
36
- try { fs.mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
37
- return path.join(dir, "launchd-automations.log");
183
+ const dataDir = path.resolve(String(userDataDir() || ""));
184
+ const data = ensureDirectory(dataDir, { create: true, restrict: true, label: "Agentlas data directory" });
185
+ const logs = ensureDirectory(path.join(data.realpath, "logs"), { create: true, restrict: true, label: "Agentlas log directory" });
186
+ assertDirectory(data, "Agentlas data directory");
187
+ assertDirectory(logs, "Agentlas log directory");
188
+ const file = path.join(logs.realpath, "launchd-automations.log");
189
+ inspectPlistTarget(file, { allowMissing: true });
190
+ return file;
191
+ }
192
+
193
+ function normalizeIntervalSec(value, fallback = 300) {
194
+ let number;
195
+ try { number = Number(value); } catch { number = NaN; }
196
+ if (!Number.isFinite(number)) return fallback;
197
+ return Math.max(MIN_INTERVAL_SEC, Math.min(MAX_INTERVAL_SEC, Math.trunc(number)));
38
198
  }
39
199
 
40
200
  function plistXml(intervalSec = 300) {
41
201
  const node = process.execPath;
42
202
  const entry = cliEntry();
43
203
  const log = logPath();
44
- const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
- return [
204
+ const esc = (s) => String(s)
205
+ .replace(/&/g, "&amp;")
206
+ .replace(/</g, "&lt;")
207
+ .replace(/>/g, "&gt;")
208
+ .replace(/"/g, "&quot;")
209
+ .replace(/'/g, "&apos;");
210
+ const xml = [
46
211
  '<?xml version="1.0" encoding="UTF-8"?>',
47
212
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
48
213
  '<plist version="1.0">',
@@ -57,7 +222,7 @@ function plistXml(intervalSec = 300) {
57
222
  " <string>tick</string>",
58
223
  " </array>",
59
224
  " <key>StartInterval</key>",
60
- ` <integer>${Math.max(30, Math.floor(intervalSec))}</integer>`,
225
+ ` <integer>${normalizeIntervalSec(intervalSec)}</integer>`,
61
226
  " <key>RunAtLoad</key>",
62
227
  " <true/>",
63
228
  " <key>ProcessType</key>",
@@ -72,12 +237,23 @@ function plistXml(intervalSec = 300) {
72
237
  "</plist>",
73
238
  "",
74
239
  ].join("\n");
240
+ if (Buffer.byteLength(xml, "utf8") > MAX_PLIST_BYTES) throw new Error("LaunchAgent plist is too large");
241
+ return xml;
75
242
  }
76
243
 
77
244
  /** launchctl 실행 — throw 하지 않고 {code, stderr} 반환(상태 함수가 판정). */
78
245
  function launchctl(args) {
79
- const res = spawnSync("launchctl", args, { encoding: "utf8" });
80
- return { code: res.status ?? -1, stderr: (res.stderr || "").trim() };
246
+ try {
247
+ const res = spawnSync("launchctl", args, {
248
+ encoding: "utf8",
249
+ timeout: LAUNCHCTL_TIMEOUT_MS,
250
+ maxBuffer: LAUNCHCTL_MAX_BUFFER,
251
+ });
252
+ const stderr = String(res && (res.stderr || res.error?.message || "") || "").slice(0, LAUNCHCTL_MAX_BUFFER).trim();
253
+ return { code: res && Number.isInteger(res.status) ? res.status : -1, stderr };
254
+ } catch (error) {
255
+ return { code: -1, stderr: String((error && error.message) || error).slice(0, LAUNCHCTL_MAX_BUFFER).trim() };
256
+ }
81
257
  }
82
258
 
83
259
  function isLoaded() {
@@ -85,11 +261,209 @@ function isLoaded() {
85
261
  return launchctl(["print", `${domainTarget()}/${LABEL}`]).code === 0;
86
262
  }
87
263
 
264
+ function writeAll(fd, payload) {
265
+ let offset = 0;
266
+ while (offset < payload.length) {
267
+ const written = fs.writeSync(fd, payload, offset, payload.length - offset);
268
+ if (!written) throw new Error("failed to write LaunchAgent plist");
269
+ offset += written;
270
+ }
271
+ }
272
+
273
+ function tempPath(directory, suffix) {
274
+ return path.join(directory.realpath, `.${LABEL}.${process.pid}.${cryptoRandomId()}.${suffix}`);
275
+ }
276
+
277
+ function cryptoRandomId() {
278
+ // LaunchAgent paths are local-only, but a random name keeps recovery files
279
+ // from colliding with a user's own dotfiles or another CLI process.
280
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
281
+ }
282
+
283
+ function writePlistTemp(directory, xml) {
284
+ const payload = Buffer.from(xml, "utf8");
285
+ if (payload.length > MAX_PLIST_BYTES) throw new Error("LaunchAgent plist is too large");
286
+ const temporary = tempPath(directory, "tmp");
287
+ let fd = null;
288
+ let created = null;
289
+ try {
290
+ fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, FILE_MODE);
291
+ try { fs.fchmodSync(fd, FILE_MODE); } catch { /* Windows/best-effort */ }
292
+ created = fs.fstatSync(fd);
293
+ if (!created.isFile() || created.isSymbolicLink() || created.nlink !== 1) throw new Error("unsafe LaunchAgent plist temporary file");
294
+ writeAll(fd, payload);
295
+ fs.fsyncSync(fd);
296
+ const written = fs.fstatSync(fd);
297
+ if (!sameFileIdentity(created, written) || written.nlink !== 1 || written.size !== payload.length) {
298
+ throw new Error("LaunchAgent plist temporary file changed during write");
299
+ }
300
+ fs.closeSync(fd);
301
+ fd = null;
302
+ assertDirectory(directory, "LaunchAgents directory");
303
+ return { path: temporary, stat: written };
304
+ } catch (error) {
305
+ if (fd !== null) {
306
+ try { fs.closeSync(fd); } catch { /* preserve original */ }
307
+ }
308
+ // Never unlink a pathname which may have been replaced after our fd was
309
+ // opened. An unknown temp successor is a recovery artifact, not ours.
310
+ if (created) {
311
+ try {
312
+ const current = inspectPlistTarget(temporary, { allowMissing: true });
313
+ if (current && sameFileIdentity(created, current) && current.nlink === 1) fs.unlinkSync(temporary);
314
+ } catch { /* best-effort; recovery path remains */ }
315
+ }
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ function removeKnownFile(file, expected, directory) {
321
+ assertDirectory(directory, "LaunchAgents directory");
322
+ const current = assertPlistTarget(file, expected);
323
+ if (current.nlink !== 1) throw new Error("LaunchAgent plist became hard-linked");
324
+ fs.unlinkSync(file);
325
+ assertDirectory(directory, "LaunchAgents directory");
326
+ }
327
+
328
+ function restoreQuarantinedFile(location, quarantine) {
329
+ let quarantined;
330
+ try { quarantined = inspectPlistTarget(quarantine); } catch { return false; }
331
+ try {
332
+ // A directory swap can make the original boundary unverifiable, but the
333
+ // recovery path is still safe to use if the destination is absent: link(2)
334
+ // is no-replace, and the inode check below prevents restoring an unknown
335
+ // successor. Do not require the unsafe directory assertion here or a
336
+ // victim moved by a race would be stranded under the quarantine name.
337
+ try { assertDirectory(location.launchAgents, "LaunchAgents directory"); } catch { /* recovery may cross a detected swap */ }
338
+ const target = inspectPlistTarget(location.target, { allowMissing: true });
339
+ if (target) return false;
340
+ fs.linkSync(quarantine, location.target);
341
+ const restored = fs.lstatSync(location.target);
342
+ if (!sameFileIdentity(quarantined, restored) || restored.nlink < 2) return false;
343
+ fs.unlinkSync(quarantine);
344
+ const final = inspectPlistTarget(location.target);
345
+ return sameFileIdentity(quarantined, final) && final.nlink === 1;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+
351
+ function writePlistAtomic(location, xml) {
352
+ if (Buffer.byteLength(xml, "utf8") > MAX_PLIST_BYTES) throw new Error("LaunchAgent plist is too large");
353
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
354
+ const expected = inspectPlistTarget(location.target, { allowMissing: true });
355
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
356
+ const temporary = writePlistTemp(location.launchAgents, xml);
357
+ let published = false;
358
+ let quarantine = null;
359
+ try {
360
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
361
+ const current = inspectPlistTarget(location.target, { allowMissing: true });
362
+ if (expected && (!current || !sameFileIdentity(expected, current) || current.nlink !== 1)) {
363
+ throw new Error("LaunchAgent plist changed before update");
364
+ }
365
+ if (!expected && current) throw new Error("LaunchAgent plist successor appeared during install");
366
+
367
+ if (expected) {
368
+ quarantine = tempPath(location.launchAgents, "previous");
369
+ // Move the known inode to a private recovery path. If a successor won
370
+ // the pathname race, the post-rename identity check restores it without
371
+ // overwriting the successor or losing the previous plist.
372
+ fs.renameSync(location.target, quarantine);
373
+ const moved = inspectPlistTarget(quarantine);
374
+ if (!sameFileIdentity(expected, moved) || moved.nlink !== 1) {
375
+ restoreQuarantinedFile(location, quarantine);
376
+ throw new Error("LaunchAgent plist successor replaced the expected file");
377
+ }
378
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
379
+ if (inspectPlistTarget(location.target, { allowMissing: true })) {
380
+ throw new Error("LaunchAgent plist successor appeared during update");
381
+ }
382
+ }
383
+
384
+ // link(2) is atomic and no-replace. A successor that appears after the
385
+ // preflight therefore causes EEXIST instead of being silently clobbered.
386
+ fs.linkSync(temporary.path, location.target);
387
+ published = true;
388
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
389
+ const linked = fs.lstatSync(location.target);
390
+ if (!sameFileIdentity(temporary.stat, linked) || linked.nlink < 2) throw new Error("LaunchAgent plist identity changed after publish");
391
+ const linkedTemporary = inspectPlistTarget(temporary.path, { allowHardLinks: true });
392
+ if (!sameFileIdentity(temporary.stat, linkedTemporary) || linkedTemporary.nlink < 2) {
393
+ throw new Error("LaunchAgent plist temporary file changed after publish");
394
+ }
395
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
396
+ fs.unlinkSync(temporary.path);
397
+ published = false;
398
+ const installed = inspectPlistTarget(location.target);
399
+ if (!sameFileIdentity(temporary.stat, installed) || installed.nlink !== 1) throw new Error("LaunchAgent plist identity changed after publish");
400
+ if (quarantine) {
401
+ const previous = inspectPlistTarget(quarantine);
402
+ removeKnownFile(quarantine, previous, location.launchAgents);
403
+ quarantine = null;
404
+ }
405
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
406
+ } catch (error) {
407
+ if (published) {
408
+ // If the directory was swapped after the no-replace link, the target
409
+ // pathname may now resolve through an unsafe successor directory. Only
410
+ // unlink it when the inode is still exactly our temporary file; never
411
+ // remove a replacement merely because it occupies the same pathname.
412
+ try {
413
+ const current = inspectPlistTarget(location.target, { allowMissing: true });
414
+ if (current && sameFileIdentity(temporary.stat, current) && current.nlink >= 2) {
415
+ fs.unlinkSync(location.target);
416
+ published = false;
417
+ }
418
+ } catch { /* leave an unknown successor and recovery artifact intact */ }
419
+ }
420
+ if (!published) {
421
+ try {
422
+ const current = inspectPlistTarget(temporary.path, { allowMissing: true });
423
+ if (current && sameFileIdentity(temporary.stat, current) && current.nlink === 1) {
424
+ fs.unlinkSync(temporary.path);
425
+ }
426
+ } catch { /* leave the temp as a recovery artifact if the boundary moved */ }
427
+ }
428
+ // A quarantine is deliberately retained when publication/update fails so
429
+ // an operator can restore the previous plist without guessing its bytes.
430
+ throw error;
431
+ }
432
+ }
433
+
434
+ function removePlistSafely(location) {
435
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
436
+ const expected = inspectPlistTarget(location.target, { allowMissing: true });
437
+ if (!expected) return;
438
+ const quarantine = tempPath(location.launchAgents, "disabled");
439
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
440
+ fs.renameSync(location.target, quarantine);
441
+ const moved = inspectPlistTarget(quarantine);
442
+ if (!sameFileIdentity(expected, moved) || moved.nlink !== 1) {
443
+ restoreQuarantinedFile(location, quarantine);
444
+ throw new Error("LaunchAgent plist successor replaced the expected file");
445
+ }
446
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
447
+ if (inspectPlistTarget(location.target, { allowMissing: true })) {
448
+ throw new Error("LaunchAgent plist successor appeared during removal");
449
+ }
450
+ removeKnownFile(quarantine, moved, location.launchAgents);
451
+ }
452
+
88
453
  function launchdStatus() {
89
454
  const supported = isSupported();
455
+ let installed = false;
456
+ if (supported) {
457
+ try {
458
+ const location = launchAgentsLocation({ create: false });
459
+ const target = inspectPlistTarget(location.target, { allowMissing: true });
460
+ assertDirectory(location.launchAgents, "LaunchAgents directory");
461
+ installed = !!target;
462
+ } catch { /* unsafe/missing path is not an installed LaunchAgent */ }
463
+ }
90
464
  return {
91
465
  supported,
92
- installed: supported && fs.existsSync(plistPath()),
466
+ installed: supported && installed,
93
467
  loaded: supported && isLoaded(),
94
468
  plistPath: plistPath(),
95
469
  label: LABEL,
@@ -100,15 +474,15 @@ function launchdStatus() {
100
474
  /** plist 작성 + launchctl bootstrap 로드(멱등 — 이미 로드면 bootout 후 재로드). */
101
475
  function enableLaunchd({ intervalSec = 300 } = {}) {
102
476
  if (!isSupported()) return { ...launchdStatus(), error: "launchd persistence is macOS-only." };
103
- const p = plistPath();
477
+ let location;
104
478
  try {
105
- fs.mkdirSync(path.dirname(p), { recursive: true });
106
- fs.writeFileSync(p, plistXml(intervalSec), "utf8");
479
+ location = launchAgentsLocation({ create: true });
480
+ writePlistAtomic(location, plistXml(intervalSec));
107
481
  } catch (err) {
108
482
  return { ...launchdStatus(), error: `failed to write plist: ${String(err)}` };
109
483
  }
110
484
  if (isLoaded()) launchctl(["bootout", `${domainTarget()}/${LABEL}`]);
111
- const res = launchctl(["bootstrap", domainTarget(), p]);
485
+ const res = launchctl(["bootstrap", domainTarget(), location.target]);
112
486
  if (res.code !== 0 && !isLoaded()) {
113
487
  return { ...launchdStatus(), error: res.stderr || "launchctl bootstrap failed." };
114
488
  }
@@ -118,10 +492,15 @@ function enableLaunchd({ intervalSec = 300 } = {}) {
118
492
  /** launchctl bootout + plist 삭제. */
119
493
  function disableLaunchd() {
120
494
  if (!isSupported()) return launchdStatus();
495
+ let location;
496
+ try {
497
+ location = launchAgentsLocation({ create: false });
498
+ removePlistSafely(location);
499
+ } catch (err) {
500
+ if (err && err.code === "ENOENT") return launchdStatus();
501
+ return { ...launchdStatus(), error: `failed to remove plist: ${String(err)}` };
502
+ }
121
503
  if (isLoaded()) launchctl(["bootout", `${domainTarget()}/${LABEL}`]);
122
- const p = plistPath();
123
- try { if (fs.existsSync(p)) fs.rmSync(p); }
124
- catch (err) { return { ...launchdStatus(), error: `failed to remove plist: ${String(err)}` }; }
125
504
  return launchdStatus();
126
505
  }
127
506
 
@@ -33,6 +33,7 @@ function cronField(expr, min, max) {
33
33
 
34
34
  const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
35
35
  const zonedFormatterCache = new Map();
36
+ const zonedCalendarFormatterCache = new Map();
36
37
 
37
38
  function zonedDateParts(date, timezone) {
38
39
  if (!timezone) {
@@ -69,6 +70,81 @@ function zonedDateParts(date, timezone) {
69
70
  };
70
71
  }
71
72
 
73
+ function zonedCalendarParts(date, timezone) {
74
+ let formatter = zonedCalendarFormatterCache.get(timezone);
75
+ if (!formatter) {
76
+ formatter = new Intl.DateTimeFormat("en-US", {
77
+ timeZone: timezone,
78
+ hourCycle: "h23",
79
+ year: "numeric",
80
+ month: "2-digit",
81
+ day: "2-digit",
82
+ hour: "2-digit",
83
+ minute: "2-digit",
84
+ });
85
+ zonedCalendarFormatterCache.set(timezone, formatter);
86
+ }
87
+ const parts = Object.fromEntries(
88
+ formatter.formatToParts(date).filter((part) => part.type !== "literal").map((part) => [part.type, part.value]),
89
+ );
90
+ return {
91
+ year: Number(parts.year),
92
+ month: Number(parts.month),
93
+ day: Number(parts.day),
94
+ hour: Number(parts.hour),
95
+ minute: Number(parts.minute),
96
+ };
97
+ }
98
+
99
+ function calendarDayNumber(parts) {
100
+ return Date.UTC(parts.year, parts.month - 1, parts.day);
101
+ }
102
+
103
+ function zonedCalendarTime(parts, timezone) {
104
+ const desired = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute);
105
+ let candidate = new Date(desired);
106
+ for (let i = 0; i < 4; i++) {
107
+ const actual = zonedCalendarParts(candidate, timezone);
108
+ const actualAsUtc = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute);
109
+ const corrected = desired - (actualAsUtc - candidate.getTime());
110
+ if (corrected === candidate.getTime()) return candidate;
111
+ candidate = new Date(corrected);
112
+ }
113
+ return candidate;
114
+ }
115
+
116
+ function nextLocalDayStart(date, timezone) {
117
+ if (!timezone) {
118
+ const next = new Date(date.getTime());
119
+ next.setHours(0, 0, 0, 0);
120
+ next.setDate(next.getDate() + 1);
121
+ return next;
122
+ }
123
+
124
+ const current = zonedCalendarParts(date, timezone);
125
+ const nextDay = new Date(Date.UTC(current.year, current.month - 1, current.day + 1));
126
+ const target = {
127
+ year: nextDay.getUTCFullYear(),
128
+ month: nextDay.getUTCMonth() + 1,
129
+ day: nextDay.getUTCDate(),
130
+ hour: 0,
131
+ minute: 0,
132
+ };
133
+ const targetDay = calendarDayNumber(target);
134
+ let candidate = zonedCalendarTime(target, timezone);
135
+
136
+ // Midnight can be skipped by a timezone transition. Accept the first instant
137
+ // that has reached the next local calendar day (or a later, non-existent day).
138
+ for (let i = 0; i < 48; i++) {
139
+ if (candidate.getTime() > date.getTime()) {
140
+ const actual = zonedCalendarParts(candidate, timezone);
141
+ if (calendarDayNumber(actual) >= targetDay) return candidate;
142
+ }
143
+ candidate = new Date(candidate.getTime() + 60 * 60 * 1000);
144
+ }
145
+ return null;
146
+ }
147
+
72
148
  function nextCronRun(cron, from = new Date(), timezone = null) {
73
149
  const parts = String(cron).trim().split(/\s+/);
74
150
  if (parts.length !== 5) return null;
@@ -85,10 +161,13 @@ function nextCronRun(cron, from = new Date(), timezone = null) {
85
161
  } catch {
86
162
  return null;
87
163
  }
88
- const t = new Date(from.getTime());
164
+ const fromMs = from.getTime();
165
+ if (!Number.isFinite(fromMs)) return null;
166
+ const t = new Date(fromMs);
89
167
  t.setSeconds(0, 0);
90
168
  t.setMinutes(t.getMinutes() + 1);
91
- for (let i = 0; i < 366 * 24 * 60; i++) {
169
+ const searchUntil = fromMs + 8 * 366 * 24 * 60 * 60 * 1000;
170
+ while (t.getTime() <= searchUntil) {
92
171
  const local = zonedDateParts(t, timezone);
93
172
  const domOk = doms.has(local.day);
94
173
  const dowOk = dows.has(local.weekday);
@@ -96,7 +175,15 @@ function nextCronRun(cron, from = new Date(), timezone = null) {
96
175
  const domRestricted = domS !== "*";
97
176
  const dowRestricted = dowS !== "*";
98
177
  const dayOk = domRestricted && dowRestricted ? domOk || dowOk : domOk && dowOk;
99
- if (mons.has(local.month) && dayOk && hours.has(local.hour) && mins.has(local.minute)) return t;
178
+ const monthOk = mons.has(local.month);
179
+ if (monthOk && dayOk && hours.has(local.hour) && mins.has(local.minute)) return t;
180
+ if (!monthOk || !dayOk) {
181
+ const dayStart = nextLocalDayStart(t, timezone);
182
+ if (dayStart && dayStart.getTime() > t.getTime()) {
183
+ t.setTime(dayStart.getTime());
184
+ continue;
185
+ }
186
+ }
100
187
  t.setMinutes(t.getMinutes() + 1);
101
188
  }
102
189
  return null;