@nowcrew/daemon 0.5.14 → 0.5.16
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/README.md +57 -3
- package/dist/bound-im-decision.js +22 -0
- package/dist/computer-cli.js +214 -0
- package/dist/computer-profile.js +195 -0
- package/dist/computer-service.js +358 -0
- package/dist/config.js +2 -1
- package/dist/console.js +10 -0
- package/dist/execution-backend.js +11 -0
- package/dist/execution-protocol.js +11 -0
- package/dist/execution-runner.js +38 -11
- package/dist/execution-supervisor.js +48 -5
- package/dist/execution-telemetry-journal.js +71 -0
- package/dist/i18n.js +25 -0
- package/dist/local-executor.js +5 -2
- package/dist/machine-info.js +25 -4
- package/dist/main.js +15 -0
- package/dist/normalize.js +11 -0
- package/dist/origin-decision.js +1 -1
- package/dist/prompt.js +40 -21
- package/dist/runner.js +25 -1
- package/dist/runtime-capabilities.js +5 -0
- package/dist/runtimes/kimi-acp-runner.js +264 -0
- package/dist/runtimes/kimi.js +10 -0
- package/dist/scheduled-report.js +4 -0
- package/dist/scheduled-run-report.js +1 -0
- package/dist/serve.js +55 -10
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -15,6 +15,57 @@ The process stays resident and reconnects with backoff. For local development, r
|
|
|
15
15
|
`pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
|
|
16
16
|
manual one-shot entry.
|
|
17
17
|
|
|
18
|
+
## Computer Profiles And Service Lifecycle
|
|
19
|
+
|
|
20
|
+
Persist a named connection without putting its machine token in shell history or service arguments:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
printf '%s' "$CREW_MACHINE_TOKEN" | crew-daemon profile save work \
|
|
24
|
+
--server-url https://nowwork.example --token-stdin
|
|
25
|
+
crew-daemon profile list
|
|
26
|
+
crew-daemon profile show work # reports configured=true; never prints the token
|
|
27
|
+
crew-daemon serve --profile work
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Profile files live under `~/.crew/daemon/profiles`. Unix writes use mode `0600`; Windows tokens are
|
|
31
|
+
encrypted with CurrentUser DPAPI and the profile ACL is restricted to the current user. Windows refuses
|
|
32
|
+
legacy plaintext profiles and fails the save if DPAPI or ACL hardening is unavailable.
|
|
33
|
+
|
|
34
|
+
Install and operate the profile as a user service:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install --global @nowcrew/daemon@latest
|
|
38
|
+
crew-daemon install --profile work
|
|
39
|
+
crew-daemon start --profile work
|
|
40
|
+
crew-daemon status --profile work
|
|
41
|
+
crew-daemon doctor --profile work
|
|
42
|
+
crew-daemon restart --profile work
|
|
43
|
+
crew-daemon stop --profile work
|
|
44
|
+
crew-daemon uninstall --profile work
|
|
45
|
+
crew-daemon profile remove work
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Lifecycle installation is intentionally refused from an `npx`/temporary cache entry because the service
|
|
49
|
+
must keep a stable executable path across cache cleanup and upgrades.
|
|
50
|
+
|
|
51
|
+
macOS uses a user LaunchAgent, Linux uses a systemd user unit, and Windows uses Task Scheduler at logon.
|
|
52
|
+
On macOS `install` writes the plist, `start` bootstraps it, and `stop` boots it out, so `KeepAlive` cannot
|
|
53
|
+
silently resurrect a stopped daemon. Always use `status` as the source of truth instead of assuming that
|
|
54
|
+
an install or start request proves the process is running.
|
|
55
|
+
Profiles also capture the install-time runtime `PATH`, so user-installed CLIs remain discoverable under
|
|
56
|
+
minimal service-manager environments. Service descriptions contain only `serve --profile work` and the
|
|
57
|
+
non-secret profile directory; the token and PATH are read by the daemon at runtime and never appear in service arguments. Lifecycle commands fail
|
|
58
|
+
when the native manager rejects an operation. Windows status uses the
|
|
59
|
+
locale-independent `Get-ScheduledTask.State` enum and reports healthy only for `Running`. Its scheduled
|
|
60
|
+
task has no execution time limit, runs on battery, and restarts after failures.
|
|
61
|
+
|
|
62
|
+
Upgrade the global daemon package, optionally restarting one installed profile afterward:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
crew-daemon upgrade
|
|
66
|
+
crew-daemon upgrade --profile work
|
|
67
|
+
```
|
|
68
|
+
|
|
18
69
|
## Execution Boundary
|
|
19
70
|
|
|
20
71
|
The server selects the machine and sends a validated `execution:start` containing:
|
|
@@ -29,9 +80,12 @@ the local workspace/environment, and launches only a built-in runtime adapter (`
|
|
|
29
80
|
`kimi`). It does not decide collaboration rules, scheduled output policy, fallback delivery, or thread
|
|
30
81
|
behavior. Those are server responsibilities.
|
|
31
82
|
|
|
32
|
-
Protocol support and limits are advertised in `machine:hello`.
|
|
33
|
-
|
|
34
|
-
|
|
83
|
+
Protocol support and limits are advertised in `machine:hello`. `runtimes` reports every recognized CLI
|
|
84
|
+
found on `PATH`; `executionRuntimes` separately reports the installed CLIs backed by a complete built-in
|
|
85
|
+
adapter. The server must use the latter for admission and treats the former as diagnostic inventory only.
|
|
86
|
+
Old daemons that omit `executionRuntimes` are conservatively interpreted as the intersection of installed
|
|
87
|
+
CLIs and server-supported adapters. Unknown required protocol semantics are rejected before side effects.
|
|
88
|
+
Protocol-0 `agent:start` remains only for the server-governed compatibility window.
|
|
35
89
|
|
|
36
90
|
## Reliability
|
|
37
91
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function boundImDecisionFilePath(runDir) {
|
|
4
|
+
return join(runDir, ".bound-im-decision.json");
|
|
5
|
+
}
|
|
6
|
+
export async function resetBoundImDecisionFile(path) {
|
|
7
|
+
await unlink(path).catch((error) => {
|
|
8
|
+
if (error.code !== "ENOENT")
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export async function readBoundImDecisionFile(path) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
15
|
+
if (!parsed || typeof parsed !== "object")
|
|
16
|
+
return null;
|
|
17
|
+
return parsed.decision === "notify" ? { decision: "notify" } : null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { daemonHome, listProfiles, loadProfile, profileIsPrivate, profilePath, publicProfile, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
|
|
6
|
+
import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
|
|
7
|
+
import { detectDaemonLang, formatDaemonText } from "./i18n.js";
|
|
8
|
+
const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
|
|
9
|
+
export function builtDaemonEntry(moduleUrl = import.meta.url) {
|
|
10
|
+
return moduleUrl.endsWith(".js")
|
|
11
|
+
? fileURLToPath(new URL("./main.js", moduleUrl))
|
|
12
|
+
: "";
|
|
13
|
+
}
|
|
14
|
+
function defaults() {
|
|
15
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
16
|
+
return {
|
|
17
|
+
env: process.env,
|
|
18
|
+
platform: process.platform,
|
|
19
|
+
userHome: homedir(),
|
|
20
|
+
uid: process.getuid?.(),
|
|
21
|
+
nodePath: process.execPath,
|
|
22
|
+
entryPath: builtDaemonEntry(),
|
|
23
|
+
runner: systemCommandRunner,
|
|
24
|
+
...(process.platform === "win32" ? { profileProtector: windowsDpapiProtector } : {}),
|
|
25
|
+
resolveGlobalNodeModules: async () => {
|
|
26
|
+
const result = await systemCommandRunner(npmCommand, ["root", "--global"]);
|
|
27
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
28
|
+
throw new Error(`Unable to locate global npm modules: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
29
|
+
}
|
|
30
|
+
return result.stdout.trim();
|
|
31
|
+
},
|
|
32
|
+
readStdin: async () => {
|
|
33
|
+
if (process.stdin.isTTY)
|
|
34
|
+
throw new Error("--token-stdin requires a token on standard input");
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for await (const chunk of process.stdin)
|
|
37
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
38
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
39
|
+
},
|
|
40
|
+
stdout: (text) => process.stdout.write(text),
|
|
41
|
+
stderr: (text) => process.stderr.write(text),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function isGlobalDaemonEntry(entryPath, globalNodeModules) {
|
|
45
|
+
return resolve(entryPath) === resolve(globalNodeModules, "@nowcrew", "daemon", "dist", "main.js");
|
|
46
|
+
}
|
|
47
|
+
function usage(td) {
|
|
48
|
+
return [
|
|
49
|
+
td("Computer lifecycle:"),
|
|
50
|
+
" crew-daemon profile save <name> --server-url <url> [--agents-root <path>] [--token-stdin]",
|
|
51
|
+
" crew-daemon profile list|show|remove [<name>]",
|
|
52
|
+
" crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
|
|
53
|
+
" crew-daemon upgrade [--profile <name>]",
|
|
54
|
+
"",
|
|
55
|
+
td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
|
|
56
|
+
].join("\n") + "\n";
|
|
57
|
+
}
|
|
58
|
+
function requireValue(value, name, td) {
|
|
59
|
+
if (!value)
|
|
60
|
+
throw new Error(td("Missing {{name}}", { name }));
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export async function runComputerCommand(argv, overrides = {}) {
|
|
64
|
+
const command = argv[0];
|
|
65
|
+
if (!command || !COMPUTER_COMMANDS.has(command))
|
|
66
|
+
return null;
|
|
67
|
+
const deps = { ...defaults(), ...overrides };
|
|
68
|
+
const lang = detectDaemonLang(deps.env);
|
|
69
|
+
const td = (message, values) => formatDaemonText(lang, message, values);
|
|
70
|
+
try {
|
|
71
|
+
const parsed = parseArgs({
|
|
72
|
+
args: [...argv],
|
|
73
|
+
allowPositionals: true,
|
|
74
|
+
strict: true,
|
|
75
|
+
options: {
|
|
76
|
+
profile: { type: "string" },
|
|
77
|
+
"server-url": { type: "string" },
|
|
78
|
+
"agents-root": { type: "string" },
|
|
79
|
+
"token-stdin": { type: "boolean", default: false },
|
|
80
|
+
help: { type: "boolean", short: "h", default: false },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (parsed.values.help) {
|
|
84
|
+
deps.stdout(usage(td));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const [, action, positionalName] = parsed.positionals;
|
|
88
|
+
const home = daemonHome(deps.env);
|
|
89
|
+
const profileStorage = {
|
|
90
|
+
platform: deps.platform,
|
|
91
|
+
...(deps.profileProtector ? { protector: deps.profileProtector } : {}),
|
|
92
|
+
...(deps.platform === "win32"
|
|
93
|
+
? { harden: (path) => hardenWindowsProfile(path, deps.runner) }
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
if (command === "profile") {
|
|
97
|
+
if (action === "list") {
|
|
98
|
+
deps.stdout(`${JSON.stringify(await listProfiles(home), null, 2)}\n`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
const name = requireValue(positionalName, "profile name", td);
|
|
102
|
+
if (action === "show") {
|
|
103
|
+
deps.stdout(`${JSON.stringify(publicProfile(await loadProfile(name, home, profileStorage)), null, 2)}\n`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
if (action === "remove") {
|
|
107
|
+
await loadProfile(name, home, profileStorage);
|
|
108
|
+
const spec = buildServiceSpec({
|
|
109
|
+
...deps, profile: name, profileHome: home,
|
|
110
|
+
});
|
|
111
|
+
const status = await serviceStatus(spec, deps.runner);
|
|
112
|
+
if (status.installed)
|
|
113
|
+
throw new Error(td("Service '{{id}}' is still installed; uninstall it before removing the profile", { id: spec.id }));
|
|
114
|
+
await removeProfile(name, home);
|
|
115
|
+
deps.stdout(`${td("Removed profile '{{name}}'.", { name })}\n`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (action !== "save")
|
|
119
|
+
throw new Error(td("Expected profile save, list, show, or remove"));
|
|
120
|
+
const fromStdin = parsed.values["token-stdin"];
|
|
121
|
+
if (fromStdin && deps.env.CREW_MACHINE_TOKEN) {
|
|
122
|
+
throw new Error(td("Choose one token source: CREW_MACHINE_TOKEN or --token-stdin"));
|
|
123
|
+
}
|
|
124
|
+
const machineToken = (fromStdin ? await deps.readStdin() : deps.env.CREW_MACHINE_TOKEN)?.trim();
|
|
125
|
+
if (!machineToken)
|
|
126
|
+
throw new Error(td("Missing machine token; use CREW_MACHINE_TOKEN or --token-stdin"));
|
|
127
|
+
await saveProfile({
|
|
128
|
+
name,
|
|
129
|
+
serverUrl: requireValue(parsed.values["server-url"], "--server-url", td),
|
|
130
|
+
machineToken,
|
|
131
|
+
...(parsed.values["agents-root"] ? { agentsRoot: resolve(parsed.values["agents-root"]) } : {}),
|
|
132
|
+
...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
|
|
133
|
+
}, home, profileStorage);
|
|
134
|
+
deps.stdout(`${td("Saved profile '{{name}}' with private credentials.", { name })}\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const profileName = parsed.values.profile;
|
|
138
|
+
if (command === "upgrade") {
|
|
139
|
+
const spec = profileName
|
|
140
|
+
? buildServiceSpec({
|
|
141
|
+
...deps,
|
|
142
|
+
profile: (await loadProfile(profileName, home, profileStorage)).name,
|
|
143
|
+
profileHome: home,
|
|
144
|
+
})
|
|
145
|
+
: null;
|
|
146
|
+
if (spec) {
|
|
147
|
+
if (!isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
|
|
148
|
+
throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
|
|
149
|
+
}
|
|
150
|
+
const before = await serviceStatus(spec, deps.runner);
|
|
151
|
+
if (!before.installed)
|
|
152
|
+
throw new Error(td("Service '{{id}}' is not installed", { id: spec.id }));
|
|
153
|
+
}
|
|
154
|
+
await upgradeDaemon(deps.platform, deps.runner);
|
|
155
|
+
if (profileName) {
|
|
156
|
+
await serviceAction(spec, "restart", deps.runner);
|
|
157
|
+
deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: profileName })}\n`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
deps.stdout(`${td("Upgraded daemon. Installed services were not restarted; pass --profile to restart one.")}\n`);
|
|
161
|
+
}
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
const name = requireValue(profileName, "--profile", td);
|
|
165
|
+
await loadProfile(name, home, profileStorage);
|
|
166
|
+
if (!deps.entryPath.endsWith(".js")) {
|
|
167
|
+
throw new Error(td("Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry"));
|
|
168
|
+
}
|
|
169
|
+
const spec = buildServiceSpec({
|
|
170
|
+
...deps, profile: name, profileHome: home,
|
|
171
|
+
});
|
|
172
|
+
if (command === "install") {
|
|
173
|
+
if (!isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
|
|
174
|
+
throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
|
|
175
|
+
}
|
|
176
|
+
await installService(spec, deps.runner);
|
|
177
|
+
deps.stdout(`${td("Installed '{{id}}'. Use status to confirm runtime state.", { id: spec.id })}\n`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
if (command === "uninstall") {
|
|
181
|
+
await uninstallService(spec, deps.runner);
|
|
182
|
+
deps.stdout(`${td("Uninstalled '{{id}}'.", { id: spec.id })}\n`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (command === "start" || command === "stop" || command === "restart") {
|
|
186
|
+
await serviceAction(spec, command, deps.runner);
|
|
187
|
+
deps.stdout(`${td("{{action}} request accepted for '{{id}}'. Verify with status.", {
|
|
188
|
+
action: command,
|
|
189
|
+
id: spec.id,
|
|
190
|
+
})}\n`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
if (command === "status") {
|
|
194
|
+
const status = await serviceStatus(spec, deps.runner);
|
|
195
|
+
deps.stdout(`${JSON.stringify({ installed: status.installed, loaded: status.loaded, running: status.running, detail: (status.stdout || status.stderr).trim() }, null, 2)}\n`);
|
|
196
|
+
return status.installed && status.running ? 0 : 3;
|
|
197
|
+
}
|
|
198
|
+
if (command === "doctor") {
|
|
199
|
+
const privateFile = deps.platform === "win32"
|
|
200
|
+
? await windowsProfileIsPrivate(profilePath(name, home), deps.runner)
|
|
201
|
+
: await profileIsPrivate(name, home, deps.platform);
|
|
202
|
+
const checks = await doctorService(spec, privateFile, deps.runner);
|
|
203
|
+
const registered = (await serviceStatus(spec, deps.runner)).installed;
|
|
204
|
+
deps.stdout(`${JSON.stringify({ checks, registered }, null, 2)}\n`);
|
|
205
|
+
return checks.every((check) => check.ok) ? 0 : 4;
|
|
206
|
+
}
|
|
207
|
+
deps.stderr(usage(td));
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
deps.stderr(`crew-daemon: ${td(error.message)}\n`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
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 an HTTP(S) URL without embedded credentials");
|
|
13
|
+
const ProfileSchema = z.object({
|
|
14
|
+
version: z.literal(1),
|
|
15
|
+
name: z.string().regex(PROFILE_NAME),
|
|
16
|
+
serverUrl: ServerUrlSchema,
|
|
17
|
+
machineToken: z.string().startsWith("sk_machine_").max(4096),
|
|
18
|
+
agentsRoot: z.string().min(1).optional(),
|
|
19
|
+
runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
|
|
20
|
+
}).strict();
|
|
21
|
+
const StoredPlainProfileSchema = ProfileSchema;
|
|
22
|
+
const StoredProtectedProfileSchema = ProfileSchema.omit({ machineToken: true }).extend({
|
|
23
|
+
machineTokenProtected: z.object({
|
|
24
|
+
scheme: z.literal("dpapi-current-user"),
|
|
25
|
+
ciphertext: z.string().min(1).max(16384),
|
|
26
|
+
}).strict(),
|
|
27
|
+
}).strict();
|
|
28
|
+
const StoredProfileSchema = z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]);
|
|
29
|
+
export function daemonHome(env = process.env) {
|
|
30
|
+
return resolve(env.CREW_DAEMON_HOME ?? resolve(homedir(), ".crew/daemon"));
|
|
31
|
+
}
|
|
32
|
+
export function validateProfileName(name) {
|
|
33
|
+
if (!PROFILE_NAME.test(name)) {
|
|
34
|
+
throw new Error("profile name must match [a-z0-9][a-z0-9_-]{0,47}");
|
|
35
|
+
}
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
export function profilePath(name, home = daemonHome()) {
|
|
39
|
+
return resolve(home, "profiles", `${validateProfileName(name)}.json`);
|
|
40
|
+
}
|
|
41
|
+
async function atomicPrivateWrite(path, content, beforeCommit) {
|
|
42
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
await chmod(dirname(path), 0o700);
|
|
44
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
47
|
+
await beforeCommit?.(temporary);
|
|
48
|
+
await rename(temporary, path);
|
|
49
|
+
await chmod(path, 0o600);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await rm(temporary, { force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const DPAPI_PROTECT = "$p=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($p);$e=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Convert]::ToBase64String($e)";
|
|
56
|
+
const DPAPI_UNPROTECT = "$p=[Console]::In.ReadToEnd();$b=[Convert]::FromBase64String($p);$d=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Console]::Out.Write([Text.Encoding]::UTF8.GetString($d))";
|
|
57
|
+
async function powershellStdin(script, input) {
|
|
58
|
+
return new Promise((resolvePromise, reject) => {
|
|
59
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
60
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
});
|
|
63
|
+
const stdout = [];
|
|
64
|
+
const stderr = [];
|
|
65
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
66
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
67
|
+
child.once("error", reject);
|
|
68
|
+
child.once("close", (code) => {
|
|
69
|
+
if (code !== 0) {
|
|
70
|
+
reject(new Error(`Windows DPAPI operation failed: ${Buffer.concat(stderr).toString("utf8").trim() || `exit ${code}`}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolvePromise(Buffer.concat(stdout).toString("utf8").trim());
|
|
74
|
+
});
|
|
75
|
+
child.stdin.end(input);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export const windowsDpapiProtector = {
|
|
79
|
+
protect: (plaintext) => powershellStdin(DPAPI_PROTECT, plaintext),
|
|
80
|
+
unprotect: (ciphertext) => powershellStdin(DPAPI_UNPROTECT, ciphertext),
|
|
81
|
+
};
|
|
82
|
+
function storageOptions(options) {
|
|
83
|
+
const platform = options.platform ?? process.platform;
|
|
84
|
+
return {
|
|
85
|
+
platform,
|
|
86
|
+
...(options.protector ? { protector: options.protector } : platform === "win32" ? { protector: windowsDpapiProtector } : {}),
|
|
87
|
+
...(options.harden ? { harden: options.harden } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function saveProfile(input, home = daemonHome(), options = {}) {
|
|
91
|
+
const profile = ProfileSchema.parse({ version: 1, ...input });
|
|
92
|
+
const configured = storageOptions(options);
|
|
93
|
+
if (configured.platform === "win32") {
|
|
94
|
+
if (!configured.protector)
|
|
95
|
+
throw new Error("Windows profile storage requires CurrentUser DPAPI");
|
|
96
|
+
if (!configured.harden)
|
|
97
|
+
throw new Error("Windows profile storage requires ACL hardening");
|
|
98
|
+
const ciphertext = await configured.protector.protect(profile.machineToken);
|
|
99
|
+
if (!ciphertext)
|
|
100
|
+
throw new Error("Windows DPAPI returned an empty ciphertext");
|
|
101
|
+
const stored = StoredProtectedProfileSchema.parse({
|
|
102
|
+
version: 1,
|
|
103
|
+
name: profile.name,
|
|
104
|
+
serverUrl: profile.serverUrl,
|
|
105
|
+
...(profile.agentsRoot ? { agentsRoot: profile.agentsRoot } : {}),
|
|
106
|
+
...(profile.runtimePath ? { runtimePath: profile.runtimePath } : {}),
|
|
107
|
+
machineTokenProtected: { scheme: "dpapi-current-user", ciphertext },
|
|
108
|
+
});
|
|
109
|
+
await atomicPrivateWrite(profilePath(profile.name, home), `${JSON.stringify(stored, null, 2)}\n`, configured.harden);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await atomicPrivateWrite(profilePath(profile.name, home), `${JSON.stringify(profile, null, 2)}\n`);
|
|
113
|
+
}
|
|
114
|
+
return profile;
|
|
115
|
+
}
|
|
116
|
+
export async function loadProfile(name, home = daemonHome(), options = {}) {
|
|
117
|
+
const path = profilePath(name, home);
|
|
118
|
+
try {
|
|
119
|
+
const stored = StoredProfileSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
120
|
+
const configured = storageOptions(options);
|
|
121
|
+
if (configured.platform === "win32") {
|
|
122
|
+
if (!("machineTokenProtected" in stored)) {
|
|
123
|
+
throw new Error(`Computer profile '${name}' contains a plaintext token and is refused on Windows`);
|
|
124
|
+
}
|
|
125
|
+
if (!configured.protector)
|
|
126
|
+
throw new Error("Windows profile storage requires CurrentUser DPAPI");
|
|
127
|
+
return ProfileSchema.parse({
|
|
128
|
+
version: stored.version,
|
|
129
|
+
name: stored.name,
|
|
130
|
+
serverUrl: stored.serverUrl,
|
|
131
|
+
...(stored.agentsRoot ? { agentsRoot: stored.agentsRoot } : {}),
|
|
132
|
+
...(stored.runtimePath ? { runtimePath: stored.runtimePath } : {}),
|
|
133
|
+
machineToken: await configured.protector.unprotect(stored.machineTokenProtected.ciphertext),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!("machineToken" in stored)) {
|
|
137
|
+
throw new Error(`Computer profile '${name}' is DPAPI-protected and can only be loaded by its Windows user`);
|
|
138
|
+
}
|
|
139
|
+
return stored;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === "ENOENT") {
|
|
143
|
+
throw new Error(`Computer profile '${name}' does not exist`);
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function listProfiles(home = daemonHome()) {
|
|
149
|
+
const root = resolve(home, "profiles");
|
|
150
|
+
try {
|
|
151
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
152
|
+
return entries
|
|
153
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
154
|
+
.map((entry) => entry.name.slice(0, -5))
|
|
155
|
+
.filter((name) => PROFILE_NAME.test(name))
|
|
156
|
+
.sort();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code === "ENOENT")
|
|
160
|
+
return [];
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function removeProfile(name, home = daemonHome()) {
|
|
165
|
+
await rm(profilePath(name, home), { force: true });
|
|
166
|
+
}
|
|
167
|
+
export async function profileIsPrivate(name, home = daemonHome(), platform = process.platform) {
|
|
168
|
+
const path = profilePath(name, home);
|
|
169
|
+
await access(path, constants.R_OK);
|
|
170
|
+
if (platform === "win32") {
|
|
171
|
+
const stored = StoredProfileSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
172
|
+
return "machineTokenProtected" in stored && stored.machineTokenProtected.scheme === "dpapi-current-user";
|
|
173
|
+
}
|
|
174
|
+
const { mode } = await stat(path);
|
|
175
|
+
return (mode & 0o077) === 0;
|
|
176
|
+
}
|
|
177
|
+
export function publicProfile(profile) {
|
|
178
|
+
return {
|
|
179
|
+
version: profile.version,
|
|
180
|
+
name: profile.name,
|
|
181
|
+
serverUrl: profile.serverUrl,
|
|
182
|
+
machineTokenConfigured: true,
|
|
183
|
+
...(profile.agentsRoot ? { agentsRoot: profile.agentsRoot } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export function applyProfileToEnv(profile, env) {
|
|
187
|
+
env.CREW_SERVER_URL = profile.serverUrl;
|
|
188
|
+
env.CREW_MACHINE_TOKEN = profile.machineToken;
|
|
189
|
+
if (profile.agentsRoot)
|
|
190
|
+
env.CREW_AGENTS_ROOT = profile.agentsRoot;
|
|
191
|
+
else
|
|
192
|
+
delete env.CREW_AGENTS_ROOT;
|
|
193
|
+
if (profile.runtimePath)
|
|
194
|
+
env.PATH = profile.runtimePath;
|
|
195
|
+
}
|