@nowcrew/daemon 0.6.64 → 0.6.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-ability/materializer.js +20 -1
- package/dist/agent-ability/runtime.js +3 -0
- package/dist/automatic-install-target.js +15 -10
- package/dist/computer-cli.js +10 -1
- package/dist/computer-service.js +41 -1
- package/dist/config.js +44 -0
- package/dist/daemon-migration-controller.js +16 -18
- package/dist/execution-runner.js +3 -1
- package/dist/local-execution-prompt.js +14 -9
- package/dist/local-executor.js +56 -3
- package/dist/local-memory/index-budget.js +74 -0
- package/dist/local-memory/pending.js +111 -0
- package/dist/local-memory/runtime.js +87 -0
- package/dist/local-memory/types.js +1 -0
- package/dist/local-memory/write.js +448 -0
- package/dist/machine-info.js +3 -2
- package/dist/main.js +83 -0
- package/dist/managed-service-lifecycle.js +47 -2
- package/dist/managed-service-startup.js +2 -0
- package/dist/profile-layout.js +10 -4
- package/dist/project-skills/controller.js +2 -0
- package/dist/prompt.js +19 -1
- package/dist/runner.js +3 -0
- package/dist/runtimes/codex-app-server-runner.js +54 -14
- package/dist/runtimes/codex-command-safety.js +75 -0
- package/dist/runtimes/codex-contract.js +20 -5
- package/dist/runtimes/codex-turn-retry.js +69 -0
- package/dist/runtimes/codex.js +4 -0
- package/dist/serve.js +68 -3
- package/dist/storage-config.js +175 -0
- package/dist/storage-control.js +193 -0
- package/dist/storage-copy.js +609 -0
- package/dist/storage-credential-guard.js +21 -0
- package/dist/storage-doctor.js +228 -0
- package/dist/storage-identity.js +29 -0
- package/dist/storage-layout.js +143 -0
- package/dist/storage-manifest.js +855 -0
- package/dist/storage-migration-controller.js +417 -0
- package/dist/storage-migration-manifest.js +105 -0
- package/dist/storage-migration-policy.js +100 -0
- package/dist/storage-runtime-controller.js +339 -0
- package/dist/storage-runtime-readiness.js +184 -0
- package/dist/storage-runtime.js +39 -0
- package/dist/storage-safe-fs.js +302 -0
- package/dist/storage-serve-adapter.js +65 -0
- package/dist/storage-status-delivery.js +209 -0
- package/dist/storage-status-replay.js +125 -0
- package/dist/storage-target-confirmation.js +129 -0
- package/dist/storage-worker-command.js +27 -0
- package/dist/storage-worker-executor.js +384 -0
- package/dist/storage-worker-handoff.js +151 -0
- package/dist/storage-worker-operation.js +284 -0
- package/dist/storage-worker-status.js +105 -0
- package/dist/supervised-runtime.js +3 -0
- package/dist/workspace.js +24 -1
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex app-server turn 级重试决策(spec Part 1)。
|
|
3
|
+
*
|
|
4
|
+
* attempt 只上报两个正交事实:retryReason(为什么值得重试)与 unsafeToRetry(本 turn 是否已有
|
|
5
|
+
* 白名单之外的工具活动)。外层循环通过 decideTurnRetry 单一出口判断,所有原因共用一个预算。
|
|
6
|
+
* 2026-08-10 事故根因:模型自己调 collaboration.interrupt_agent,Codex 把父 turn 也置为
|
|
7
|
+
* interrupted;旧代码直接 exit 130。同时旧的网关过载重试是无条件的,会重放 crew message send。
|
|
8
|
+
*/
|
|
9
|
+
import { isReadOnlyCrewCommand } from "./codex-command-safety.js";
|
|
10
|
+
/**
|
|
11
|
+
* Codex app-server v2 `codex app-server generate-json-schema` ThreadItem 枚举的全部 18 种类型,
|
|
12
|
+
* 逐一裁定安全性(2026-08-28 最终评审 F1:实测真实 app-server 0.150.1 发现 userMessage 缺失导致
|
|
13
|
+
* 所有 turn 被判不安全,回归影响过载重试与 interrupted 重试两条路径)。新增协议类型时,必须在此
|
|
14
|
+
* 表格里显式落座,不允许静默归类。
|
|
15
|
+
*
|
|
16
|
+
* safe(协议/叙述类,无副作用,共 9 种):
|
|
17
|
+
* - userMessage Codex 对我们自己发送的 prompt 的回显,每个 turn 必有
|
|
18
|
+
* - hookPrompt hook 触发的系统提示回显
|
|
19
|
+
* - agentMessage 模型最终回复文本
|
|
20
|
+
* - reasoning 模型推理过程(不透出给下游)
|
|
21
|
+
* - plan 模型计划更新
|
|
22
|
+
* - contextCompaction 上下文压缩通知
|
|
23
|
+
* - sleep 模型主动等待
|
|
24
|
+
* - enteredReviewMode 进入 review 模式通知
|
|
25
|
+
* - exitedReviewMode 退出 review 模式通知
|
|
26
|
+
* conditional(1 种,视具体内容而定):
|
|
27
|
+
* - commandExecution 仅当 isReadOnlyCrewCommand(command) 为 true 时安全
|
|
28
|
+
* unsafe(有副作用或无法判断,共 8 种 + 未知类型):
|
|
29
|
+
* - fileChange 写文件
|
|
30
|
+
* - mcpToolCall 调用外部 MCP 工具
|
|
31
|
+
* - dynamicToolCall 调用动态工具
|
|
32
|
+
* - collabAgentToolCall 协作工具(spawnAgent/interrupt_agent 等)
|
|
33
|
+
* - subAgentActivity 子 agent 活动
|
|
34
|
+
* - webSearch 联网搜索
|
|
35
|
+
* - imageView 查看图片
|
|
36
|
+
* - imageGeneration 生成图片
|
|
37
|
+
* - (任何未知/缺失 type) 一律视为不安全
|
|
38
|
+
*/
|
|
39
|
+
const NARRATIVE_ITEM_TYPES = new Set([
|
|
40
|
+
"userMessage",
|
|
41
|
+
"hookPrompt",
|
|
42
|
+
"agentMessage",
|
|
43
|
+
"reasoning",
|
|
44
|
+
"plan",
|
|
45
|
+
"contextCompaction",
|
|
46
|
+
"sleep",
|
|
47
|
+
"enteredReviewMode",
|
|
48
|
+
"exitedReviewMode",
|
|
49
|
+
]);
|
|
50
|
+
/** 白名单判定:只有叙述类 item 和白名单内的 crew 只读命令是安全的;其它一切(含未知类型)不安全。 */
|
|
51
|
+
export function itemMakesTurnUnsafe(item) {
|
|
52
|
+
if (item?.type === undefined)
|
|
53
|
+
return true;
|
|
54
|
+
if (NARRATIVE_ITEM_TYPES.has(item.type))
|
|
55
|
+
return false;
|
|
56
|
+
if (item.type === "commandExecution") {
|
|
57
|
+
return typeof item.command !== "string" || !isReadOnlyCrewCommand(item.command);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
export function decideTurnRetry(result, turnRetries, maxRetries) {
|
|
62
|
+
if (result.retryReason === undefined)
|
|
63
|
+
return { kind: "return" };
|
|
64
|
+
if (result.unsafeToRetry)
|
|
65
|
+
return { kind: "skipped", reason: result.retryReason };
|
|
66
|
+
if (turnRetries < maxRetries)
|
|
67
|
+
return { kind: "retry", reason: result.retryReason };
|
|
68
|
+
return { kind: "exhausted", reason: result.retryReason };
|
|
69
|
+
}
|
package/dist/runtimes/codex.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import spawn from "cross-spawn";
|
|
6
6
|
/** Minimum CLI whose real app-server contract supports NowCrew native project workspaces. */
|
|
7
7
|
export const CODEX_NATIVE_WORKSPACE_MIN_VERSION = "0.148.0";
|
|
8
|
+
/** Minimum CLI whose `multi_agent` collaboration tool contract was empirically verified (2026-08-28). */
|
|
9
|
+
export const CODEX_COLLABORATION_MIN_VERSION = "0.149.1";
|
|
8
10
|
// Codex CLI 原生 model_reasoning_effort 档位(codex 0.135.0 实测:非法值时 config 解析报错枚举这六档)。
|
|
9
11
|
// 注意:codex 对非法值是硬失败(进程直接退出),所以必须白名单过滤;白名单外(含 "default"、
|
|
10
12
|
// claude 专属的 "max")回落 CODEX_DEFAULT_EFFORT。
|
|
@@ -22,6 +24,8 @@ export function buildCodexArgs(input) {
|
|
|
22
24
|
? input.reasoning
|
|
23
25
|
: CODEX_DEFAULT_EFFORT;
|
|
24
26
|
args.push("-c", `model_reasoning_effort=${effort}`);
|
|
27
|
+
// 纵深防御:exec 启动器没有 initialize 握手、无法断言版本,协作工具恒关(spec 2b)。
|
|
28
|
+
args.push("-c", "features.multi_agent=false", "-c", "features.multi_agent_v2=false");
|
|
25
29
|
if (input.projectRootMarkers) {
|
|
26
30
|
args.push("-c", `project_root_markers=${JSON.stringify(input.projectRootMarkers)}`);
|
|
27
31
|
}
|
package/dist/serve.js
CHANGED
|
@@ -51,6 +51,7 @@ import { handleRuntimeProbeFrame, probeRuntimeHealth } from "./runtime-health.js
|
|
|
51
51
|
import { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
52
52
|
import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
|
|
53
53
|
import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
|
|
54
|
+
import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
54
55
|
export { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
55
56
|
// normalize.ts 的活动种类 → activity 枚举
|
|
56
57
|
const ACTIVITY_MAP = {
|
|
@@ -122,6 +123,7 @@ export function serve(config, opts = {}) {
|
|
|
122
123
|
catch { /* reconnect/timeout reconciliation handles a lost status frame */ }
|
|
123
124
|
},
|
|
124
125
|
});
|
|
126
|
+
let storageMigrationAdmissionClosed = () => false;
|
|
125
127
|
const migrationController = createServeMigrationController({
|
|
126
128
|
config,
|
|
127
129
|
...(opts.profileName === undefined ? {} : { profileName: opts.profileName }),
|
|
@@ -142,6 +144,7 @@ export function serve(config, opts = {}) {
|
|
|
142
144
|
const agentAbilityRuntime = createAgentAbilityRuntime(config.agentsRoot, {
|
|
143
145
|
...opts.agentAbility,
|
|
144
146
|
coordinator: projectionCoordinator,
|
|
147
|
+
isAdmissionClosed: () => storageMigrationAdmissionClosed(),
|
|
145
148
|
});
|
|
146
149
|
let projectSkillsController;
|
|
147
150
|
const projectSkillsReconciler = opts.projectSkills?.reconciler ?? createProjectSkillsReconciler({
|
|
@@ -161,6 +164,7 @@ export function serve(config, opts = {}) {
|
|
|
161
164
|
}
|
|
162
165
|
catch { /* 下一次 ready 或项目操作会重新发送完整快照 */ }
|
|
163
166
|
},
|
|
167
|
+
isAdmissionClosed: () => storageMigrationAdmissionClosed(),
|
|
164
168
|
reconcile: (handle, bindings) => projectSkillsReconciler.reconcile(handle, bindings),
|
|
165
169
|
ensureSnapshot: (handle, snapshot, options) => projectSkillsReconciler.ensureSnapshot(handle, snapshot, options),
|
|
166
170
|
});
|
|
@@ -175,13 +179,37 @@ export function serve(config, opts = {}) {
|
|
|
175
179
|
});
|
|
176
180
|
const machineHelloPublisher = createProjectSkillMachineHelloPublisher({
|
|
177
181
|
status: projectSkillControllerReadiness.status,
|
|
178
|
-
effective:
|
|
182
|
+
effective: (hello, status) => {
|
|
183
|
+
const effective = withProjectSkillCapabilityStatus(hello, status);
|
|
184
|
+
const configuredIdentity = opts.storageMigration?.identity;
|
|
185
|
+
const currentIdentity = typeof configuredIdentity === "function"
|
|
186
|
+
? configuredIdentity()
|
|
187
|
+
: configuredIdentity;
|
|
188
|
+
return currentIdentity === undefined ? effective : { ...effective, ...currentIdentity };
|
|
189
|
+
},
|
|
179
190
|
onSent: (hello) => log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · installed=[${hello.runtimes.join(",")}] · executable=[${hello.executionRuntimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`),
|
|
180
191
|
});
|
|
181
192
|
const initializedProjectSkills = initializedProjectSkillsReconciler(projectSkillControllerReadiness.ensure, projectSkillsReconciler);
|
|
193
|
+
const configuredCapabilities = opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform);
|
|
194
|
+
const storageCapabilities = storageMigrationCapabilities(configuredCapabilities, opts.storageMigration);
|
|
195
|
+
const storageStatusDelivery = createServeStorageStatusDelivery(opts.storageMigration, {
|
|
196
|
+
onPersistenceError: (error) => dslog("daemon.storage_status_outbox_failed", "存储迁移状态 outbox 持久化失败", { level: "ERROR", error_message: error.message }),
|
|
197
|
+
transmit: (frame) => { try {
|
|
198
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
199
|
+
ws.send(JSON.stringify(frame));
|
|
200
|
+
}
|
|
201
|
+
catch { /* retry */ } },
|
|
202
|
+
});
|
|
203
|
+
const storageMigrationRuntime = attachStorageMigrationRuntime(opts.storageMigration, {
|
|
204
|
+
isBusy: () => executionRuns.size > 0 || legacyRuns.size > 0,
|
|
205
|
+
waitForExecutionFrames: () => executionFrameQueue,
|
|
206
|
+
flushLocalState: flushSlog,
|
|
207
|
+
sendStatus: storageStatusDelivery.send,
|
|
208
|
+
});
|
|
209
|
+
storageMigrationAdmissionClosed = storageMigrationRuntime.isAdmissionClosed;
|
|
182
210
|
const prepareProjectSkillCapabilities = createProjectSkillConnectionCapabilityProvider({
|
|
183
211
|
platform: process.platform,
|
|
184
|
-
baseCapabilities:
|
|
212
|
+
baseCapabilities: storageCapabilities,
|
|
185
213
|
additionalCapabilities: () => managedDaemonCapabilities(updateEligibility),
|
|
186
214
|
probeRuntimeRootStorePrimitive: projectSkillRuntimeRootStartup.probeNativePublicationPrimitiveReadiness,
|
|
187
215
|
initializeController: projectSkillControllerReadiness.ensure,
|
|
@@ -310,6 +338,12 @@ export function serve(config, opts = {}) {
|
|
|
310
338
|
// 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
|
|
311
339
|
void drainSpool();
|
|
312
340
|
// 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
|
|
341
|
+
storageStatusDelivery.replayPending();
|
|
342
|
+
const storageReplay = replayStorageMigrationStatus(opts.storageMigration, openedSocket, () => ws === openedSocket, (error) => {
|
|
343
|
+
dslog("daemon.storage_status_replay_failed", "存储迁移状态重放失败", {
|
|
344
|
+
level: "ERROR", error_message: error.message,
|
|
345
|
+
});
|
|
346
|
+
}, storageStatusDelivery.send);
|
|
313
347
|
const { detectInstalled, detectExecutable = detectExecutionRuntimesWithSignal } = opts.machineInfo ?? {};
|
|
314
348
|
const helloPromise = (opts.machineInfo?.collect ?? collectMachineHello)(config.agentsRoot, config.executionLimits, process.platform, {
|
|
315
349
|
...(detectInstalled ? { detectInstalled } : {}),
|
|
@@ -325,6 +359,7 @@ export function serve(config, opts = {}) {
|
|
|
325
359
|
if (hello.executionProtocol === undefined)
|
|
326
360
|
return [];
|
|
327
361
|
const detected = await runtimeProbe.detect(hello.runtimes, detectExecutable);
|
|
362
|
+
await storageReplay;
|
|
328
363
|
if (ws === openedSocket && openedSocket.readyState === WebSocket.OPEN
|
|
329
364
|
&& detected.some((runtime) => !hello.executionRuntimes.includes(runtime))) {
|
|
330
365
|
detectedExecutionRuntimes = detected;
|
|
@@ -339,7 +374,8 @@ export function serve(config, opts = {}) {
|
|
|
339
374
|
return [];
|
|
340
375
|
});
|
|
341
376
|
void helloPromise
|
|
342
|
-
.then((hello) => {
|
|
377
|
+
.then(async (hello) => {
|
|
378
|
+
await storageReplay;
|
|
343
379
|
if (ws !== openedSocket || openedSocket.readyState !== WebSocket.OPEN)
|
|
344
380
|
return;
|
|
345
381
|
detectedExecutionRuntimes = hello.executionRuntimes;
|
|
@@ -387,6 +423,8 @@ export function serve(config, opts = {}) {
|
|
|
387
423
|
if (typeof decoded !== "object" || decoded === null)
|
|
388
424
|
return;
|
|
389
425
|
const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
|
|
426
|
+
if (storageStatusDelivery.acknowledge(decoded))
|
|
427
|
+
return;
|
|
390
428
|
if (rawType === "daemon:update" || rawType === "daemon:restart") {
|
|
391
429
|
void updateController.handle(decoded).catch((error) => {
|
|
392
430
|
dslog("daemon.control_failed", "daemon 控制操作处理失败", {
|
|
@@ -403,6 +441,11 @@ export function serve(config, opts = {}) {
|
|
|
403
441
|
void codexHomeModeController.handle(decoded).catch((error) => dslog("daemon.codex_home_mode_failed", "Codex Home 模式切换失败", { level: "ERROR", error_message: error.message }));
|
|
404
442
|
return;
|
|
405
443
|
}
|
|
444
|
+
if (handleStorageMigrationControl(opts.storageMigration, rawType, decoded, storageStatusDelivery.send, (kind, error) => {
|
|
445
|
+
dslog(kind === "migration" ? "daemon.storage_migration_failed" : "daemon.storage_migration_confirmation_failed", kind === "migration" ? "存储迁移控制操作失败" : "存储迁移 hello 确认处理失败", { level: "ERROR", error_message: error.message });
|
|
446
|
+
})) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
406
449
|
if (rawType.startsWith("execution:")) {
|
|
407
450
|
const parsedExecution = ServerToDaemonExecutionFrameSchema.safeParse(decoded);
|
|
408
451
|
if (!parsedExecution.success) {
|
|
@@ -464,6 +507,14 @@ export function serve(config, opts = {}) {
|
|
|
464
507
|
return;
|
|
465
508
|
}
|
|
466
509
|
const spec = frame;
|
|
510
|
+
if (storageMigrationRuntime.isAdmissionClosed()) {
|
|
511
|
+
safeExecutionSend(ExecutionRejectedSchema.parse({
|
|
512
|
+
type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
|
|
513
|
+
reason: "resource_limit", message: "Storage migration is quiescing this machine",
|
|
514
|
+
at: new Date().toISOString(),
|
|
515
|
+
}));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
467
518
|
const workspaceRejection = projectWorkspaceCapabilityRejection(spec, capabilities.executionRunner, new Date().toISOString());
|
|
468
519
|
if (workspaceRejection !== null)
|
|
469
520
|
return void safeExecutionSend(workspaceRejection);
|
|
@@ -770,6 +821,17 @@ export function serve(config, opts = {}) {
|
|
|
770
821
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
771
822
|
const r = msg;
|
|
772
823
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
824
|
+
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
|
|
825
|
+
try {
|
|
826
|
+
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
|
|
827
|
+
machineHelloPublisher.republish();
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", {
|
|
831
|
+
level: "ERROR", error_message: error.message,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
773
835
|
// 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
|
|
774
836
|
serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
|
|
775
837
|
? r.serverCapabilities.filter((c) => typeof c === "string")
|
|
@@ -868,6 +930,8 @@ export function serve(config, opts = {}) {
|
|
|
868
930
|
}
|
|
869
931
|
if (msg.type !== "agent:start")
|
|
870
932
|
return; // ready/error 等忽略
|
|
933
|
+
if (storageMigrationRuntime.isAdmissionClosed())
|
|
934
|
+
return;
|
|
871
935
|
// 任务键:scheduled run 用 runId(每次运行独立 cwd/work-log;overlap 由 server 端 skip_if_running 管,
|
|
872
936
|
// daemon 的 running 去重只兜底"同一 run 重复投递");普通唤醒仍是 线程锚点 ?? 频道。
|
|
873
937
|
const scheduled = msg.reason === "scheduled_job" && msg.scheduledRun
|
|
@@ -1283,6 +1347,7 @@ export function serve(config, opts = {}) {
|
|
|
1283
1347
|
concurrencyTelemetry.stop();
|
|
1284
1348
|
runtimeProbe.stop();
|
|
1285
1349
|
completionRetransmitter.stop();
|
|
1350
|
+
storageStatusDelivery.stop();
|
|
1286
1351
|
const deadline = createShutdownDeadline(shutdownTimeoutMs);
|
|
1287
1352
|
if (reconnectTimer !== null) {
|
|
1288
1353
|
clearTimeout(reconnectTimer);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { atomicPrivateWrite } from "./atomic-private-write.js";
|
|
5
|
+
import { resolveEnvTag, resolveNowworkHome, storageLayout, } from "./storage-layout.js";
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
7
|
+
const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
|
|
8
|
+
const PORTABLE_ABSOLUTE_PATH = z.string().min(1).max(32768).refine((value) => posix.isAbsolute(value) || win32.isAbsolute(value), "must be an absolute path");
|
|
9
|
+
const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
|
12
|
+
}, "serverUrl must be HTTP(S) without embedded credentials");
|
|
13
|
+
const InstallationRecordSchema = z.object({
|
|
14
|
+
version: z.literal(1),
|
|
15
|
+
installationId: z.string().uuid(),
|
|
16
|
+
profile: z.string().regex(PROFILE),
|
|
17
|
+
serverUrl: ServerUrlSchema,
|
|
18
|
+
environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
|
|
19
|
+
workspaceId: z.string().uuid(),
|
|
20
|
+
storageId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/u),
|
|
21
|
+
nowworkHome: PORTABLE_ABSOLUTE_PATH,
|
|
22
|
+
runtimeRoot: PORTABLE_ABSOLUTE_PATH,
|
|
23
|
+
credentialRef: z.string().regex(/^profile:[a-z0-9][a-z0-9_-]{0,47}$/u),
|
|
24
|
+
storageMigrationId: z.string().uuid().optional(),
|
|
25
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
26
|
+
updatedAt: z.string().datetime({ offset: true }),
|
|
27
|
+
}).strict();
|
|
28
|
+
const InstallationRegistrySchema = z.object({
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
installations: z.array(InstallationRecordSchema).max(256),
|
|
31
|
+
}).strict();
|
|
32
|
+
export function findInstallationForProfile(records, profile) {
|
|
33
|
+
if (!PROFILE.test(profile))
|
|
34
|
+
throw new Error("profile name is invalid");
|
|
35
|
+
const matches = records.filter((record) => record.profile === profile);
|
|
36
|
+
if (matches.length > 1)
|
|
37
|
+
throw new Error(`Multiple installations use profile '${profile}'`);
|
|
38
|
+
return matches[0] ?? null;
|
|
39
|
+
}
|
|
40
|
+
const pathsFor = (platform) => platform === "win32" ? win32 : posix;
|
|
41
|
+
function absoluteBase(value, fallback, platform, name) {
|
|
42
|
+
const paths = pathsFor(platform);
|
|
43
|
+
const selected = value ?? fallback;
|
|
44
|
+
if (!paths.isAbsolute(selected) || selected.includes("\0") || selected.includes("\n") || selected.includes("\r")) {
|
|
45
|
+
throw new Error(`${name} must be an absolute single-line path`);
|
|
46
|
+
}
|
|
47
|
+
return paths.normalize(selected);
|
|
48
|
+
}
|
|
49
|
+
export function resolveLocalStorageRoots(input) {
|
|
50
|
+
const paths = pathsFor(input.platform);
|
|
51
|
+
const userHome = absoluteBase(input.userHome, input.userHome, input.platform, "user home");
|
|
52
|
+
if (input.platform === "win32") {
|
|
53
|
+
const local = absoluteBase(input.environment.LOCALAPPDATA, paths.join(userHome, "AppData", "Local"), input.platform, "LOCALAPPDATA");
|
|
54
|
+
const root = paths.join(local, "NowWork");
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
configRoot: paths.join(root, "config"),
|
|
57
|
+
runtimeBase: paths.join(root, "runtime"),
|
|
58
|
+
cacheRoot: paths.join(root, "cache"),
|
|
59
|
+
stateRoot: paths.join(root, "state"),
|
|
60
|
+
backupsRoot: paths.join(root, "state", "backups"),
|
|
61
|
+
runtimePackagesRoot: paths.join(root, "cache", "runtime-packages"),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (input.platform === "darwin") {
|
|
65
|
+
const applicationSupport = paths.join(userHome, "Library", "Application Support", "NowWork");
|
|
66
|
+
const cacheRoot = paths.join(userHome, "Library", "Caches", "NowWork");
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
configRoot: paths.join(applicationSupport, "config"),
|
|
69
|
+
runtimeBase: paths.join(cacheRoot, "runtime"),
|
|
70
|
+
cacheRoot,
|
|
71
|
+
stateRoot: paths.join(applicationSupport, "state"),
|
|
72
|
+
backupsRoot: paths.join(applicationSupport, "state", "backups"),
|
|
73
|
+
runtimePackagesRoot: paths.join(cacheRoot, "runtime-packages"),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const configHome = absoluteBase(input.environment.XDG_CONFIG_HOME, paths.join(userHome, ".config"), input.platform, "XDG_CONFIG_HOME");
|
|
77
|
+
const cacheHome = absoluteBase(input.environment.XDG_CACHE_HOME, paths.join(userHome, ".cache"), input.platform, "XDG_CACHE_HOME");
|
|
78
|
+
const stateHome = absoluteBase(input.environment.XDG_STATE_HOME, paths.join(userHome, ".local", "state"), input.platform, "XDG_STATE_HOME");
|
|
79
|
+
const runtimeHome = absoluteBase(input.environment.XDG_RUNTIME_DIR, paths.join(stateHome, "nowwork", "runtime"), input.platform, "XDG_RUNTIME_DIR");
|
|
80
|
+
const stateRoot = paths.join(stateHome, "nowwork");
|
|
81
|
+
const cacheRoot = paths.join(cacheHome, "nowwork");
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
configRoot: paths.join(configHome, "nowwork"),
|
|
84
|
+
runtimeBase: input.environment.XDG_RUNTIME_DIR === undefined ? runtimeHome : paths.join(runtimeHome, "nowwork"),
|
|
85
|
+
cacheRoot,
|
|
86
|
+
stateRoot,
|
|
87
|
+
backupsRoot: paths.join(stateRoot, "backups"),
|
|
88
|
+
runtimePackagesRoot: paths.join(cacheRoot, "runtime-packages"),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function buildStorageConfig(input) {
|
|
92
|
+
if (!PROFILE.test(input.profile))
|
|
93
|
+
throw new Error("profile name is invalid");
|
|
94
|
+
if (!UUID.test(input.workspaceId))
|
|
95
|
+
throw new Error("workspace id must be a UUID");
|
|
96
|
+
const paths = pathsFor(input.platform);
|
|
97
|
+
const local = resolveLocalStorageRoots(input);
|
|
98
|
+
const home = resolveNowworkHome({
|
|
99
|
+
...(input.nowworkHome === undefined ? {} : { explicit: input.nowworkHome }),
|
|
100
|
+
...(input.environment.NOWWORK_HOME === undefined ? {} : { environment: input.environment.NOWWORK_HOME }),
|
|
101
|
+
userHome: input.userHome,
|
|
102
|
+
platform: input.platform,
|
|
103
|
+
});
|
|
104
|
+
const credentialRef = `profile:${input.profile}`;
|
|
105
|
+
if (home.mode === "legacy") {
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
mode: "legacy",
|
|
108
|
+
profile: input.profile,
|
|
109
|
+
workspaceId: input.workspaceId.toLowerCase(),
|
|
110
|
+
credentialRef,
|
|
111
|
+
daemonHome: input.legacyDaemonHome ?? paths.join(input.userHome, ".crew", "daemon"),
|
|
112
|
+
agentsRoot: input.legacyAgentsRoot ?? paths.join(input.userHome, ".crew", "agents"),
|
|
113
|
+
layout: null,
|
|
114
|
+
local,
|
|
115
|
+
diagnostics: Object.freeze(["legacy_storage_detected"]),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const environment = resolveEnvTag({
|
|
119
|
+
...(input.envTag === undefined ? {} : { explicit: input.envTag }),
|
|
120
|
+
...(input.environment.NOWWORK_ENV_TAG === undefined ? {} : { environment: input.environment.NOWWORK_ENV_TAG }),
|
|
121
|
+
}).environment;
|
|
122
|
+
const layout = storageLayout({
|
|
123
|
+
nowworkHome: home.root,
|
|
124
|
+
environment,
|
|
125
|
+
workspaceId: input.workspaceId,
|
|
126
|
+
runtimeBase: local.runtimeBase,
|
|
127
|
+
platform: input.platform,
|
|
128
|
+
});
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
mode: "standard",
|
|
131
|
+
profile: input.profile,
|
|
132
|
+
workspaceId: input.workspaceId.toLowerCase(),
|
|
133
|
+
credentialRef,
|
|
134
|
+
daemonHome: local.configRoot,
|
|
135
|
+
agentsRoot: layout.agentItemsRoot,
|
|
136
|
+
layout,
|
|
137
|
+
local,
|
|
138
|
+
diagnostics: Object.freeze([]),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export function installationsPath(configRoot) {
|
|
142
|
+
const paths = win32.isAbsolute(configRoot) && !posix.isAbsolute(configRoot) ? win32 : posix;
|
|
143
|
+
return paths.join(configRoot, "installations.json");
|
|
144
|
+
}
|
|
145
|
+
export async function readInstallations(input) {
|
|
146
|
+
const path = installationsPath(input.configRoot);
|
|
147
|
+
try {
|
|
148
|
+
const parsed = InstallationRegistrySchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
149
|
+
return Object.freeze(parsed.installations.map((record) => Object.freeze(record)));
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code === "ENOENT")
|
|
153
|
+
return Object.freeze([]);
|
|
154
|
+
throw new Error(`Invalid installation registry: ${path}`, { cause: error });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function writeInstallations(records, input) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = InstallationRegistrySchema.parse({ version: 1, installations: records });
|
|
161
|
+
const installationIds = new Set();
|
|
162
|
+
const profiles = new Set();
|
|
163
|
+
for (const record of parsed.installations) {
|
|
164
|
+
if (installationIds.has(record.installationId) || profiles.has(record.profile)) {
|
|
165
|
+
throw new Error("duplicate installation identity");
|
|
166
|
+
}
|
|
167
|
+
installationIds.add(record.installationId);
|
|
168
|
+
profiles.add(record.profile);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw new Error("Invalid installation metadata", { cause: error });
|
|
173
|
+
}
|
|
174
|
+
await atomicPrivateWrite(installationsPath(input.configRoot), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
175
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const STORAGE_MIGRATION_CAPABILITY = "storage_migration_v1";
|
|
3
|
+
export const STORAGE_MIGRATION_STATUS_CAPABILITY = "storage_migration_status_v1";
|
|
4
|
+
const IdentitySchema = z.object({
|
|
5
|
+
migrationId: z.string().uuid(),
|
|
6
|
+
environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
|
|
7
|
+
workspaceId: z.string().uuid(),
|
|
8
|
+
});
|
|
9
|
+
const DoctorRequestSchema = IdentitySchema.extend({ type: z.literal("daemon:storage-doctor") }).strict();
|
|
10
|
+
const MigrateRequestSchema = IdentitySchema.extend({
|
|
11
|
+
type: z.literal("daemon:storage-migrate"),
|
|
12
|
+
manifestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
13
|
+
}).strict();
|
|
14
|
+
const StatusSchema = z.enum([
|
|
15
|
+
"preflight_running", "preflight_passed", "backing_up", "initial_sync", "quiescing", "final_sync", "verified",
|
|
16
|
+
"service_switched", "hello_confirmed", "completed", "failed", "rollback_pending", "rolled_back",
|
|
17
|
+
"rollback_failed",
|
|
18
|
+
]);
|
|
19
|
+
const TOKEN = /(?:\bsk_(?:machine|user|agent)_[A-Za-z0-9_-]{4,}\b|\bbearer\s+[^\s]+|(?:password|secret|token)\s*[:=]\s*[^\s]+)/giu;
|
|
20
|
+
const safeError = (error) => (error instanceof Error ? error.message : String(error))
|
|
21
|
+
.replaceAll(TOKEN, "<redacted>").slice(0, 500) || "storage migration failed";
|
|
22
|
+
const stableErrorCode = (candidate, fallback) => typeof candidate === "string" && /^[a-z0-9._-]{1,128}$/u.test(candidate) ? candidate : fallback;
|
|
23
|
+
export function createStorageControlController(input) {
|
|
24
|
+
const environment = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u).parse(input.environment);
|
|
25
|
+
const workspaceId = z.string().uuid().parse(input.workspaceId);
|
|
26
|
+
const profile = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u).parse(input.profile);
|
|
27
|
+
const sourceStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.sourceStorageFingerprint);
|
|
28
|
+
const targetStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.targetStorageFingerprint);
|
|
29
|
+
const preflights = new Map();
|
|
30
|
+
const preflightRuns = new Map();
|
|
31
|
+
const appliedMigrationIds = new Set();
|
|
32
|
+
let runningMigrationId = null;
|
|
33
|
+
const preflightTimeoutMs = z.number().int().positive().max(24 * 60 * 60 * 1000)
|
|
34
|
+
.parse(input.preflightTimeoutMs ?? 30 * 60 * 1000);
|
|
35
|
+
const frame = (identity, status, extra = {}) => ({
|
|
36
|
+
type: "daemon:storage-migration-status",
|
|
37
|
+
migrationId: identity.migrationId,
|
|
38
|
+
status,
|
|
39
|
+
environment,
|
|
40
|
+
workspaceId,
|
|
41
|
+
profile,
|
|
42
|
+
...extra,
|
|
43
|
+
});
|
|
44
|
+
const failure = (identity, errorCode, error, reopenAdmission = false, extra = {}) => {
|
|
45
|
+
input.sendStatus(frame(identity, "failed", {
|
|
46
|
+
errorCode,
|
|
47
|
+
...extra,
|
|
48
|
+
...(error === undefined ? {} : { errorMessage: safeError(error) }),
|
|
49
|
+
...(reopenAdmission || error?.reopenAdmission === true
|
|
50
|
+
? { reopenAdmission: true }
|
|
51
|
+
: {}),
|
|
52
|
+
}));
|
|
53
|
+
};
|
|
54
|
+
const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId;
|
|
55
|
+
const preflightKey = (identity) => `${identity.migrationId}\0${identity.environment}\0${identity.workspaceId}`;
|
|
56
|
+
const runPreflight = (identity, failureFallback = "doctor_failed") => {
|
|
57
|
+
const key = preflightKey(identity);
|
|
58
|
+
const existing = preflightRuns.get(key);
|
|
59
|
+
if (existing !== undefined)
|
|
60
|
+
return existing;
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let scannedEntries = 0;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
input.sendStatus(frame(identity, "preflight_running", {
|
|
65
|
+
progress: { state: "running", scannedEntries: 0 },
|
|
66
|
+
}));
|
|
67
|
+
const doctorPromise = Promise.resolve().then(() => input.runDoctor(identity.migrationId, {
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
onProgress: (progress) => {
|
|
70
|
+
if (timedOut)
|
|
71
|
+
return;
|
|
72
|
+
scannedEntries = progress.scannedEntries;
|
|
73
|
+
input.sendStatus(frame(identity, "preflight_running", {
|
|
74
|
+
progress: { state: "running", scannedEntries },
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
let timeoutHandle;
|
|
79
|
+
const timeout = new Promise((_, reject) => {
|
|
80
|
+
timeoutHandle = setTimeout(() => {
|
|
81
|
+
timedOut = true;
|
|
82
|
+
controller.abort();
|
|
83
|
+
reject(Object.assign(new Error("storage doctor preflight timed out"), {
|
|
84
|
+
code: "preflight_stale",
|
|
85
|
+
scannedEntries,
|
|
86
|
+
}));
|
|
87
|
+
}, preflightTimeoutMs);
|
|
88
|
+
timeoutHandle.unref?.();
|
|
89
|
+
});
|
|
90
|
+
void doctorPromise.catch(() => undefined);
|
|
91
|
+
let outcome;
|
|
92
|
+
outcome = Promise.race([doctorPromise, timeout]).then((doctor) => {
|
|
93
|
+
if (doctor.manifest.migrationId !== identity.migrationId
|
|
94
|
+
|| doctor.environment !== environment || doctor.workspaceId !== workspaceId
|
|
95
|
+
|| doctor.profile !== profile) {
|
|
96
|
+
failure(identity, "storage_identity_mismatch", undefined, true);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!doctor.applyAllowed) {
|
|
100
|
+
failure(identity, doctor.blockers[0]?.code ?? "doctor_blocked", undefined, true);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
preflights.set(identity.migrationId, doctor);
|
|
104
|
+
input.sendStatus(frame(identity, "preflight_passed", {
|
|
105
|
+
sourceStorageFingerprint,
|
|
106
|
+
targetStorageFingerprint,
|
|
107
|
+
manifestDigest: doctor.manifestDigest,
|
|
108
|
+
}));
|
|
109
|
+
return doctor;
|
|
110
|
+
}).catch((error) => {
|
|
111
|
+
const code = error.code;
|
|
112
|
+
if (code === "preflight_stale") {
|
|
113
|
+
failure(identity, "preflight_stale", undefined, true, {
|
|
114
|
+
retryable: true,
|
|
115
|
+
progress: { state: "stale", scannedEntries: Number(error.scannedEntries) || scannedEntries },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else if (code === "preflight_aborted") {
|
|
119
|
+
failure(identity, "preflight_aborted", error, true, {
|
|
120
|
+
retryable: true,
|
|
121
|
+
progress: { state: "stale", scannedEntries },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
failure(identity, stableErrorCode(code, failureFallback), error, true);
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}).finally(() => {
|
|
129
|
+
if (timeoutHandle !== undefined)
|
|
130
|
+
clearTimeout(timeoutHandle);
|
|
131
|
+
if (preflightRuns.get(key) === outcome)
|
|
132
|
+
preflightRuns.delete(key);
|
|
133
|
+
});
|
|
134
|
+
preflightRuns.set(key, outcome);
|
|
135
|
+
return outcome;
|
|
136
|
+
};
|
|
137
|
+
const handle = async (raw) => {
|
|
138
|
+
const type = typeof raw === "object" && raw !== null && "type" in raw ? raw.type : null;
|
|
139
|
+
if (type !== "daemon:storage-doctor" && type !== "daemon:storage-migrate")
|
|
140
|
+
return false;
|
|
141
|
+
const schema = type === "daemon:storage-doctor" ? DoctorRequestSchema : MigrateRequestSchema;
|
|
142
|
+
const parsed = schema.safeParse(raw);
|
|
143
|
+
if (!parsed.success)
|
|
144
|
+
return true;
|
|
145
|
+
const identity = parsed.data;
|
|
146
|
+
if (!matchesIdentity(identity)) {
|
|
147
|
+
failure(identity, "storage_identity_mismatch", undefined, true);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (type === "daemon:storage-doctor") {
|
|
151
|
+
await runPreflight(identity);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (runningMigrationId !== null) {
|
|
155
|
+
if (runningMigrationId !== identity.migrationId)
|
|
156
|
+
failure(identity, "migration_active");
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const migrate = MigrateRequestSchema.parse(identity);
|
|
160
|
+
if (appliedMigrationIds.has(identity.migrationId))
|
|
161
|
+
return true;
|
|
162
|
+
let doctor = preflights.get(identity.migrationId);
|
|
163
|
+
if (doctor === undefined) {
|
|
164
|
+
const rebuilt = await runPreflight(identity, "preflight_rebuild_failed");
|
|
165
|
+
if (rebuilt === null)
|
|
166
|
+
return true;
|
|
167
|
+
doctor = rebuilt;
|
|
168
|
+
}
|
|
169
|
+
if (!doctor.applyAllowed || doctor.manifestDigest !== migrate.manifestDigest
|
|
170
|
+
|| doctor.manifest.migrationId !== identity.migrationId
|
|
171
|
+
|| doctor.environment !== environment || doctor.workspaceId !== workspaceId || doctor.profile !== profile) {
|
|
172
|
+
failure(identity, "preflight_digest_mismatch", undefined, true);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
preflights.delete(identity.migrationId);
|
|
176
|
+
runningMigrationId = identity.migrationId;
|
|
177
|
+
try {
|
|
178
|
+
await input.runMigration(doctor, (status) => {
|
|
179
|
+
if (StatusSchema.safeParse(status).success)
|
|
180
|
+
input.sendStatus(frame(identity, status));
|
|
181
|
+
});
|
|
182
|
+
appliedMigrationIds.add(identity.migrationId);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
failure(identity, stableErrorCode(error.code, "storage_migration_failed"), error);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
runningMigrationId = null;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
return Object.freeze({ handle });
|
|
193
|
+
}
|