@commonlyai/cli 0.1.73 → 0.1.74
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/package.json +1 -1
- package/src/commands/daemon.js +27 -4
- package/src/lib/adapters/index.js +15 -0
- package/src/lib/adapters/pi.js +8 -0
- package/src/lib/daemon-service.js +131 -7
package/package.json
CHANGED
package/src/commands/daemon.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { hostname, homedir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import {
|
|
12
|
-
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch,
|
|
12
|
+
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch,
|
|
13
13
|
} from 'fs';
|
|
14
14
|
import { createClient } from '../lib/api.js';
|
|
15
15
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from '../lib/daemon-supervisor.js';
|
|
22
22
|
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
23
23
|
import { getLastTurn } from '../lib/session-store.js';
|
|
24
|
-
import { getAdapter } from '../lib/adapters/index.js';
|
|
24
|
+
import { getAdapter, providerKeyEnvNames } from '../lib/adapters/index.js';
|
|
25
25
|
import {
|
|
26
26
|
installDaemonService,
|
|
27
27
|
uninstallDaemonService,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
stopDaemonService,
|
|
30
30
|
restartDaemonService,
|
|
31
31
|
daemonLogPath,
|
|
32
|
+
missingProviderKeys,
|
|
32
33
|
servicePaths,
|
|
33
34
|
} from '../lib/daemon-service.js';
|
|
34
35
|
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
@@ -237,7 +238,10 @@ Examples:
|
|
|
237
238
|
|
|
238
239
|
// ── install / uninstall (ADR-026 D1) ──────────────────────────────────────
|
|
239
240
|
const serviceDeps = () => ({
|
|
240
|
-
writeFile
|
|
241
|
+
// writeFile is deliberately NOT injected here: daemon-service.js's default
|
|
242
|
+
// writer sets the 0600 mode at CREATION, and a bare `writeFileSync(file,
|
|
243
|
+
// content, 'utf8')` dropped that — leaving the service file, which can hold
|
|
244
|
+
// a provider key, readable at umask until the chmod a moment later.
|
|
241
245
|
mkdirp: (dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); },
|
|
242
246
|
chmod: (path, mode) => chmodSync(path, mode),
|
|
243
247
|
ensureFile: (path) => { const fd = openSync(path, 'a', 0o600); closeSync(fd); },
|
|
@@ -270,7 +274,12 @@ Examples:
|
|
|
270
274
|
try {
|
|
271
275
|
// A service without a credential just crash-loops at boot.
|
|
272
276
|
requireDaemonRecord();
|
|
273
|
-
|
|
277
|
+
// Same class one layer over (TASK-049): a service without the SEATS'
|
|
278
|
+
// provider key comes up clean, and every pi seat then dies inside its
|
|
279
|
+
// adapter. The keys are captured here because this is the only moment
|
|
280
|
+
// the operator's shell is in reach; the ones that are missing are named
|
|
281
|
+
// by installDaemonService rather than left to be discovered per seat.
|
|
282
|
+
await installDaemonService({ ...serviceDeps(), providerKeyEnvNames: providerKeyEnvNames() });
|
|
274
283
|
console.log('The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
|
|
275
284
|
} catch (error) {
|
|
276
285
|
console.error(`Daemon install failed: ${error.message}`);
|
|
@@ -342,6 +351,20 @@ Examples:
|
|
|
342
351
|
chmodSync(logsDir, 0o700);
|
|
343
352
|
const stampLog = (line) => console.log(`${new Date().toISOString()} ${line}`);
|
|
344
353
|
|
|
354
|
+
// TASK-049: fail loudly at BIND, once, naming the variable.
|
|
355
|
+
//
|
|
356
|
+
// A service-started daemon inherits a clean environment, so a provider
|
|
357
|
+
// key that lives only in the operator's shell is absent here; every pi
|
|
358
|
+
// seat then dies at adapter start with `COMMONLY_LITELLM_KEY is not set`
|
|
359
|
+
// and the operator sees crash loops with no cause at daemon level. One
|
|
360
|
+
// line at startup answers the question the log would otherwise take a
|
|
361
|
+
// seat autopsy to answer. Not fatal: the daemon's other seats are
|
|
362
|
+
// unaffected, and refusing to start would turn one missing key into a
|
|
363
|
+
// whole-machine outage.
|
|
364
|
+
for (const name of missingProviderKeys({ names: providerKeyEnvNames() })) {
|
|
365
|
+
stampLog(`WARNING: ${name} is not set in this environment — a seat whose adapter needs it will fail to start. Export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
366
|
+
}
|
|
367
|
+
|
|
345
368
|
const supervisor = createDaemonSupervisor({
|
|
346
369
|
record,
|
|
347
370
|
client,
|
|
@@ -22,3 +22,18 @@ const ADAPTERS = {
|
|
|
22
22
|
export const listAdapterNames = () => Object.keys(ADAPTERS);
|
|
23
23
|
|
|
24
24
|
export const getAdapter = (name) => ADAPTERS[name] || null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* TASK-049: the environment a seat's ADAPTER needs from the operator's shell.
|
|
28
|
+
*
|
|
29
|
+
* The adapter owns the provider, so the adapter declares the variable (see
|
|
30
|
+
* `pi.js`); the daemon reads the list to carry those keys into its login
|
|
31
|
+
* service, because launchd and systemd start the daemon with a clean
|
|
32
|
+
* environment — a key that exists only in an interactive shell is absent at
|
|
33
|
+
* boot, and the seat then dies inside its own adapter. An adapter that
|
|
34
|
+
* authenticates through its own CLI (claude, codex) declares nothing here and
|
|
35
|
+
* contributes nothing.
|
|
36
|
+
*/
|
|
37
|
+
export const providerKeyEnvNames = (adapters = ADAPTERS) => [...new Set(
|
|
38
|
+
Object.values(adapters).map((adapter) => adapter.providerKeyEnv).filter(Boolean),
|
|
39
|
+
)];
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -386,6 +386,14 @@ const runPi = ({ args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl
|
|
|
386
386
|
export default {
|
|
387
387
|
name: 'pi',
|
|
388
388
|
|
|
389
|
+
// TASK-049: the variable this adapter needs from the environment of whatever
|
|
390
|
+
// process spawns it. Declared HERE because the adapter owns the provider; the
|
|
391
|
+
// registry collects it (adapters/index.js) so the daemon can carry the key
|
|
392
|
+
// into its login service and fail loudly at bind when it is absent, instead
|
|
393
|
+
// of the seat dying at line ~406 with nothing naming the missing variable at
|
|
394
|
+
// daemon level.
|
|
395
|
+
providerKeyEnv: DEFAULT_PROVIDER.apiKeyEnv,
|
|
396
|
+
|
|
389
397
|
async detect() {
|
|
390
398
|
const res = spawnSync('pi', ['--version'], { encoding: 'utf8' });
|
|
391
399
|
if (res.error || res.status !== 0) return null;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* supervisor: the unit CONTENT is unit-testable without touching launchctl.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
15
|
+
import { renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
14
16
|
import { homedir } from 'os';
|
|
15
17
|
import { join, dirname, resolve } from 'path';
|
|
16
18
|
|
|
@@ -28,6 +30,29 @@ export const servicePaths = (platform = process.platform, home = homedir()) => (
|
|
|
28
30
|
const xmlEscape = (value) => String(value)
|
|
29
31
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
30
32
|
|
|
33
|
+
// The systemd sink is NOT the plist sink, so escaping one is not escaping both.
|
|
34
|
+
// Inside `Environment="…"` a raw `"` closes the quote, and a raw newline ends
|
|
35
|
+
// the assignment so the remainder of the key lands in the unit as a directive.
|
|
36
|
+
// The adversary here is the operator's own environment, so severity is low —
|
|
37
|
+
// but correctness does not need an adversary: a provider key containing a `"`
|
|
38
|
+
// writes a unit that will not start, with nothing on screen to say why.
|
|
39
|
+
// systemd quoted values carry C-style escapes (systemd.syntax(7)), so both
|
|
40
|
+
// values are escaped and neither sink is trusted to be the harmless one. The
|
|
41
|
+
// last replacement is the least obvious: `Environment=` does NOT expand `$VAR`
|
|
42
|
+
// but DOES perform specifier expansion (systemd.exec(5), "Specifier expansion is
|
|
43
|
+
// performed"), and `systemd.unit(5)` gives the escape — `%%` in place of `%`.
|
|
44
|
+
// A key containing `%h` would otherwise be rewritten to the home directory: the
|
|
45
|
+
// unit parses, the daemon starts, the seat gets a key nobody exported, and it
|
|
46
|
+
// fails at the provider with nothing at daemon level naming the cause — the same
|
|
47
|
+
// failure class this code exists to remove, arriving through the escaping.
|
|
48
|
+
const systemdEscape = (value) => String(value)
|
|
49
|
+
.replace(/%/g, '%%')
|
|
50
|
+
.replace(/\\/g, '\\\\')
|
|
51
|
+
.replace(/"/g, '\\"')
|
|
52
|
+
.replace(/\n/g, '\\n')
|
|
53
|
+
.replace(/\r/g, '\\r')
|
|
54
|
+
.replace(/\t/g, '\\t');
|
|
55
|
+
|
|
31
56
|
// PATH matters for the CHILDREN: the daemon spawns `commonly agent run`,
|
|
32
57
|
// which spawns the user's claude/codex CLI by name. launchd's default PATH
|
|
33
58
|
// has no /opt/homebrew/bin, so without this the daemon comes up and every
|
|
@@ -36,7 +61,87 @@ const childPath = (nodePath) => [
|
|
|
36
61
|
dirname(nodePath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
37
62
|
].filter((entry, index, all) => all.indexOf(entry) === index).join(':');
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
// TASK-049: PATH is not the only thing the children need.
|
|
65
|
+
//
|
|
66
|
+
// launchd and systemd both start the daemon from a CLEAN environment, so a
|
|
67
|
+
// seat's provider key that exists only in the operator's interactive shell is
|
|
68
|
+
// absent at boot — and the seat then dies inside its own adapter (`pi.js`
|
|
69
|
+
// throws `COMMONLY_LITELLM_KEY is not set`). That is the same defect the PATH
|
|
70
|
+
// line above fixes, one layer over, and it is fixed the same way: carry what
|
|
71
|
+
// the install could see. Installation is the ONLY moment the operator's shell is
|
|
72
|
+
// in reach, so the keys are captured there, and the ones that were missing are
|
|
73
|
+
// named at install time rather than discovered one crash-looping seat at a time.
|
|
74
|
+
//
|
|
75
|
+
// The values are secrets. The daemon's own credential is a 0600 file, so the
|
|
76
|
+
// service file is written 0600 too instead of being left at the umask default a
|
|
77
|
+
// PATH-only unit could safely keep.
|
|
78
|
+
//
|
|
79
|
+
// The mode is guaranteed BY CONSTRUCTION rather than by the order of two calls:
|
|
80
|
+
// the content is written to a sibling temp file CREATED 0600 and then renamed
|
|
81
|
+
// over the target. `writeFileSync`'s mode applies only at creation, so the
|
|
82
|
+
// obvious alternatives each leave a path open — a write straight to the target
|
|
83
|
+
// cannot narrow a file an EARLIER install left at 0644 (its mode survives the
|
|
84
|
+
// write, and a pre-existing 0644 service file is what every upgrade meets),
|
|
85
|
+
// and a chmod after the write closes the door the key already walked through.
|
|
86
|
+
// The rename also removes a second hazard, narrower than it first looks: systemd
|
|
87
|
+
// does NOT re-read a changed unit by itself — it reports the file as changed on
|
|
88
|
+
// disk and keeps the loaded copy until a `systemctl --user daemon-reload`. What
|
|
89
|
+
// is exposed is a reader CONCURRENT with the write, and that is not exotic here:
|
|
90
|
+
// an install ends by running `daemon-reload`, every other install on the machine
|
|
91
|
+
// can run one at the same moment, and `systemctl --user show` reads the file.
|
|
92
|
+
// After a rename each of them sees the old file or the new one, never a
|
|
93
|
+
// truncated unit.
|
|
94
|
+
//
|
|
95
|
+
// POSIX rename replaces the destination atomically, so this needs no
|
|
96
|
+
// chmod-if-exists dance and no cleanup of the old file.
|
|
97
|
+
//
|
|
98
|
+
// The temp NAME and the temp WRITE are each half of the same defence, and
|
|
99
|
+
// neither is sufficient on its own. A predictable name (`<file>.tmp-<pid>`) lets
|
|
100
|
+
// a process running as this user — which is every seat this repo spawns —
|
|
101
|
+
// pre-plant a symlink at that path and capture the key when the write follows
|
|
102
|
+
// it; `O_EXCL` is what actually refuses that, because it fails on a symlink at
|
|
103
|
+
// the final component instead of writing through it. The random suffix only
|
|
104
|
+
// removes the easy target, and the cleanup only deletes a temp this call made.
|
|
105
|
+
export const SERVICE_FILE_MODE = 0o600;
|
|
106
|
+
|
|
107
|
+
// Exported so the O_EXCL refusal is witnessed against a real symlink at a known
|
|
108
|
+
// path rather than assumed from the absence of a race.
|
|
109
|
+
export const serviceTempPath = (file, suffix = randomBytes(6).toString('hex')) => `${file}.tmp-${suffix}`;
|
|
110
|
+
|
|
111
|
+
export const writeServiceFile = (file, content, { suffix } = {}) => {
|
|
112
|
+
const tmp = serviceTempPath(file, suffix);
|
|
113
|
+
let created = false;
|
|
114
|
+
try {
|
|
115
|
+
// `wx` = O_CREAT|O_EXCL: the write either creates the temp itself or does not
|
|
116
|
+
// happen at all, so a planted path is refused rather than followed.
|
|
117
|
+
writeFileSync(tmp, content, { encoding: 'utf8', mode: SERVICE_FILE_MODE, flag: 'wx' });
|
|
118
|
+
created = true;
|
|
119
|
+
renameSync(tmp, file);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Only the temp this call created is ours to remove. After a refused write
|
|
122
|
+
// the path belongs to whoever planted it.
|
|
123
|
+
if (created) {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(tmp);
|
|
126
|
+
} catch {
|
|
127
|
+
// Nothing to clean up; the original error is the one that matters.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
export const providerEnvPairs = ({ names = [], env = process.env } = {}) => names
|
|
134
|
+
.filter((name) => env[name])
|
|
135
|
+
.map((name) => [name, String(env[name])]);
|
|
136
|
+
|
|
137
|
+
export const missingProviderKeys = ({ names = [], env = process.env } = {}) => names
|
|
138
|
+
.filter((name) => !env[name]);
|
|
139
|
+
|
|
140
|
+
const plistEnvPairs = (pairs) => pairs
|
|
141
|
+
.map(([name, value]) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t<string>${xmlEscape(value)}</string>\n`)
|
|
142
|
+
.join('');
|
|
143
|
+
|
|
144
|
+
export const launchdPlist = ({ nodePath, cliPath, home = homedir(), providerEnv = [] }) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
145
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
146
|
<plist version="1.0">
|
|
42
147
|
<dict>
|
|
@@ -48,7 +153,7 @@ export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml
|
|
|
48
153
|
\t\t<string>${xmlEscape(childPath(nodePath))}</string>
|
|
49
154
|
\t\t<key>HOME</key>
|
|
50
155
|
\t\t<string>${xmlEscape(home)}</string>
|
|
51
|
-
\t</dict>
|
|
156
|
+
${plistEnvPairs(providerEnv)}\t</dict>
|
|
52
157
|
\t<key>ProgramArguments</key>
|
|
53
158
|
\t<array>
|
|
54
159
|
\t\t<string>${xmlEscape(nodePath)}</string>
|
|
@@ -69,7 +174,11 @@ export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml
|
|
|
69
174
|
</plist>
|
|
70
175
|
`;
|
|
71
176
|
|
|
72
|
-
export const systemdUnit = ({ nodePath, cliPath }) =>
|
|
177
|
+
export const systemdUnit = ({ nodePath, cliPath, providerEnv = [] }) => {
|
|
178
|
+
const providerLines = providerEnv.length
|
|
179
|
+
? `\n${providerEnv.map(([name, value]) => `Environment="${systemdEscape(`${name}=${value}`)}"`).join('\n')}`
|
|
180
|
+
: '';
|
|
181
|
+
return `[Unit]
|
|
73
182
|
Description=Commonly local agent daemon (ADR-026)
|
|
74
183
|
After=network-online.target
|
|
75
184
|
|
|
@@ -77,23 +186,28 @@ After=network-online.target
|
|
|
77
186
|
ExecStart=${nodePath} ${cliPath} daemon run --foreground
|
|
78
187
|
Restart=always
|
|
79
188
|
RestartSec=5
|
|
80
|
-
Environment=PATH=${childPath(nodePath)}
|
|
189
|
+
Environment=PATH=${childPath(nodePath)}${providerLines}
|
|
81
190
|
|
|
82
191
|
[Install]
|
|
83
192
|
WantedBy=default.target
|
|
84
193
|
`;
|
|
194
|
+
};
|
|
85
195
|
|
|
86
196
|
export const installDaemonService = async ({
|
|
87
197
|
platform = process.platform,
|
|
88
198
|
home = homedir(),
|
|
89
199
|
nodePath = process.execPath,
|
|
90
200
|
cliPath = resolve(process.argv[1]),
|
|
91
|
-
|
|
201
|
+
// The provider keys the seats' adapters declare (adapters/index.js).
|
|
202
|
+
providerKeyEnvNames = [],
|
|
203
|
+
env = process.env,
|
|
204
|
+
writeFile = writeServiceFile,
|
|
92
205
|
mkdirp,
|
|
93
206
|
execCmd, // async (argv: string[]) => void — throws on failure
|
|
94
207
|
chmod = () => {},
|
|
95
208
|
ensureFile,
|
|
96
209
|
log = () => {},
|
|
210
|
+
warn = log,
|
|
97
211
|
}) => {
|
|
98
212
|
const target = servicePaths(platform, home);
|
|
99
213
|
mkdirp(dirname(target.file));
|
|
@@ -104,17 +218,27 @@ export const installDaemonService = async ({
|
|
|
104
218
|
chmod(daemonLogPath(home), 0o600);
|
|
105
219
|
}
|
|
106
220
|
|
|
221
|
+
const providerEnv = providerEnvPairs({ names: providerKeyEnvNames, env });
|
|
222
|
+
const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
|
|
223
|
+
|
|
107
224
|
if (target.kind === 'launchd') {
|
|
108
|
-
|
|
225
|
+
// The mode and the atomicity are the WRITER's, not an ordering rule here:
|
|
226
|
+
// `writeServiceFile` creates a 0600 sibling and renames it over the target,
|
|
227
|
+
// so no chmod is needed on this path and no half-written unit is ever
|
|
228
|
+
// visible to launchd (see the writer for why neither half could cover both).
|
|
229
|
+
writeFile(target.file, launchdPlist({ nodePath, cliPath, home, providerEnv }));
|
|
109
230
|
// Reload cleanly if a previous version is loaded; the unload of an
|
|
110
231
|
// unknown label fails by design and is ignored.
|
|
111
232
|
await execCmd(['launchctl', 'unload', target.file]).catch(() => {});
|
|
112
233
|
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
113
234
|
} else {
|
|
114
|
-
writeFile(target.file, systemdUnit({ nodePath, cliPath }));
|
|
235
|
+
writeFile(target.file, systemdUnit({ nodePath, cliPath, providerEnv }));
|
|
115
236
|
await execCmd(['systemctl', '--user', 'daemon-reload']);
|
|
116
237
|
await execCmd(['systemctl', '--user', 'enable', '--now', SYSTEMD_UNIT]);
|
|
117
238
|
}
|
|
239
|
+
for (const name of missing) {
|
|
240
|
+
warn(`${name} is not set in this shell, so it is NOT in the service file. A seat whose adapter needs it will fail to start; export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
241
|
+
}
|
|
118
242
|
log(`Installed ${target.kind} service (${target.file}). Logs: ${daemonLogPath(home)}`);
|
|
119
243
|
return target;
|
|
120
244
|
};
|