@ravefox/space 0.2.1 → 0.4.0
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/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ADR-026 D1: install-once service registration for the resident daemon.
|
|
3
|
-
*
|
|
4
|
-
* macOS gets a launchd LaunchAgent, Linux a systemd user unit, Windows a
|
|
5
|
-
* per-user Task Scheduler task (daemon-service-windows.js). All three run the
|
|
6
|
-
* exact interpreter + CLI entry that executed `daemon install`, resolved at
|
|
7
|
-
* install time — a PATH lookup at boot would race version managers and
|
|
8
|
-
* silently run a different install than the one the user tested.
|
|
9
|
-
*
|
|
10
|
-
* Pure generators + injected side effects, same discipline as the
|
|
11
|
-
* supervisor: the unit CONTENT is unit-testable without touching launchctl.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { randomBytes } from 'crypto';
|
|
15
|
-
import { existsSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
16
|
-
import { homedir, userInfo } from 'os';
|
|
17
|
-
import { basename, join, dirname, resolve } from 'path';
|
|
18
|
-
import {
|
|
19
|
-
WINDOWS_TASK_NAME, installWindowsService, uninstallWindowsService, windowsServiceAction, windowsServicePaths,
|
|
20
|
-
} from './daemon-service-windows.js';
|
|
21
|
-
import { CLI } from './cli-hint.js';
|
|
22
|
-
|
|
23
|
-
export const LAUNCHD_LABEL = 'me.commonly.daemon';
|
|
24
|
-
export const SYSTEMD_UNIT = 'commonly-daemon.service';
|
|
25
|
-
|
|
26
|
-
export const daemonLogPath = (home = homedir()) => join(home, '.commonly', 'logs', 'daemon', 'daemon.log');
|
|
27
|
-
|
|
28
|
-
export const servicePaths = (platform = process.platform, home = homedir()) => {
|
|
29
|
-
if (platform === 'darwin') return { kind: 'launchd', file: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`) };
|
|
30
|
-
if (platform === 'win32') return windowsServicePaths(home);
|
|
31
|
-
return { kind: 'systemd', file: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT) };
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const xmlEscape = (value) => String(value)
|
|
35
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
36
|
-
|
|
37
|
-
// The systemd sink is NOT the plist sink, so escaping one is not escaping both.
|
|
38
|
-
// Inside `Environment="…"` a raw `"` closes the quote, and a raw newline ends
|
|
39
|
-
// the assignment so the remainder of the key lands in the unit as a directive.
|
|
40
|
-
// The adversary here is the operator's own environment, so severity is low —
|
|
41
|
-
// but correctness does not need an adversary: a provider key containing a `"`
|
|
42
|
-
// writes a unit that will not start, with nothing on screen to say why.
|
|
43
|
-
// systemd quoted values carry C-style escapes (systemd.syntax(7)), so both
|
|
44
|
-
// values are escaped and neither sink is trusted to be the harmless one. The
|
|
45
|
-
// last replacement is the least obvious: `Environment=` does NOT expand `$VAR`
|
|
46
|
-
// but DOES perform specifier expansion (systemd.exec(5), "Specifier expansion is
|
|
47
|
-
// performed"), and `systemd.unit(5)` gives the escape — `%%` in place of `%`.
|
|
48
|
-
// A key containing `%h` would otherwise be rewritten to the home directory: the
|
|
49
|
-
// unit parses, the daemon starts, the seat gets a key nobody exported, and it
|
|
50
|
-
// fails at the provider with nothing at daemon level naming the cause — the same
|
|
51
|
-
// failure class this code exists to remove, arriving through the escaping.
|
|
52
|
-
const systemdEscape = (value) => String(value)
|
|
53
|
-
.replace(/%/g, '%%')
|
|
54
|
-
.replace(/\\/g, '\\\\')
|
|
55
|
-
.replace(/"/g, '\\"')
|
|
56
|
-
.replace(/\n/g, '\\n')
|
|
57
|
-
.replace(/\r/g, '\\r')
|
|
58
|
-
.replace(/\t/g, '\\t');
|
|
59
|
-
|
|
60
|
-
// PATH matters for the CHILDREN: the daemon spawns `commonly agent run`,
|
|
61
|
-
// which spawns the user's claude/codex CLI by name. launchd's default PATH
|
|
62
|
-
// has no /opt/homebrew/bin, so without this the daemon comes up and every
|
|
63
|
-
// seat dies at adapter detection.
|
|
64
|
-
const childPath = (nodePath) => [
|
|
65
|
-
dirname(nodePath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
66
|
-
].filter((entry, index, all) => all.indexOf(entry) === index).join(':');
|
|
67
|
-
|
|
68
|
-
// TASK-049: PATH is not the only thing the children need.
|
|
69
|
-
//
|
|
70
|
-
// launchd and systemd both start the daemon from a CLEAN environment, so a
|
|
71
|
-
// seat's provider key that exists only in the operator's interactive shell is
|
|
72
|
-
// absent at boot — and the seat then dies inside its own adapter (`pi.js`
|
|
73
|
-
// throws `COMMONLY_LITELLM_KEY is not set`). That is the same defect the PATH
|
|
74
|
-
// line above fixes, one layer over, and it is fixed the same way: carry what
|
|
75
|
-
// the install could see. Installation is the ONLY moment the operator's shell is
|
|
76
|
-
// in reach, so the keys are captured there, and the ones that were missing are
|
|
77
|
-
// named at install time rather than discovered one crash-looping seat at a time.
|
|
78
|
-
//
|
|
79
|
-
// The values are secrets. The daemon's own credential is a 0600 file, so the
|
|
80
|
-
// service file is written 0600 too instead of being left at the umask default a
|
|
81
|
-
// PATH-only unit could safely keep.
|
|
82
|
-
//
|
|
83
|
-
// The mode is guaranteed BY CONSTRUCTION rather than by the order of two calls:
|
|
84
|
-
// the content is written to a sibling temp file CREATED 0600 and then renamed
|
|
85
|
-
// over the target. `writeFileSync`'s mode applies only at creation, so the
|
|
86
|
-
// obvious alternatives each leave a path open — a write straight to the target
|
|
87
|
-
// cannot narrow a file an EARLIER install left at 0644 (its mode survives the
|
|
88
|
-
// write, and a pre-existing 0644 service file is what every upgrade meets),
|
|
89
|
-
// and a chmod after the write closes the door the key already walked through.
|
|
90
|
-
// The rename also removes a second hazard, narrower than it first looks: systemd
|
|
91
|
-
// does NOT re-read a changed unit by itself — it reports the file as changed on
|
|
92
|
-
// disk and keeps the loaded copy until a `systemctl --user daemon-reload`. What
|
|
93
|
-
// is exposed is a reader CONCURRENT with the write, and that is not exotic here:
|
|
94
|
-
// an install ends by running `daemon-reload`, every other install on the machine
|
|
95
|
-
// can run one at the same moment, and `systemctl --user show` reads the file.
|
|
96
|
-
// After a rename each of them sees the old file or the new one, never a
|
|
97
|
-
// truncated unit.
|
|
98
|
-
//
|
|
99
|
-
// POSIX rename replaces the destination atomically, so this needs no
|
|
100
|
-
// chmod-if-exists dance and no cleanup of the old file.
|
|
101
|
-
//
|
|
102
|
-
// The temp NAME and the temp WRITE are each half of the same defence, and
|
|
103
|
-
// neither is sufficient on its own. A predictable name (`<file>.tmp-<pid>`) lets
|
|
104
|
-
// a process running as this user — which is every seat this repo spawns —
|
|
105
|
-
// pre-plant a symlink at that path and capture the key when the write follows
|
|
106
|
-
// it; `O_EXCL` is what actually refuses that, because it fails on a symlink at
|
|
107
|
-
// the final component instead of writing through it. The random suffix only
|
|
108
|
-
// removes the easy target, and the cleanup only deletes a temp this call made.
|
|
109
|
-
export const SERVICE_FILE_MODE = 0o600;
|
|
110
|
-
|
|
111
|
-
// Exported so the O_EXCL refusal is witnessed against a real symlink at a known
|
|
112
|
-
// path rather than assumed from the absence of a race.
|
|
113
|
-
export const serviceTempPath = (file, suffix = randomBytes(6).toString('hex')) => `${file}.tmp-${suffix}`;
|
|
114
|
-
|
|
115
|
-
export const writeServiceFile = (file, content, { suffix } = {}) => {
|
|
116
|
-
const tmp = serviceTempPath(file, suffix);
|
|
117
|
-
let created = false;
|
|
118
|
-
try {
|
|
119
|
-
// `wx` = O_CREAT|O_EXCL: the write either creates the temp itself or does not
|
|
120
|
-
// happen at all, so a planted path is refused rather than followed.
|
|
121
|
-
writeFileSync(tmp, content, { encoding: 'utf8', mode: SERVICE_FILE_MODE, flag: 'wx' });
|
|
122
|
-
created = true;
|
|
123
|
-
renameSync(tmp, file);
|
|
124
|
-
} catch (error) {
|
|
125
|
-
// Only the temp this call created is ours to remove. After a refused write
|
|
126
|
-
// the path belongs to whoever planted it.
|
|
127
|
-
if (created) {
|
|
128
|
-
try {
|
|
129
|
-
unlinkSync(tmp);
|
|
130
|
-
} catch {
|
|
131
|
-
// Nothing to clean up; the original error is the one that matters.
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
throw error;
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
export const providerEnvPairs = ({ names = [], env = process.env } = {}) => names
|
|
138
|
-
.filter((name) => env[name])
|
|
139
|
-
.map((name) => [name, String(env[name])]);
|
|
140
|
-
|
|
141
|
-
export const missingProviderKeys = ({ names = [], env = process.env } = {}) => names
|
|
142
|
-
.filter((name) => !env[name]);
|
|
143
|
-
|
|
144
|
-
const plistEnvPairs = (pairs) => pairs
|
|
145
|
-
.map(([name, value]) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t<string>${xmlEscape(value)}</string>\n`)
|
|
146
|
-
.join('');
|
|
147
|
-
|
|
148
|
-
export const launchdPlist = ({ nodePath, cliPath, home = homedir(), providerEnv = [] }) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
149
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
150
|
-
<plist version="1.0">
|
|
151
|
-
<dict>
|
|
152
|
-
\t<key>Label</key>
|
|
153
|
-
\t<string>${LAUNCHD_LABEL}</string>
|
|
154
|
-
\t<key>EnvironmentVariables</key>
|
|
155
|
-
\t<dict>
|
|
156
|
-
\t\t<key>PATH</key>
|
|
157
|
-
\t\t<string>${xmlEscape(childPath(nodePath))}</string>
|
|
158
|
-
\t\t<key>HOME</key>
|
|
159
|
-
\t\t<string>${xmlEscape(home)}</string>
|
|
160
|
-
${plistEnvPairs(providerEnv)}\t</dict>
|
|
161
|
-
\t<key>ProgramArguments</key>
|
|
162
|
-
\t<array>
|
|
163
|
-
\t\t<string>${xmlEscape(nodePath)}</string>
|
|
164
|
-
\t\t<string>${xmlEscape(cliPath)}</string>
|
|
165
|
-
\t\t<string>daemon</string>
|
|
166
|
-
\t\t<string>run</string>
|
|
167
|
-
\t\t<string>--foreground</string>
|
|
168
|
-
\t</array>
|
|
169
|
-
\t<key>RunAtLoad</key>
|
|
170
|
-
\t<true/>
|
|
171
|
-
\t<key>KeepAlive</key>
|
|
172
|
-
\t<dict>
|
|
173
|
-
\t\t<key>SuccessfulExit</key>
|
|
174
|
-
\t\t<false/>
|
|
175
|
-
\t</dict>
|
|
176
|
-
\t<key>StandardOutPath</key>
|
|
177
|
-
\t<string>${xmlEscape(daemonLogPath(home))}</string>
|
|
178
|
-
\t<key>StandardErrorPath</key>
|
|
179
|
-
\t<string>${xmlEscape(daemonLogPath(home))}</string>
|
|
180
|
-
</dict>
|
|
181
|
-
</plist>
|
|
182
|
-
`;
|
|
183
|
-
|
|
184
|
-
export const systemdUnit = ({ nodePath, cliPath, providerEnv = [] }) => {
|
|
185
|
-
const providerLines = providerEnv.length
|
|
186
|
-
? `\n${providerEnv.map(([name, value]) => `Environment="${systemdEscape(`${name}=${value}`)}"`).join('\n')}`
|
|
187
|
-
: '';
|
|
188
|
-
return `[Unit]
|
|
189
|
-
Description=Space local agent daemon (ADR-026)
|
|
190
|
-
After=network-online.target
|
|
191
|
-
|
|
192
|
-
[Service]
|
|
193
|
-
ExecStart=${nodePath} ${cliPath} daemon run --foreground
|
|
194
|
-
Restart=always
|
|
195
|
-
RestartPreventExitStatus=77 78
|
|
196
|
-
RestartSec=5
|
|
197
|
-
Environment=PATH=${childPath(nodePath)}${providerLines}
|
|
198
|
-
|
|
199
|
-
[Install]
|
|
200
|
-
WantedBy=default.target
|
|
201
|
-
`;
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// D171: a systemd user unit is started by the user's own systemd manager, and
|
|
205
|
-
// without lingering that manager starts at the user's login, not at boot. On a
|
|
206
|
-
// headless box nobody logs in to, the daemon would then stay down after every
|
|
207
|
-
// reboot. logind records lingering as one file per user, so that file is what
|
|
208
|
-
// says the daemon starts at boot, not the exit code of the call that asked.
|
|
209
|
-
// logind names the file cescape(user name) (src/basic/escape.c): the named
|
|
210
|
-
// escapes, and octal for any other byte below a space or from 127 up.
|
|
211
|
-
const NAMED_ESCAPES = {
|
|
212
|
-
7: 'a', 8: 'b', 12: 'f', 10: 'n', 13: 'r', 9: 't', 11: 'v', 92: '\\', 34: '"', 39: "'",
|
|
213
|
-
};
|
|
214
|
-
const cescape = (value) => [...Buffer.from(value, 'utf8')].map((byte) => {
|
|
215
|
-
if (NAMED_ESCAPES[byte]) return `\\${NAMED_ESCAPES[byte]}`;
|
|
216
|
-
if (byte < 32 || byte >= 127) return `\\${byte.toString(8).padStart(3, '0')}`;
|
|
217
|
-
return String.fromCharCode(byte);
|
|
218
|
-
}).join('');
|
|
219
|
-
export const lingerFile = (username) => join('/var/lib/systemd/linger', cescape(username));
|
|
220
|
-
|
|
221
|
-
// The command is printed for a person to paste into a shell, so a user name the
|
|
222
|
-
// shell would split or expand is single-quoted. The call above it takes argv.
|
|
223
|
-
const shellWord = (value) => (/^[A-Za-z0-9._@%+=:,/-]+$/.test(value)
|
|
224
|
-
? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
225
|
-
|
|
226
|
-
// logind before v253 passes the escaped file name to manager_add_user_by_name at
|
|
227
|
-
// boot, so it never starts a user whose name needs escaping. The version isn't read,
|
|
228
|
-
// so for such a name the file alone doesn't prove a boot start.
|
|
229
|
-
const lingeringStartsAtBoot = (username, warn) => {
|
|
230
|
-
if (cescape(username) === username) return true;
|
|
231
|
-
warn(`Lingering is on for ${username}, but systemd before v253 cannot start a user whose name needs escaping at boot, so the daemon is only sure to start when ${username} logs in.`);
|
|
232
|
-
return false;
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const ensureLinger = async ({ username, existsFile, execCmd, warn }) => {
|
|
236
|
-
if (existsFile(lingerFile(username))) return lingeringStartsAtBoot(username, warn);
|
|
237
|
-
let failure = '';
|
|
238
|
-
try {
|
|
239
|
-
// --no-ask-password: the install must not stop at a polkit prompt; a refusal
|
|
240
|
-
// is answered below with the command to run instead.
|
|
241
|
-
await execCmd(['loginctl', '--no-ask-password', 'enable-linger', username]);
|
|
242
|
-
} catch (error) {
|
|
243
|
-
failure = ` (${error.message})`;
|
|
244
|
-
}
|
|
245
|
-
if (existsFile(lingerFile(username))) return lingeringStartsAtBoot(username, warn);
|
|
246
|
-
warn(`Lingering is off for ${username}${failure}, so the daemon starts when ${username} logs in, not at boot. On a machine nobody logs in to, it stays down after a reboot. To start it at boot, run: sudo loginctl enable-linger ${shellWord(username)}`);
|
|
247
|
-
return false;
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
// The line the install command ends with. "Survives reboots" is said only when
|
|
251
|
-
// the service starts at boot: a LaunchAgent, like a systemd user unit without
|
|
252
|
-
// lingering, starts at login.
|
|
253
|
-
// A launchd label, a systemd user unit and a scheduled-task name are one per OS
|
|
254
|
-
// user, not one per HOME. Without this check a second Commonly account's
|
|
255
|
-
// install on the same computer account unloaded the first account's running
|
|
256
|
-
// daemon and loaded its own under the same name, and nothing said so (Growth's
|
|
257
|
-
// F370 walk). So install first asks the service manager which file the live
|
|
258
|
-
// service came from, and goes ahead only when it is this HOME's own file.
|
|
259
|
-
export const serviceOwnerQuery = (platform = process.platform, uid = process.getuid?.()) => {
|
|
260
|
-
if (platform === 'darwin') return ['launchctl', 'print', `gui/${uid}/${LAUNCHD_LABEL}`];
|
|
261
|
-
if (platform === 'win32') return ['schtasks', '/Query', '/TN', WINDOWS_TASK_NAME, '/XML'];
|
|
262
|
-
return ['systemctl', '--user', 'show', SYSTEMD_UNIT, '-p', 'FragmentPath', '--value'];
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
// schtasks may answer in UTF-16 (BOM or NUL bytes) or in the console's code
|
|
266
|
-
// page; anything that did not decode cleanly is treated as no answer, so a
|
|
267
|
-
// non-ASCII user name never turns into a false "another account".
|
|
268
|
-
export const decodeCmdOutput = (buffer) => {
|
|
269
|
-
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(String(buffer ?? ''));
|
|
270
|
-
const utf16 = (bytes[0] === 0xff && bytes[1] === 0xfe) || bytes.subarray(0, 64).includes(0);
|
|
271
|
-
return bytes.toString(utf16 ? 'utf16le' : 'utf8').replace(/^\uFEFF/, '');
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const xmlUnescape = (value) => value
|
|
275
|
-
.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>')
|
|
276
|
-
.replace(/&/g, '&');
|
|
277
|
-
|
|
278
|
-
// The path in the query's answer, or null when the answer names none.
|
|
279
|
-
export const loadedServiceFile = (kind, stdout = '') => {
|
|
280
|
-
const text = String(stdout).replace(/\0/g, '');
|
|
281
|
-
let found;
|
|
282
|
-
if (kind === 'launchd') found = /^\s*path = (.+)$/m.exec(text)?.[1].trim();
|
|
283
|
-
// schtasks prints the definition it holds, whose action runs this HOME's launcher.
|
|
284
|
-
else if (kind === 'schtasks') found = /-File (?:"|")(.+?)(?:"|")/.exec(text)?.[1];
|
|
285
|
-
else found = text.trim();
|
|
286
|
-
if (!found) return null;
|
|
287
|
-
if (kind === 'schtasks') found = xmlUnescape(found);
|
|
288
|
-
return found.includes('\uFFFD') ? null : found;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
// The same file under another spelling (a symlinked HOME) is not foreign.
|
|
292
|
-
// The file itself may not exist yet, so the nearest folder that does is resolved.
|
|
293
|
-
const canonicalPath = (p, platform) => {
|
|
294
|
-
let dir = resolve(p);
|
|
295
|
-
let rest = '';
|
|
296
|
-
let out = null;
|
|
297
|
-
while (out === null) {
|
|
298
|
-
try {
|
|
299
|
-
out = join(realpathSync(dir), rest);
|
|
300
|
-
} catch {
|
|
301
|
-
const up = dirname(dir);
|
|
302
|
-
if (up === dir) out = resolve(p);
|
|
303
|
-
else { rest = rest ? join(basename(dir), rest) : basename(dir); dir = up; }
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return platform === 'win32' ? out.toLowerCase() : out;
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
export const foreignServiceRefusal = ({ target, loadedFile, platform = process.platform }) => {
|
|
310
|
-
if (!loadedFile) return null;
|
|
311
|
-
const own = target.kind === 'schtasks' ? target.launcher : target.file;
|
|
312
|
-
if (canonicalPath(loadedFile, platform) === canonicalPath(own, platform)) return null;
|
|
313
|
-
const head = `This computer already runs a Space daemon for another account (its service file is ${loadedFile}), `
|
|
314
|
-
+ `and installing here would stop it. To see it, run \`${CLI} daemon status\` as that account. `;
|
|
315
|
-
// The systemd user manager reads units only from its own account's folder,
|
|
316
|
-
// so a unit written under another HOME is never loaded: --replace can't help,
|
|
317
|
-
// and neither would uninstalling the other one first.
|
|
318
|
-
if (target.kind === 'systemd') {
|
|
319
|
-
return `${head}On Linux one computer account runs one Space daemon, from its own home folder. `
|
|
320
|
-
+ 'To run another, sign in to this computer as a different user and set it up there.';
|
|
321
|
-
}
|
|
322
|
-
return `${head}To replace it with this one on purpose, run this command again with --replace.`;
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
export const installSummary = ({ startsAtBoot }) => (startsAtBoot
|
|
326
|
-
? `The daemon now starts at boot and is kept alive, so it survives reboots. Uninstall with: ${CLI} daemon uninstall`
|
|
327
|
-
: `The daemon now starts at login and is kept alive. Uninstall with: ${CLI} daemon uninstall`);
|
|
328
|
-
|
|
329
|
-
export const installDaemonService = async ({
|
|
330
|
-
platform = process.platform,
|
|
331
|
-
home = homedir(),
|
|
332
|
-
nodePath = process.execPath,
|
|
333
|
-
cliPath = resolve(process.argv[1]),
|
|
334
|
-
// The provider keys the seats' adapters declare (adapters/index.js).
|
|
335
|
-
providerKeyEnvNames = [],
|
|
336
|
-
// Settings carried only when set, never warned about when absent: the
|
|
337
|
-
// operator's opt-outs (COMMONLY_NO_UPDATE_CHECK), which reach the seats the
|
|
338
|
-
// daemon spawns only through this file.
|
|
339
|
-
optionalEnvNames = [],
|
|
340
|
-
env = process.env,
|
|
341
|
-
writeFile = writeServiceFile,
|
|
342
|
-
mkdirp,
|
|
343
|
-
execCmd, // async (argv: string[]) => void — throws on failure
|
|
344
|
-
chmod = () => {},
|
|
345
|
-
ensureFile,
|
|
346
|
-
existsFile = existsSync,
|
|
347
|
-
// Resolved only on Linux, and only after the unit is up: userInfo() throws
|
|
348
|
-
// for a uid with no passwd entry.
|
|
349
|
-
username,
|
|
350
|
-
lookupUsername = () => userInfo().username,
|
|
351
|
-
log = () => {},
|
|
352
|
-
warn = log,
|
|
353
|
-
// argv => stdout; rejects when the service manager has no such service.
|
|
354
|
-
// Absent, the owner check is skipped (callers that only test the writers).
|
|
355
|
-
readCmd,
|
|
356
|
-
uid = process.getuid?.(),
|
|
357
|
-
// Take over a daemon another HOME installed, on purpose. Not on Linux: the
|
|
358
|
-
// user manager loads units only from its own home, so the check still runs.
|
|
359
|
-
replace = false,
|
|
360
|
-
}) => {
|
|
361
|
-
const target = servicePaths(platform, home);
|
|
362
|
-
if (readCmd && (!replace || target.kind === 'systemd')) {
|
|
363
|
-
const loadedFile = loadedServiceFile(target.kind, await readCmd(serviceOwnerQuery(platform, uid)).catch(() => ''));
|
|
364
|
-
const refusal = foreignServiceRefusal({ target, loadedFile, platform });
|
|
365
|
-
if (refusal) throw new Error(refusal);
|
|
366
|
-
}
|
|
367
|
-
mkdirp(dirname(target.file));
|
|
368
|
-
mkdirp(dirname(daemonLogPath(home)));
|
|
369
|
-
chmod(dirname(daemonLogPath(home)), 0o700);
|
|
370
|
-
if (ensureFile) {
|
|
371
|
-
ensureFile(daemonLogPath(home));
|
|
372
|
-
chmod(daemonLogPath(home), 0o600);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const providerEnv = providerEnvPairs({ names: [...new Set([...providerKeyEnvNames, ...optionalEnvNames])], env });
|
|
376
|
-
const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
|
|
377
|
-
|
|
378
|
-
let startsAtBoot = false;
|
|
379
|
-
if (target.kind === 'schtasks') {
|
|
380
|
-
// A logon task, like a LaunchAgent: it starts when this user logs in.
|
|
381
|
-
mkdirp(dirname(target.launcher));
|
|
382
|
-
await installWindowsService({
|
|
383
|
-
home,
|
|
384
|
-
nodePath,
|
|
385
|
-
cliPath,
|
|
386
|
-
logPath: daemonLogPath(home),
|
|
387
|
-
providerEnv,
|
|
388
|
-
env,
|
|
389
|
-
writeFile,
|
|
390
|
-
execCmd,
|
|
391
|
-
lookupUsername,
|
|
392
|
-
warn,
|
|
393
|
-
});
|
|
394
|
-
} else if (target.kind === 'launchd') {
|
|
395
|
-
// The mode and the atomicity are the WRITER's, not an ordering rule here:
|
|
396
|
-
// `writeServiceFile` creates a 0600 sibling and renames it over the target,
|
|
397
|
-
// so no chmod is needed on this path and no half-written unit is ever
|
|
398
|
-
// visible to launchd (see the writer for why neither half could cover both).
|
|
399
|
-
writeFile(target.file, launchdPlist({ nodePath, cliPath, home, providerEnv }));
|
|
400
|
-
// Reload cleanly if a previous version is loaded; the unload of an
|
|
401
|
-
// unknown label fails by design and is ignored.
|
|
402
|
-
await execCmd(['launchctl', 'unload', target.file]).catch(() => {});
|
|
403
|
-
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
404
|
-
} else {
|
|
405
|
-
writeFile(target.file, systemdUnit({ nodePath, cliPath, providerEnv }));
|
|
406
|
-
await execCmd(['systemctl', '--user', 'daemon-reload']);
|
|
407
|
-
// restart, not `enable --now`: a unit that is already running must pick up
|
|
408
|
-
// the environment just written, the way launchd's unload/load does.
|
|
409
|
-
await execCmd(['systemctl', '--user', 'enable', SYSTEMD_UNIT]);
|
|
410
|
-
await execCmd(['systemctl', '--user', 'restart', SYSTEMD_UNIT]);
|
|
411
|
-
// After the unit is up, so a refused linger leaves a working login service.
|
|
412
|
-
let lingerUser = username;
|
|
413
|
-
if (!lingerUser) {
|
|
414
|
-
try {
|
|
415
|
-
lingerUser = lookupUsername();
|
|
416
|
-
} catch (error) {
|
|
417
|
-
warn(`Could not read this account's user name (${error.message}), so lingering could not be checked: the daemon starts at login, and at boot only if lingering is already on for this account.`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
if (lingerUser) startsAtBoot = await ensureLinger({ username: lingerUser, existsFile, execCmd, warn });
|
|
421
|
-
}
|
|
422
|
-
for (const name of missing) {
|
|
423
|
-
warn(`${name} is not set in this shell, so it is NOT in the service file. A seat that relies on it will fail to start (one with its own provider.keyFile won't); export it and re-run: ${CLI} daemon install (docs/agents/daemon-service-environment.md)`);
|
|
424
|
-
}
|
|
425
|
-
log(`Installed ${target.kind} service (${target.file}). Logs: ${daemonLogPath(home)}`);
|
|
426
|
-
return { ...target, startsAtBoot };
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
export const uninstallDaemonService = async ({
|
|
430
|
-
platform = process.platform,
|
|
431
|
-
home = homedir(),
|
|
432
|
-
existsFile,
|
|
433
|
-
removeFile,
|
|
434
|
-
execCmd,
|
|
435
|
-
log = () => {},
|
|
436
|
-
}) => {
|
|
437
|
-
const target = servicePaths(platform, home);
|
|
438
|
-
if (!existsFile(target.file)) {
|
|
439
|
-
log('No installed daemon service found.');
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
if (target.kind === 'schtasks') {
|
|
443
|
-
await uninstallWindowsService({ home, execCmd, removeFile, existsFile });
|
|
444
|
-
log(`Removed the ${target.taskName} scheduled task and its files (${dirname(target.file)}).`);
|
|
445
|
-
return target;
|
|
446
|
-
}
|
|
447
|
-
if (target.kind === 'launchd') {
|
|
448
|
-
await execCmd(['launchctl', 'unload', '-w', target.file]).catch(() => {});
|
|
449
|
-
} else {
|
|
450
|
-
await execCmd(['systemctl', '--user', 'disable', '--now', SYSTEMD_UNIT]).catch(() => {});
|
|
451
|
-
}
|
|
452
|
-
removeFile(target.file);
|
|
453
|
-
log(`Removed ${target.kind} service (${target.file}).`);
|
|
454
|
-
return target;
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const serviceAction = async ({
|
|
458
|
-
action,
|
|
459
|
-
platform = process.platform,
|
|
460
|
-
home = homedir(),
|
|
461
|
-
execCmd,
|
|
462
|
-
}) => {
|
|
463
|
-
const target = servicePaths(platform, home);
|
|
464
|
-
if (target.kind === 'schtasks') return windowsServiceAction({ action, home, execCmd });
|
|
465
|
-
if (target.kind === 'launchd') {
|
|
466
|
-
if (action === 'start') {
|
|
467
|
-
// `install` already loads the plist. A second `start` should be
|
|
468
|
-
// idempotent rather than failing with "already loaded".
|
|
469
|
-
await execCmd(['launchctl', 'load', '-w', target.file]).catch(async () => {
|
|
470
|
-
await execCmd(['launchctl', 'start', LAUNCHD_LABEL]);
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
else if (action === 'stop') await execCmd(['launchctl', 'unload', '-w', target.file]);
|
|
474
|
-
else {
|
|
475
|
-
await execCmd(['launchctl', 'unload', '-w', target.file]).catch(() => {});
|
|
476
|
-
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
477
|
-
}
|
|
478
|
-
} else {
|
|
479
|
-
await execCmd(['systemctl', '--user', action, SYSTEMD_UNIT]);
|
|
480
|
-
}
|
|
481
|
-
return target;
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
export const startDaemonService = (options) => serviceAction({ ...options, action: 'start' });
|
|
485
|
-
export const stopDaemonService = (options) => serviceAction({ ...options, action: 'stop' });
|
|
486
|
-
export const restartDaemonService = (options) => serviceAction({ ...options, action: 'restart' });
|
package/src/lib/daemon-state.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
chmodSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
renameSync,
|
|
6
|
-
unlinkSync,
|
|
7
|
-
writeFileSync,
|
|
8
|
-
} from 'node:fs';
|
|
9
|
-
import { homedir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
|
|
12
|
-
// The state file is deliberately next to the daemon credential, but it never
|
|
13
|
-
// contains one. Keeping both under the already-private daemon directory makes
|
|
14
|
-
// it difficult for a future status field to accidentally become world-readable.
|
|
15
|
-
export const daemonStateDir = (home = homedir()) => join(home, '.commonly', 'daemon');
|
|
16
|
-
export const daemonStatePath = (home = homedir()) => join(daemonStateDir(home), 'state.json');
|
|
17
|
-
|
|
18
|
-
const fixedLastError = (value) => {
|
|
19
|
-
if (!value) return null;
|
|
20
|
-
const match = typeof value === 'string'
|
|
21
|
-
? value.match(/^child exited with code (-?\d+|null)$/)
|
|
22
|
-
: null;
|
|
23
|
-
return match ? `child exited with code ${match[1]}` : 'child process error';
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const seatState = (seat = {}) => ({
|
|
27
|
-
agentName: seat.agentName,
|
|
28
|
-
instanceId: seat.instanceId,
|
|
29
|
-
// The local record the seat runs from (`<name>` or `<name>@<id>`, F427):
|
|
30
|
-
// a file name, not a secret. Status reads its log and last turn by it.
|
|
31
|
-
recordKey: typeof seat.recordKey === 'string' ? seat.recordKey : null,
|
|
32
|
-
state: seat.state,
|
|
33
|
-
restarts: seat.restarts,
|
|
34
|
-
adapter: seat.adapter || null,
|
|
35
|
-
model: seat.model || null,
|
|
36
|
-
effort: seat.effort || null,
|
|
37
|
-
pid: Number.isInteger(seat.pid) ? seat.pid : null,
|
|
38
|
-
lastTurnAt: seat.lastTurnAt || null,
|
|
39
|
-
lastError: fixedLastError(seat.lastError),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// Pick fields rather than serializing arbitrary supervisor objects. This is a
|
|
43
|
-
// security boundary: no token, environment, or child-process metadata can be
|
|
44
|
-
// persisted merely because a future caller adds it to a runtime row.
|
|
45
|
-
export const publicDaemonState = (state = {}) => ({
|
|
46
|
-
machineName: state.machineName || null,
|
|
47
|
-
machineDbId: state.machineDbId || null,
|
|
48
|
-
updatedAt: state.updatedAt || new Date().toISOString(),
|
|
49
|
-
seats: Array.isArray(state.seats) ? state.seats.map(seatState) : [],
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export const saveDaemonState = (state, { home = homedir() } = {}) => {
|
|
53
|
-
const dir = daemonStateDir(home);
|
|
54
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
55
|
-
chmodSync(dir, 0o700);
|
|
56
|
-
const path = daemonStatePath(home);
|
|
57
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
58
|
-
writeFileSync(temp, `${JSON.stringify(publicDaemonState(state), null, 2)}\n`, {
|
|
59
|
-
encoding: 'utf8',
|
|
60
|
-
mode: 0o600,
|
|
61
|
-
});
|
|
62
|
-
chmodSync(temp, 0o600);
|
|
63
|
-
renameSync(temp, path);
|
|
64
|
-
chmodSync(path, 0o600);
|
|
65
|
-
return path;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export const loadDaemonState = ({ home = homedir() } = {}) => {
|
|
69
|
-
try {
|
|
70
|
-
const parsed = JSON.parse(readFileSync(daemonStatePath(home), 'utf8'));
|
|
71
|
-
return publicDaemonState(parsed);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (error?.code === 'ENOENT') return null;
|
|
74
|
-
throw new Error(`Could not read daemon state: ${error.message}`);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export const removeDaemonState = ({ home = homedir() } = {}) => {
|
|
79
|
-
try {
|
|
80
|
-
unlinkSync(daemonStatePath(home));
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
83
|
-
}
|
|
84
|
-
};
|