@ravefox/space 0.2.0 → 0.3.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.
Files changed (163) hide show
  1. package/LICENSE +21 -203
  2. package/README.md +89 -26
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +28 -40
  15. package/NOTICE +0 -44
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
276
- .replace(/&amp;/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 (?:"|&quot;)(.+?)(?:"|&quot;)/.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' });
@@ -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
- };