@nowcrew/daemon 0.6.88 → 0.6.90

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 (122) hide show
  1. package/README.md +66 -4
  2. package/dist/agent-ability/controller.js +11 -1
  3. package/dist/agent-ability/git-errors.js +22 -0
  4. package/dist/agent-ability/materializer.js +21 -10
  5. package/dist/agent-ability/resolver.js +12 -4
  6. package/dist/agent-environment-contract.js +55 -0
  7. package/dist/agent-environment-control.js +24 -0
  8. package/dist/agent-environment-execution.js +98 -0
  9. package/dist/agent-environments.js +275 -0
  10. package/dist/automatic-daemon-installer.js +51 -2
  11. package/dist/automatic-install-target.js +8 -0
  12. package/dist/computer-main.js +18 -0
  13. package/dist/computer-profile.js +17 -4
  14. package/dist/computer-setup-client.js +318 -0
  15. package/dist/computer-setup-orchestration.js +203 -0
  16. package/dist/computer-setup.js +244 -0
  17. package/dist/config.js +30 -12
  18. package/dist/console-formatter.js +3 -3
  19. package/dist/console.js +15 -2
  20. package/dist/daemon-update-eligibility.js +1 -1
  21. package/dist/execution-journal.js +9 -6
  22. package/dist/execution-protocol.js +1 -0
  23. package/dist/execution-runner.js +13 -8
  24. package/dist/i18n.js +19 -0
  25. package/dist/list-models.js +12 -12
  26. package/dist/local-execution-lease.js +22 -0
  27. package/dist/local-executor.js +57 -84
  28. package/dist/local-memory/candidate.js +43 -0
  29. package/dist/local-memory/index-budget.js +8 -0
  30. package/dist/local-memory/inventory-cli.js +61 -0
  31. package/dist/local-memory/inventory-preview.js +159 -0
  32. package/dist/local-memory/inventory-source.js +159 -0
  33. package/dist/local-memory/pending-match.js +29 -0
  34. package/dist/local-memory/pending-query.js +100 -0
  35. package/dist/local-memory/pending-schema.js +29 -0
  36. package/dist/local-memory/pending-source.js +126 -0
  37. package/dist/local-memory/pending.js +34 -84
  38. package/dist/local-memory/prompt.js +19 -12
  39. package/dist/local-memory/read-cli.js +97 -12
  40. package/dist/local-memory/rollout.js +82 -0
  41. package/dist/local-memory/runtime.js +23 -17
  42. package/dist/local-memory/work-log-execution.js +31 -0
  43. package/dist/local-memory/work-log.js +58 -0
  44. package/dist/local-memory/write.js +27 -58
  45. package/dist/local-runtime-environment.js +50 -0
  46. package/dist/machine-environment-cache.js +147 -0
  47. package/dist/machine-environment-contract.js +10 -0
  48. package/dist/machine-environment-control.js +27 -0
  49. package/dist/machine-environment-policy.js +75 -0
  50. package/dist/machine-environment-process.js +110 -0
  51. package/dist/machine-info.js +70 -25
  52. package/dist/machine-runtime-environment.js +189 -0
  53. package/dist/machine-shell-environment-capture.js +135 -0
  54. package/dist/machine-windows-environment-capture.js +215 -0
  55. package/dist/machine-windows-environment-script.js +158 -0
  56. package/dist/main.js +4 -69
  57. package/dist/marketplace-command.js +21 -0
  58. package/dist/marketplace-controller.js +80 -0
  59. package/dist/marketplace-protocol.js +38 -0
  60. package/dist/marketplace-remote.js +48 -0
  61. package/dist/marketplace-runner.js +235 -0
  62. package/dist/marketplace-serve.js +17 -0
  63. package/dist/marketplace-source.js +103 -0
  64. package/dist/message-file-preview.js +246 -0
  65. package/dist/pre-admission-rejection.js +64 -0
  66. package/dist/project-skills/execution-adapter.js +3 -2
  67. package/dist/project-skills/runtime-launch.js +14 -0
  68. package/dist/provider-env.js +26 -15
  69. package/dist/runner.js +17 -4
  70. package/dist/runtime-environment-merge.js +16 -0
  71. package/dist/runtime-health-handler.js +10 -0
  72. package/dist/runtime-health.js +3 -1
  73. package/dist/runtime-path.js +22 -12
  74. package/dist/runtime-probe.js +35 -6
  75. package/dist/runtimes/acp-probe-transport.js +69 -0
  76. package/dist/runtimes/claude-models.js +20 -9
  77. package/dist/runtimes/deepseek-harness-launch.js +10 -0
  78. package/dist/runtimes/deepseek-harness-profile.js +92 -0
  79. package/dist/runtimes/deepseek-harness-runner.js +226 -0
  80. package/dist/runtimes/deepseek-harness.js +76 -15
  81. package/dist/runtimes/harness-probe-retry.js +42 -0
  82. package/dist/runtimes/hermes-models.js +2 -1
  83. package/dist/runtimes/kimi-acp-runner.js +78 -13
  84. package/dist/runtimes/opencode.js +2 -2
  85. package/dist/serve.js +88 -176
  86. package/dist/server-url.js +25 -0
  87. package/dist/slog.js +2 -1
  88. package/dist/storage-identity.js +0 -3
  89. package/dist/storage-runtime.js +2 -2
  90. package/dist/storage-serve-adapter.js +6 -72
  91. package/dist/supervised-runtime.js +17 -2
  92. package/dist/token.js +0 -1
  93. package/dist/workspace-control-handler.js +27 -0
  94. package/dist/workspace-fs.js +66 -15
  95. package/dist/workspace.js +1 -1
  96. package/package.json +6 -4
  97. package/dist/codex-home-launch.js +0 -7
  98. package/dist/codex-home-mode-controller.js +0 -68
  99. package/dist/daemon-migration-controller.js +0 -211
  100. package/dist/daemon-migration-wiring.js +0 -22
  101. package/dist/runtimes/codex-home-migration-cli.js +0 -26
  102. package/dist/runtimes/codex-home-migration.js +0 -112
  103. package/dist/runtimes/codex-home.js +0 -236
  104. package/dist/storage-control.js +0 -212
  105. package/dist/storage-copy.js +0 -628
  106. package/dist/storage-credential-guard.js +0 -21
  107. package/dist/storage-doctor.js +0 -228
  108. package/dist/storage-manifest.js +0 -1027
  109. package/dist/storage-migration-controller.js +0 -440
  110. package/dist/storage-migration-manifest.js +0 -105
  111. package/dist/storage-migration-policy.js +0 -100
  112. package/dist/storage-runtime-controller.js +0 -381
  113. package/dist/storage-runtime-readiness.js +0 -240
  114. package/dist/storage-safe-fs.js +0 -302
  115. package/dist/storage-status-delivery.js +0 -224
  116. package/dist/storage-status-replay.js +0 -140
  117. package/dist/storage-target-confirmation.js +0 -129
  118. package/dist/storage-worker-command.js +0 -37
  119. package/dist/storage-worker-executor.js +0 -449
  120. package/dist/storage-worker-handoff.js +0 -151
  121. package/dist/storage-worker-operation.js +0 -294
  122. package/dist/storage-worker-status.js +0 -259
@@ -1,7 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { startDormantSupervisor, } from "./execution-supervisor.js";
3
3
  import { buildClaudeLaunch } from "./runtimes/claude.js";
4
- import { DEEPSEEK_HARNESS_CONFIG_ENV, deepSeekHarnessConfigPath, } from "./runtimes/deepseek-harness.js";
4
+ import { DEEPSEEK_HARNESS_CONFIG_ENV, deepSeekHarnessConfigPath, deepSeekHarnessLauncher, } from "./runtimes/deepseek-harness.js";
5
5
  import { RuntimeCancelledError } from "./runtime-cancellation.js";
6
6
  export function supervisorLaunch(request) {
7
7
  const common = {
@@ -82,7 +82,22 @@ export function supervisorLaunch(request) {
82
82
  if (request.runtime === "deepseek-harness") {
83
83
  if (request.resume)
84
84
  throw new Error("DeepSeek Harness ACP does not support session resume");
85
- const configPath = deepSeekHarnessConfigPath();
85
+ const launcher = deepSeekHarnessLauncher(request.env);
86
+ if (launcher.kind === "profile") {
87
+ return {
88
+ command: process.execPath,
89
+ args: [fileURLToPath(new URL("./runtimes/deepseek-harness-runner.js", import.meta.url)),
90
+ "--bin", request.bin, ...(launcher.patchPath ? ["--patch", launcher.patchPath] : [])],
91
+ cwd: request.cwd, env: request.env,
92
+ stdinText: JSON.stringify({ systemPrompt: request.systemPrompt, wakePrompt: request.wakePrompt,
93
+ stateRoot: request.harnessStateRoot ?? request.cwd, skillRoots: request.harnessSkillRoots ?? [],
94
+ effectivePermission: request.effectivePermission }),
95
+ };
96
+ }
97
+ if ((request.harnessSkillRoots?.length ?? 0) > 0) {
98
+ throw new Error("DeepSeek Harness bound Skills require the modern dsh ACP profile");
99
+ }
100
+ const configPath = deepSeekHarnessConfigPath(request.env);
86
101
  if (configPath === null) {
87
102
  throw new Error(`${DEEPSEEK_HARNESS_CONFIG_ENV} must be an absolute path`);
88
103
  }
package/dist/token.js CHANGED
@@ -1,4 +1,3 @@
1
- /** 用机器令牌为某 agent 换取 per-launch 的 sk_agent_*。 */
2
1
  export async function mintAgentToken(serverUrl, machineToken, handle, displayName, options = {}, fetchImpl = fetch) {
3
2
  const res = await fetchImpl(`${serverUrl}/daemon/agents/token`, {
4
3
  method: "POST",
@@ -0,0 +1,27 @@
1
+ /** 控制面文件/技能查看的按需处理,独立于执行生命周期。 */
2
+ import { join } from "node:path";
3
+ import { readSharedMessageFile } from "./message-file-preview.js";
4
+ import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
5
+ import { listSkills } from "./skills.js";
6
+ import { listRuntimeModels } from "./list-models.js";
7
+ export async function handleWorkspaceControlRequest(raw, agentsRoot, registry, models = listRuntimeModels) {
8
+ if (!raw || typeof raw !== "object")
9
+ return null;
10
+ const request = raw;
11
+ if (!["fs:list", "fs:read", "fs:read-shared", "skills:list", "probe-models"].includes(request.type))
12
+ return null;
13
+ if (typeof request.reqId !== "string" || request.reqId.length > 128)
14
+ return null;
15
+ try {
16
+ const root = join(agentsRoot, request.handle);
17
+ const data = request.type === "fs:read-shared" ? await readSharedMessageFile(agentsRoot, raw, registry)
18
+ : request.type === "fs:list" ? await listWorkspace(root, request.path)
19
+ : request.type === "fs:read" ? await readWorkspaceFile(root, request.path)
20
+ : request.type === "skills:list" ? await listSkills(request.runtime)
21
+ : { models: await models(request.runtime ?? "") };
22
+ return { type: "fs:result", reqId: request.reqId, ok: true, data };
23
+ }
24
+ catch (error) {
25
+ return { type: "fs:result", reqId: request.reqId, ok: false, error: request.type === "probe-models" ? "runtime_model_probe_failed" : error.message };
26
+ }
27
+ }
@@ -3,21 +3,31 @@
3
3
  *
4
4
  * 安全铁律:
5
5
  * - 所有路径都 resolve 后必须仍落在 agent 根内 (防 `..` / 符号链接逃逸)。
6
- * - 隐藏内部目录与点文件 (.crew / .git / 任何 . 开头)。
6
+ * - 列表隐藏点文件;内部环境快照始终不可列出或读取,包括链接别名。
7
7
  * - 只回文本文件,二进制拒绝;单文件大小上限,超出截断。
8
8
  * - 仅 list / read,无写删。
9
9
  */
10
- import { realpath, readdir, readFile, stat } from "node:fs/promises";
11
- import { join, resolve, sep } from "node:path";
10
+ import { constants } from "node:fs";
11
+ import { realpath, readdir, lstat, open, stat } from "node:fs/promises";
12
+ import { join, relative, resolve, sep } from "node:path";
12
13
  export class FsError extends Error {
13
14
  }
14
15
  const MAX_FILE_BYTES = 256 * 1024; // 单文件查看上限 256KB
15
16
  const HIDDEN = new Set([".crew", ".git", ".slock"]);
16
17
  const isHidden = (name) => name.startsWith(".") || HIDDEN.has(name);
18
+ /** 该命名空间包含完整机器环境,只允许本地执行 harness 使用。 */
19
+ export const isInternalEnvironmentPath = (path) => path.split(/[\\/]/u)
20
+ .some((part) => part.toLowerCase().startsWith(".environment-"));
21
+ const assertViewablePath = (path) => {
22
+ if (isInternalEnvironmentPath(path))
23
+ throw new FsError("file_not_viewable");
24
+ };
17
25
  /** 把相对路径安全解析到 root 内的绝对路径;越界抛错。realpath 兜底符号链接逃逸。 */
18
26
  async function safeResolve(root, rel) {
27
+ assertViewablePath(rel);
19
28
  const rootResolved = await realpath(root);
20
29
  const target = resolve(rootResolved, rel.replace(/^\/+/, ""));
30
+ assertViewablePath(target);
21
31
  const withSep = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
22
32
  if (target !== rootResolved && !target.startsWith(withSep)) {
23
33
  throw new FsError("path escapes workspace");
@@ -33,6 +43,7 @@ async function safeResolve(root, rel) {
33
43
  if (real !== rootResolved && !real.startsWith(withSep)) {
34
44
  throw new FsError("path escapes workspace (symlink)");
35
45
  }
46
+ assertViewablePath(real);
36
47
  return real;
37
48
  }
38
49
  /** 列出某目录 (相对 root)。隐藏点文件/内部目录;目录在前、按名排序。 */
@@ -51,13 +62,15 @@ export async function listWorkspace(root, rel) {
51
62
  for (const name of names) {
52
63
  if (isHidden(name))
53
64
  continue;
54
- const full = join(dir, name);
65
+ const full = await safeResolve(root, relative(rootResolved, join(dir, name))).catch(() => null);
66
+ if (full === null)
67
+ continue;
55
68
  const s = await stat(full).catch(() => null);
56
69
  if (!s)
57
70
  continue;
58
71
  if (s.isDirectory())
59
72
  entries.push({ name, type: "dir" });
60
- else if (s.isFile())
73
+ else if (s.isFile() && s.nlink === 1)
61
74
  entries.push({ name, type: "file", size: s.size });
62
75
  }
63
76
  entries.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name));
@@ -66,15 +79,53 @@ export async function listWorkspace(root, rel) {
66
79
  /** 读取某文本文件 (相对 root)。二进制/超大拒绝或截断。 */
67
80
  export async function readWorkspaceFile(root, rel) {
68
81
  const file = await safeResolve(root, rel);
69
- const s = await stat(file).catch(() => null);
70
- if (!s || !s.isFile())
82
+ const before = await lstat(file, { bigint: true }).catch(() => null);
83
+ if (!before?.isFile())
71
84
  throw new FsError("not a file");
72
- const buf = await readFile(file);
73
- // 二进制探测:含 NUL 即视为二进制
74
- const sample = buf.subarray(0, Math.min(buf.length, 8192));
75
- if (sample.includes(0))
76
- throw new FsError("binary file not viewable");
77
- const truncated = buf.length > MAX_FILE_BYTES;
78
- const content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8");
79
- return { path: rel.replace(/^\/+/, ""), content, truncated, size: buf.length };
85
+ // 真实路径不能辨认硬链接的私有来源,因此拒绝多链接文件。
86
+ if (before.nlink !== 1n)
87
+ throw new FsError("file_not_viewable");
88
+ // macOS 原子拒绝父目录链接;Linux 在打开后额外核对句柄的真实路径。
89
+ const noFollow = process.platform === "darwin" ? 0x20000000 : (constants.O_NOFOLLOW ?? 0);
90
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | noFollow)
91
+ .catch(() => { throw new FsError("file_not_viewable"); });
92
+ try {
93
+ const opened = await handle.stat({ bigint: true });
94
+ if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== before.dev || opened.ino !== before.ino)
95
+ throw new FsError("file_changed");
96
+ if (process.platform === "linux") {
97
+ const actual = await realpath(`/proc/self/fd/${handle.fd}`);
98
+ assertViewablePath(actual);
99
+ if (actual !== file)
100
+ throw new FsError("file_changed");
101
+ }
102
+ if (await safeResolve(root, rel) !== file)
103
+ throw new FsError("file_changed");
104
+ const buffer = Buffer.alloc(MAX_FILE_BYTES);
105
+ let length = 0;
106
+ while (length < buffer.length) {
107
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
108
+ if (bytesRead === 0)
109
+ break;
110
+ length += bytesRead;
111
+ }
112
+ const after = await handle.stat({ bigint: true });
113
+ const current = await lstat(file, { bigint: true });
114
+ if (await safeResolve(root, rel) !== file || !current.isFile() || current.nlink !== 1n
115
+ || current.dev !== opened.dev || current.ino !== opened.ino || after.nlink !== 1n
116
+ || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs)
117
+ throw new FsError("file_changed");
118
+ const bytes = buffer.subarray(0, length);
119
+ if (bytes.subarray(0, Math.min(length, 8192)).includes(0))
120
+ throw new FsError("binary file not viewable");
121
+ return { path: rel.replace(/^\/+/, ""), content: bytes.toString("utf8"), truncated: opened.size > BigInt(length), size: Number(opened.size) };
122
+ }
123
+ catch (error) {
124
+ if (error instanceof FsError)
125
+ throw error;
126
+ throw new FsError("file_changed");
127
+ }
128
+ finally {
129
+ await handle.close();
130
+ }
80
131
  }
package/dist/workspace.js CHANGED
@@ -205,7 +205,7 @@ export async function rotateAgentSession(sessionDir) {
205
205
  * 第一次准备工作区时写入;之后 agent 自己维护(系统提示词里有协议)。
206
206
  * 建 agent 时填了 description,则用它种子化 ## Role(否则留占位符,由 agent 自填)。
207
207
  */
208
- function memorySeed(handle, description) {
208
+ export function memorySeed(handle, description) {
209
209
  const role = description?.trim()
210
210
  ? description.trim()
211
211
  : "(填写你的身份与职责,保持在几行以内。)";
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.88",
3
+ "version": "0.6.90",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
7
7
  "bin": {
8
- "crew-daemon": "./dist/main.js"
8
+ "daemon": "./dist/main.js",
9
+ "crew-daemon": "./dist/main.js",
10
+ "nowwork-computer": "./dist/computer-main.js"
9
11
  },
10
12
  "files": [
11
13
  "dist"
@@ -18,9 +20,10 @@
18
20
  },
19
21
  "dependencies": {
20
22
  "@agentclientprotocol/sdk": "1.2.1",
21
- "@nowcrew/cli": "0.4.16",
23
+ "@nowcrew/cli": "0.4.17",
22
24
  "cross-spawn": "^7.0.6",
23
25
  "semver": "7.8.5",
26
+ "smol-toml": "1.8.0",
24
27
  "ws": "^8",
25
28
  "yaml": "^2.8.1",
26
29
  "zod": "^3.23.0"
@@ -40,7 +43,6 @@
40
43
  "scripts": {
41
44
  "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
42
45
  "build": "tsc -p tsconfig.json",
43
- "codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
44
46
  "test": "vitest run",
45
47
  "typecheck": "tsc --noEmit"
46
48
  }
@@ -1,7 +0,0 @@
1
- import { awaitWithCancellation } from "./runtime-cancellation.js";
2
- import { materializeDefaultCodexHome } from "./runtimes/codex-home.js";
3
- export async function materializePrivateCodexHome(input) {
4
- if (input.runtime !== "codex" || input.mode !== "private")
5
- return;
6
- await awaitWithCancellation((input.materialize ?? materializeDefaultCodexHome)(input.homeDir, input.sourceHome, input.sessionId), input.cancellation);
7
- }
@@ -1,68 +0,0 @@
1
- import { z } from "zod";
2
- import { daemonHome, loadProfile, saveProfile } from "./computer-profile.js";
3
- const MessageSchema = z.object({
4
- type: z.literal("daemon:codex-home-mode"),
5
- operationId: z.string().uuid(),
6
- mode: z.enum(["shared", "private"]),
7
- }).strict();
8
- export function createCodexHomeModeController(input) {
9
- let running = null;
10
- const handled = new Set();
11
- const fail = (operationId, errorCode) => {
12
- input.sendStatus({ type: "daemon:codex-home-mode-status", operationId, status: "failed", errorCode });
13
- };
14
- const execute = async (message) => {
15
- if (input.profileName === undefined) {
16
- fail(message.operationId, "profile_unavailable");
17
- return;
18
- }
19
- if (input.isBusy()) {
20
- fail(message.operationId, "busy");
21
- return;
22
- }
23
- input.sendStatus({ type: "daemon:codex-home-mode-status", operationId: message.operationId, status: "saving" });
24
- try {
25
- const home = input.profileHome ?? daemonHome();
26
- const profile = await loadProfile(input.profileName, home);
27
- await saveProfile({ ...profile, codexHomeMode: message.mode }, home);
28
- }
29
- catch {
30
- fail(message.operationId, "save_failed");
31
- return;
32
- }
33
- input.sendStatus({ type: "daemon:codex-home-mode-status", operationId: message.operationId, status: "restarting" });
34
- try {
35
- const serviceSpec = await input.getServiceSpec();
36
- if (serviceSpec === null) {
37
- fail(message.operationId, "restart_failed");
38
- return;
39
- }
40
- const handoff = input.scheduleRestart(serviceSpec);
41
- void handoff.failed.then(() => fail(message.operationId, "restart_failed"), () => fail(message.operationId, "restart_failed"));
42
- }
43
- catch {
44
- fail(message.operationId, "restart_failed");
45
- }
46
- };
47
- return {
48
- handle: async (raw) => {
49
- const parsed = MessageSchema.safeParse(raw);
50
- if (!parsed.success)
51
- return false;
52
- if (handled.has(parsed.data.operationId))
53
- return true;
54
- if (running !== null) {
55
- fail(parsed.data.operationId, "busy");
56
- handled.add(parsed.data.operationId);
57
- return true;
58
- }
59
- running = execute(parsed.data).finally(() => {
60
- handled.add(parsed.data.operationId);
61
- running = null;
62
- });
63
- await running;
64
- return true;
65
- },
66
- drain: async () => { await running; },
67
- };
68
- }
@@ -1,211 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { isAbsolute, relative, resolve } from "node:path";
5
- import { z } from "zod";
6
- import { loadProfile } from "./computer-profile.js";
7
- import { builtDaemonEntry } from "./computer-cli.js";
8
- import { installDaemonAutomatically } from "./automatic-daemon-installer.js";
9
- import { buildServiceSpec, captureExactService, serviceStatus, systemCommandRunner } from "./computer-service.js";
10
- import { readManagedServiceRegistry } from "./managed-service-registry.js";
11
- import { uninstallManagedService } from "./managed-service-lifecycle.js";
12
- import { resolveAutomaticInstallTarget } from "./automatic-install-target.js";
13
- import { buildStorageMigrationMappings, createSafeServiceManifest, CredentialMaterialError, } from "./storage-migration-manifest.js";
14
- const MigrationMessageSchema = z.object({
15
- type: z.literal("daemon:migrate"),
16
- migrationId: z.string().uuid(),
17
- targetProfile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/),
18
- targetDaemonHome: z.string().min(1),
19
- targetAgentsRoot: z.string().min(1),
20
- }).strict();
21
- function migrationBackupRoot(input, id) {
22
- return resolve(input.backupRoot ?? resolve(input.userHome ?? homedir(), ".crew", "migration-backups"), id);
23
- }
24
- function legacyLocalHome(input) {
25
- return resolve(input.userHome ?? homedir(), ".crew");
26
- }
27
- async function assertEmptyOrMissing(path) {
28
- try {
29
- const entries = await readdir(path);
30
- if (entries.length > 0)
31
- throw new Error(`target path is not empty: ${path}`);
32
- }
33
- catch (error) {
34
- if (error.code !== "ENOENT")
35
- throw error;
36
- }
37
- }
38
- async function hashedFiles(root, directory = root) {
39
- const entries = await readdir(directory, { withFileTypes: true });
40
- const files = [];
41
- for (const entry of entries) {
42
- const path = resolve(directory, entry.name);
43
- const stat = await lstat(path);
44
- if (stat.isSymbolicLink())
45
- throw new Error(`backup source contains a symbolic link: ${relative(root, path)}`);
46
- if (stat.isDirectory())
47
- files.push(...await hashedFiles(root, path));
48
- else if (stat.isFile())
49
- files.push({ path: relative(root, path), sha256: createHash("sha256").update(await readFile(path)).digest("hex") });
50
- }
51
- return files;
52
- }
53
- async function backupAndCopy(input, id, targetHome, targetAgents, service) {
54
- // Validate and project service identity before creating any backup bytes. A descriptor containing
55
- // a token must stop the migration before it can be copied into the local backup root.
56
- const safeService = createSafeServiceManifest({
57
- spec: service.spec,
58
- snapshot: service.snapshot,
59
- managedRecord: service.managedRecord,
60
- });
61
- const backup = migrationBackupRoot(input, id);
62
- await mkdir(backup, { recursive: true, mode: 0o700 });
63
- await cp(input.currentDaemonHome, resolve(backup, "daemon-home"), { recursive: true, force: false });
64
- await cp(input.currentAgentsRoot, resolve(backup, "agents-root"), { recursive: true, force: false });
65
- await cp(input.currentDaemonHome, targetHome, { recursive: true, force: false });
66
- await cp(input.currentAgentsRoot, targetAgents, { recursive: true, force: false });
67
- const daemonFiles = await hashedFiles(resolve(backup, "daemon-home"));
68
- const agentFiles = await hashedFiles(resolve(backup, "agents-root"));
69
- const mappings = buildStorageMigrationMappings({ daemonFiles, agentFiles });
70
- await writeFile(resolve(backup, "manifest.json"), `${JSON.stringify({
71
- version: 2,
72
- migrationId: id,
73
- profile: input.profileName,
74
- daemonHome: input.currentDaemonHome,
75
- agentsRoot: input.currentAgentsRoot,
76
- service: safeService,
77
- mappings,
78
- }, null, 2)}\n`, { mode: 0o600 });
79
- return backup;
80
- }
81
- async function cleanupCurrentServiceDefault(spec, input) {
82
- const userHome = input.userHome ?? homedir();
83
- const records = await readManagedServiceRegistry({ userHome, localHome: legacyLocalHome(input) });
84
- const record = records.find((candidate) => candidate.profile === spec.profile && candidate.daemonHome === input.currentDaemonHome);
85
- if (record !== undefined && (spec.platform === "darwin" || spec.platform === "linux")) {
86
- await uninstallManagedService({ spec, record, runner: systemCommandRunner, registry: { userHome, localHome: legacyLocalHome(input) } });
87
- return;
88
- }
89
- // Windows and legacy descriptors are handled by the normal CLI/service lifecycle.
90
- const { uninstallService } = await import("./computer-service.js");
91
- const status = await serviceStatus(spec, systemCommandRunner);
92
- if (status.installed)
93
- await uninstallService(spec, systemCommandRunner);
94
- }
95
- export function createDaemonMigrationController(input) {
96
- const handled = new Set();
97
- let running = null;
98
- const sendFailure = (id, errorCode, error) => {
99
- input.sendStatus({ type: "daemon:migration-status", migrationId: id, status: "failed", errorCode, errorMessage: error instanceof Error ? error.message : String(error) });
100
- };
101
- const execute = async (raw) => {
102
- const parsed = MigrationMessageSchema.safeParse(raw);
103
- if (!parsed.success)
104
- return false;
105
- const message = parsed.data;
106
- if (handled.has(message.migrationId))
107
- return true;
108
- if (running !== null) {
109
- sendFailure(message.migrationId, "target_conflict", new Error("another migration is already running"));
110
- return true;
111
- }
112
- running = (async () => {
113
- input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "preflight" });
114
- if (input.isBusy()) {
115
- sendFailure(message.migrationId, "runtime_busy", new Error("active executions must finish before migration"));
116
- return;
117
- }
118
- const platform = input.platform ?? process.platform;
119
- const userHome = input.userHome ?? homedir();
120
- let targetPath;
121
- let targetAgents;
122
- let currentSpec;
123
- let currentService;
124
- let managedRecord = null;
125
- let currentRegistryUrl;
126
- try {
127
- const currentProfile = await (input.loadCurrentProfile ?? loadProfile)(input.profileName, input.currentDaemonHome);
128
- if (currentProfile.machineToken !== input.machineToken)
129
- throw new Error("active profile token does not match this daemon");
130
- currentRegistryUrl = currentProfile.registryUrl;
131
- const target = resolveAutomaticInstallTarget({
132
- serverUrl: input.serverUrl,
133
- requestedProfile: message.targetProfile,
134
- userHome,
135
- platform,
136
- // Layout migration creates the canonical service in the new local-home
137
- // root. Source registry and rollback material remain under legacy .crew.
138
- requestedLocalHomeMode: "nowwork",
139
- });
140
- if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
141
- || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
142
- || resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
143
- throw new Error("requested target does not match the standard profile layout");
144
- targetPath = resolve(target.daemonHome);
145
- targetAgents = resolve(target.agentsRoot);
146
- await assertEmptyOrMissing(targetPath);
147
- await assertEmptyOrMissing(targetAgents);
148
- currentSpec = buildServiceSpec({ platform, profile: input.profileName, userHome, uid: process.getuid?.(), nodePath: process.execPath, entryPath: builtDaemonEntry(), profileHome: input.currentDaemonHome });
149
- const exact = await (input.captureCurrentService ?? ((spec) => captureExactService(spec, systemCommandRunner)))(currentSpec);
150
- if (!exact.installed || !exact.running || !exact.exact)
151
- throw new Error("current managed service identity is not exact and running");
152
- currentService = { installed: exact.installed, running: exact.running };
153
- if (platform === "darwin" || platform === "linux") {
154
- const records = await (input.readManagedRecords ?? (() => readManagedServiceRegistry({ userHome, localHome: legacyLocalHome(input) })))();
155
- managedRecord = records.find((record) => record.serviceId === currentSpec.id && record.profile === input.profileName && record.daemonHome === input.currentDaemonHome) ?? null;
156
- if (managedRecord === null)
157
- throw new Error("current managed service registry record is missing");
158
- }
159
- }
160
- catch (error) {
161
- sendFailure(message.migrationId, error instanceof CredentialMaterialError ? "credential_material" : "target_conflict", error);
162
- return;
163
- }
164
- input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "backing_up" });
165
- let backupPath;
166
- try {
167
- backupPath = await backupAndCopy(input, message.migrationId, targetPath, targetAgents, { spec: currentSpec, snapshot: currentService, managedRecord });
168
- input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "installing", backupPath });
169
- }
170
- catch (error) {
171
- await Promise.allSettled([rm(targetPath, { recursive: true, force: true }), rm(targetAgents, { recursive: true, force: true })]);
172
- sendFailure(message.migrationId, error instanceof CredentialMaterialError ? "credential_material" : "backup_failed", error);
173
- return;
174
- }
175
- const install = input.install ?? installDaemonAutomatically;
176
- try {
177
- const result = await install({
178
- serverUrl: input.serverUrl,
179
- machineToken: input.machineToken,
180
- requestedProfile: message.targetProfile,
181
- requestedLocalHomeMode: "nowwork",
182
- ...(currentRegistryUrl === undefined ? {} : { requestedRegistryUrl: currentRegistryUrl }),
183
- });
184
- if (result.profile !== message.targetProfile || result.serviceId.length === 0) {
185
- throw new Error("target service identity did not match the requested profile");
186
- }
187
- }
188
- catch (error) {
189
- await Promise.allSettled([rm(targetPath, { recursive: true, force: true }), rm(targetAgents, { recursive: true, force: true })]);
190
- sendFailure(message.migrationId, "install_failed", error);
191
- return;
192
- }
193
- input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "restarting" });
194
- try {
195
- await (input.cleanupCurrentService ?? ((spec) => cleanupCurrentServiceDefault(spec, input)))(currentSpec);
196
- }
197
- catch (error) {
198
- const targetSpec = buildServiceSpec({ platform, profile: message.targetProfile, userHome, uid: process.getuid?.(), nodePath: process.execPath, entryPath: builtDaemonEntry(), profileHome: targetPath });
199
- await (input.cleanupTargetService ?? ((spec) => cleanupCurrentServiceDefault(spec, { ...input, profileName: message.targetProfile, currentDaemonHome: targetPath })))(targetSpec).catch(() => undefined);
200
- sendFailure(message.migrationId, "service_cleanup_failed", error);
201
- return;
202
- }
203
- })().finally(() => {
204
- handled.add(message.migrationId);
205
- running = null;
206
- });
207
- await running;
208
- return true;
209
- };
210
- return { handle: execute, drain: async () => { await running; } };
211
- }
@@ -1,22 +0,0 @@
1
- import { daemonHome } from "./computer-profile.js";
2
- import { createDaemonMigrationController } from "./daemon-migration-controller.js";
3
- export function createServeMigrationController(input) {
4
- if (input.profileName === undefined)
5
- return null;
6
- return createDaemonMigrationController({
7
- serverUrl: input.config.serverUrl,
8
- machineToken: input.config.machineToken,
9
- profileName: input.profileName,
10
- currentDaemonHome: daemonHome(),
11
- currentAgentsRoot: input.config.agentsRoot,
12
- isBusy: input.isBusy,
13
- sendStatus: input.sendStatus,
14
- });
15
- }
16
- export async function handleMigrationControlMessage(controller, decoded) {
17
- if (controller === null || typeof decoded !== "object" || decoded === null
18
- || !("type" in decoded) || decoded.type !== "daemon:migrate")
19
- return false;
20
- await controller.handle(decoded);
21
- return true;
22
- }
@@ -1,26 +0,0 @@
1
- #!/usr/bin/env node
2
- import { parseArgs } from "node:util";
3
- import { migrateCodexHome } from "./codex-home-migration.js";
4
- const { values } = parseArgs({
5
- args: process.argv.slice(2),
6
- strict: true,
7
- options: {
8
- "agent-home": { type: "string" },
9
- "source-home": { type: "string" },
10
- "backup-root": { type: "string" },
11
- yes: { type: "boolean", default: false },
12
- },
13
- });
14
- function required(value, name) {
15
- if (!value?.trim())
16
- throw new Error(`Missing --${name}`);
17
- return value;
18
- }
19
- if (!values.yes)
20
- throw new Error("Refusing Codex home migration without --yes");
21
- const result = await migrateCodexHome({
22
- agentHome: required(values["agent-home"], "agent-home"),
23
- sourceHome: required(values["source-home"], "source-home"),
24
- backupRoot: required(values["backup-root"], "backup-root"),
25
- });
26
- process.stdout.write(`${result.status}${result.backupDir ? ` backup=${result.backupDir}` : ""}\n`);