@cabane/companion 0.6.156 → 0.6.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/launch.js ADDED
@@ -0,0 +1,411 @@
1
+ // src/launch.ts
2
+ import { readFileSync as readFileSync3 } from "fs";
3
+ import { join as join5 } from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ // src/cabane-command.ts
7
+ import { accessSync, constants, realpathSync as realpathSync2 } from "fs";
8
+ import { homedir as homedir3 } from "os";
9
+ import { delimiter, join as join2, sep as sep2 } from "path";
10
+
11
+ // src/bundled-binaries.ts
12
+ import { realpathSync, statSync } from "fs";
13
+ import { createRequire } from "module";
14
+ import { homedir } from "os";
15
+ import { dirname, join, sep } from "path";
16
+ import { fileURLToPath } from "url";
17
+ var CODEX_CLI = "@openai/codex";
18
+ var CODEX_PACKAGE_BY_TRIPLE = {
19
+ "x86_64-unknown-linux-musl": `${CODEX_CLI}-linux-x64`,
20
+ "aarch64-unknown-linux-musl": `${CODEX_CLI}-linux-arm64`,
21
+ "x86_64-apple-darwin": `${CODEX_CLI}-darwin-x64`,
22
+ "aarch64-apple-darwin": `${CODEX_CLI}-darwin-arm64`,
23
+ "x86_64-pc-windows-msvc": `${CODEX_CLI}-win32-x64`,
24
+ "aarch64-pc-windows-msvc": `${CODEX_CLI}-win32-arm64`
25
+ };
26
+
27
+ // src/cli-entry.ts
28
+ import { existsSync } from "fs";
29
+ import { fileURLToPath as fileURLToPath2 } from "url";
30
+
31
+ // src/term.ts
32
+ import { createInterface } from "readline";
33
+ import { homedir as homedir2 } from "os";
34
+ import pc from "picocolors";
35
+
36
+ // src/config.ts
37
+ import {
38
+ chmodSync,
39
+ mkdirSync,
40
+ readFileSync,
41
+ renameSync,
42
+ rmSync,
43
+ writeFileSync,
44
+ existsSync as existsSync2
45
+ } from "fs";
46
+ import { homedir as homedir4, userInfo } from "os";
47
+ import { dirname as dirname2, join as join3 } from "path";
48
+ import { z as z2 } from "zod";
49
+
50
+ // src/pairing.ts
51
+ function isAllowedBaseUrl(raw) {
52
+ let url;
53
+ try {
54
+ url = new URL(raw);
55
+ } catch {
56
+ return false;
57
+ }
58
+ if (url.protocol === "https:") return true;
59
+ if (url.protocol === "http:") {
60
+ const host = url.hostname.toLowerCase();
61
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
62
+ }
63
+ return false;
64
+ }
65
+
66
+ // src/prepare-hook.ts
67
+ import { spawn } from "child_process";
68
+ import { z } from "zod";
69
+ var prepareHookSchema = z.object({
70
+ command: z.string().min(1),
71
+ args: z.array(z.string()).optional(),
72
+ // Extra env handed to the hook process itself (merged over process.env).
73
+ env: z.record(z.string(), z.string()).optional(),
74
+ // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
75
+ // default is generous; a hook that hangs past this is killed and the turn
76
+ // fails with a clear timeout message rather than pinning the companion.
77
+ timeoutMs: z.number().int().positive().optional(),
78
+ // Re-invoke the hook when a prepared result already exists, in VALIDATE
79
+ // mode: the cached result is handed back as `prepared` and stdout is
80
+ // ignored, so the cwd cannot move. Its only power is to exit non-zero and
81
+ // fail the turn. The hook resolves once per (conversation, agent), so a
82
+ // later message asking for a different environment is otherwise silently
83
+ // ignored; this lets a hook refuse it out loud.
84
+ //
85
+ // Default OFF: a hook that provisions unconditionally must not start
86
+ // running on every turn because the companion upgraded.
87
+ validateCached: z.boolean().optional(),
88
+ // Independent operator-owned reporter. Receives sanitized failure metadata
89
+ // on stdin; never re-enters this hook and cannot change the turn outcome.
90
+ failureReporter: z.object({
91
+ command: z.string().min(1),
92
+ args: z.array(z.string()).optional(),
93
+ env: z.record(z.string(), z.string()).optional(),
94
+ timeoutMs: z.number().int().positive().max(3e4).optional()
95
+ }).strict().optional()
96
+ }).strict();
97
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
98
+ var prepareResultSchema = z.object({
99
+ cwd: z.string().min(1),
100
+ env: z.record(z.string(), z.string()).optional()
101
+ });
102
+
103
+ // src/config.ts
104
+ var realHomeCache;
105
+ function realAccountHome() {
106
+ if (realHomeCache === void 0) {
107
+ try {
108
+ realHomeCache = userInfo().homedir;
109
+ } catch {
110
+ realHomeCache = null;
111
+ }
112
+ }
113
+ return realHomeCache;
114
+ }
115
+ function cabaneDir() {
116
+ const dir = join3(process.env.CABANE_COMPANION_HOME || homedir4(), ".cabane");
117
+ if (process.env.VITEST) {
118
+ const real = realAccountHome();
119
+ if (real && dir === join3(real, ".cabane")) {
120
+ throw new Error(
121
+ `cabaneDir() resolved to the real ${dir} during a test run. Companion tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real companion config (see apps/companion/test/setup-home.ts).`
122
+ );
123
+ }
124
+ }
125
+ return dir;
126
+ }
127
+ var localAgentConfigSchema = z2.object({
128
+ cwd: z2.string().optional(),
129
+ prepareHook: prepareHookSchema.optional(),
130
+ // The Claude Code auto-memory escape hatch. Auto-memory is forced OFF by
131
+ // default (memory belongs in the Cabane workspace, and a shared companion
132
+ // would pool one cwd-keyed memory dir across users). On a companion you run
133
+ // yourself, `claudeCode: { autoMemory: true }` hands auto-memory back to your
134
+ // own `~/.claude/settings.json` — Cabane then stops
135
+ // injecting the off switch and your normal Claude Code memory workflow applies
136
+ // (in coding mode, where the checkout's project settings are read).
137
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
138
+ // This agent's exception to `runners.sandbox`: an ops agent that runs with
139
+ // the machine on a sandboxed device, or one sandboxed agent on a device that
140
+ // isn't. Absent is "inherit". Only on a row keyed by the agent's id.
141
+ sandbox: z2.enum(["os", "none"]).optional()
142
+ }).strict();
143
+ var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
144
+ var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
145
+ for (const [key, row] of Object.entries(agents)) {
146
+ if (row.sandbox === void 0) continue;
147
+ if (AGENT_ID_KEY.test(key)) continue;
148
+ ctx.addIssue({
149
+ code: z2.ZodIssueCode.custom,
150
+ path: [key, "sandbox"],
151
+ message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
152
+ });
153
+ }
154
+ });
155
+ var companionConfigSchema = z2.object({
156
+ // https-enforced (loopback exempt), so a hand-edited config can't put the
157
+ // device token and prompt on a plaintext channel.
158
+ baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
159
+ message: "baseUrl must be https (loopback http is allowed for local dev only)"
160
+ }),
161
+ // The `cabdev_` device token plaintext — the companion's one durable credential,
162
+ // presented as `Authorization: Bearer <token>` on the device-facing pull +
163
+ // heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
164
+ // "paired but logged out" state the supervisor refuses to run) while keeping
165
+ // the rest of the config; `pair` always writes one.
166
+ deviceToken: z2.string().optional(),
167
+ // Identity hints, learned from the device flow and refreshed on the first
168
+ // assignments pull. Cosmetic — used for `status`/dashboard display only.
169
+ deviceId: z2.string().optional(),
170
+ deviceLabel: z2.string().optional(),
171
+ // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
172
+ // agentId / username / `slug/username`. Hand-added by the operator; the companion
173
+ // never writes this (it only persists credentials + the device, elsewhere).
174
+ agents: agentsConfigSchema.optional(),
175
+ // The DEVICE-LEVEL prepare hook, for every agent assigned here unless its own
176
+ // `agents` row names one. Per-agent hooks alone make the config a second
177
+ // silent roster: an agent nobody listed gets no hook and no error, and starts
178
+ // in the operator's `$HOME` (an agent woke in `/home/joshed` this way on
179
+ // 2026-08-17).
180
+ prepareHook: prepareHookSchema.optional(),
181
+ // `dashboardPort` and `autoOpen` are IGNORED by the CLI (no port, no browser)
182
+ // but stay in the schema so an existing file still parses; the Electron shell
183
+ // reads them. `logLevel` is pino's level, live-editable.
184
+ dashboardPort: z2.number().int().min(1).max(65535).optional(),
185
+ autoOpen: z2.boolean().optional(),
186
+ logLevel: z2.enum(["warn", "info", "debug"]).optional(),
187
+ logFormat: z2.enum(["human", "json"]).optional(),
188
+ // An `opencode serve` the operator runs here (auth via opencode's own
189
+ // `/connect`; Cabane never sees provider keys). Set, the device advertises the
190
+ // `opencode` runtime and registers its adapter.
191
+ opencode: z2.object({
192
+ serverUrl: z2.string().url()
193
+ }).strict().optional(),
194
+ // Whether the user connected Claude Code on this machine: consent, opted into
195
+ // like codex. The manifest advertises claude-code only when this says so AND
196
+ // the Agent SDK's bundled executable resolves (not `claude` on PATH, which the
197
+ // SDK never runs).
198
+ //
199
+ // Absent means one of two things, told apart by `harnessesChosen`: a legacy
200
+ // config whose user was never asked (`migrateConnectedHarnesses`), or a
201
+ // device that has never been offered Claude Code. `enabled: false` is a
202
+ // decline, and `start` doesn't offer it again.
203
+ //
204
+ // Not the per-agent `agents.<key>.claudeCode.autoMemory`, one agent's memory
205
+ // switch.
206
+ claudeCode: z2.object({
207
+ enabled: z2.boolean().optional()
208
+ }).strict().optional(),
209
+ // Written by every pairing that asks which agents to connect. With it, an
210
+ // absent `claudeCode` block means "never offered"; without it, the block's
211
+ // absence marks a config from before connectors were chosen.
212
+ harnessesChosen: z2.boolean().optional(),
213
+ // Whether this companion keeps itself current: installs the version Cabane names, between
214
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
215
+ // which `start` offers; off either way until someone says yes.
216
+ update: z2.object({
217
+ auto: z2.boolean()
218
+ }).strict().optional(),
219
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
220
+ // `cabane service install` still installs it.
221
+ serviceDeclined: z2.boolean().optional(),
222
+ // OpenCode's decline. Its block is an address, so a decline can't live there
223
+ // the way `claudeCode`/`codex` `enabled: false` does; without it a declined
224
+ // serve would be offered again on every `start`.
225
+ opencodeDeclined: z2.boolean().optional(),
226
+ // Codex is a local CLI the SDK spawns per turn, so this is an opt-in flag, not
227
+ // an address. The operator logs in (`codex login` or `CODEX_API_KEY`; Cabane
228
+ // never sees the key). The block's presence enables it; `enabled: false` turns
229
+ // it off. Half the answer: advertising also requires the SDK's vendored
230
+ // `codex` binary to resolve.
231
+ codex: z2.object({
232
+ enabled: z2.boolean().optional()
233
+ }).strict().optional(),
234
+ // Run each turn in a child runner process. Absent or `enabled: false` is the
235
+ // in-process path. `sandbox: 'os'` wraps the runner in an OS sandbox where the
236
+ // host supports one; `'none'` (the default) is a plain child, supported in its
237
+ // own right. `policy` is what the sandbox lets a turn reach, read and hold,
238
+ // validated in `runner/sandbox-policy.ts` so a bad policy refuses the turns it
239
+ // governs, field named, instead of stopping the companion starting.
240
+ runners: z2.object({
241
+ enabled: z2.boolean().optional(),
242
+ provider: z2.enum(["local-process", "docker"]).optional(),
243
+ sandbox: z2.enum(["os", "none"]).optional(),
244
+ // A container per conversation and agent, from one image, on a network
245
+ // whose only way out is an allow-listed proxy.
246
+ docker: z2.object({
247
+ // The base image a container runs. Default: the one this companion
248
+ // builds from its own Dockerfile on first use. One named here must
249
+ // have that one's layout (the `runner` user, `cabane-companion` on
250
+ // PATH reading `CABANE_COMPANION_ROOT`).
251
+ image: z2.string().min(1).optional(),
252
+ // The companion install mounted into every container. Default, on
253
+ // Linux: this companion's own. Needed for one run from a checkout
254
+ // (a `pnpm --filter @cabane/companion deploy --prod <dir>`).
255
+ companionDir: z2.string().min(1).optional(),
256
+ // Hosts a turn may reach besides the Cabane API, the model providers
257
+ // and the git host. `*.example.com` matches any subdomain.
258
+ egressAllow: z2.array(z2.string().min(1)).optional(),
259
+ // Keep a turn's container between turns and start it again for the
260
+ // next one. Off by default: the container goes after every turn and
261
+ // the runtime's session files are restored into the next.
262
+ keepContainers: z2.boolean().optional(),
263
+ // The Cabane URL as a container reaches it, for a device whose own
264
+ // `baseUrl` is a loopback address the container can't see.
265
+ apiUrl: z2.string().url().refine(isAllowedBaseUrl).optional(),
266
+ // Env set on each turn's runner, `${NAME}` resolved from secrets.json
267
+ // at exec: the runtime's own auth, which the image never carries.
268
+ env: z2.record(z2.string(), z2.string()).optional(),
269
+ // A git credential helper run on this device to answer the
270
+ // container's git for an allowed host, e.g. `!gh auth git-credential`.
271
+ // Absent: git's own configured helpers on this device.
272
+ gitCredentialHelper: z2.string().min(1).optional()
273
+ }).strict().optional(),
274
+ policy: z2.unknown().optional()
275
+ }).strict().optional()
276
+ });
277
+
278
+ // src/install-layout.ts
279
+ import {
280
+ existsSync as existsSync3,
281
+ lstatSync,
282
+ mkdirSync as mkdirSync2,
283
+ readFileSync as readFileSync2,
284
+ readdirSync,
285
+ realpathSync as realpathSync3,
286
+ renameSync as renameSync2,
287
+ rmSync as rmSync2,
288
+ symlinkSync
289
+ } from "fs";
290
+ import { dirname as dirname3, join as join4 } from "path";
291
+ import { fileURLToPath as fileURLToPath3 } from "url";
292
+ function currentLink() {
293
+ return join4(cabaneDir(), "current");
294
+ }
295
+ function ownPackageRoot(moduleUrl = import.meta.url) {
296
+ return dirname3(dirname3(fileURLToPath3(moduleUrl)));
297
+ }
298
+ function packageVersion(root) {
299
+ try {
300
+ const pkg = JSON.parse(readFileSync2(join4(root, "package.json"), "utf8"));
301
+ return typeof pkg.version === "string" ? pkg.version : null;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+ function currentRoot() {
307
+ try {
308
+ const root = realpathSync3(currentLink());
309
+ return existsSync3(join4(root, "dist", "cli.js")) ? root : null;
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+ function pointCurrentAt(root) {
315
+ const link = currentLink();
316
+ mkdirSync2(dirname3(link), { recursive: true });
317
+ const fresh = `${link}.${process.pid}.${Date.now()}`;
318
+ symlinkSync(root, fresh);
319
+ try {
320
+ renameSync2(fresh, link);
321
+ } catch (err) {
322
+ rmSync2(fresh, { force: true });
323
+ throw err;
324
+ }
325
+ }
326
+ function currentIsLink() {
327
+ try {
328
+ return lstatSync(currentLink()).isSymbolicLink();
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+ function compareVersions(a, b) {
334
+ const parts = (v) => /^\d+(\.\d+)*$/.test(v) ? v.split(".").map(Number) : null;
335
+ const pa = parts(a);
336
+ const pb = parts(b);
337
+ if (!pa || !pb) return pa ? 1 : pb ? -1 : 0;
338
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
339
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
340
+ if (d !== 0) return d;
341
+ }
342
+ return 0;
343
+ }
344
+
345
+ // src/launch.ts
346
+ var SOURCE_BUILD_VERSION = "0.0.0";
347
+ function chooseInstall(own, current) {
348
+ if (own.version === SOURCE_BUILD_VERSION || !current || current.root === own.root)
349
+ return { install: own, repoint: false };
350
+ if (compareVersions(current.version, own.version) >= 0)
351
+ return { install: current, repoint: false };
352
+ return { install: own, repoint: true };
353
+ }
354
+ function parseEnvFile(text) {
355
+ const out = {};
356
+ for (const raw of text.split("\n")) {
357
+ const line = raw.trim();
358
+ if (!line || line.startsWith("#")) continue;
359
+ const eq = line.indexOf("=");
360
+ if (eq <= 0) continue;
361
+ const key = line.slice(0, eq).trim();
362
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
363
+ let value = line.slice(eq + 1).trim();
364
+ if (value.length >= 2 && /^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
365
+ out[key] = value;
366
+ }
367
+ return out;
368
+ }
369
+ function loadServiceEnv() {
370
+ let text;
371
+ try {
372
+ text = readFileSync3(join5(cabaneDir(), "service.env"), "utf8");
373
+ } catch {
374
+ return;
375
+ }
376
+ for (const [key, value] of Object.entries(parseEnvFile(text))) {
377
+ if (process.env[key] === void 0) process.env[key] = value;
378
+ }
379
+ }
380
+ async function launch(argv = process.argv.slice(2)) {
381
+ const ownRoot = ownPackageRoot();
382
+ if (process.env.CABANE_LAUNCH_SELF === "1") {
383
+ await import("./cli.js");
384
+ return;
385
+ }
386
+ const own = { root: ownRoot, version: packageVersion(ownRoot) ?? SOURCE_BUILD_VERSION };
387
+ const curRoot = currentRoot();
388
+ const curVersion = curRoot ? packageVersion(curRoot) : null;
389
+ const choice = chooseInstall(
390
+ own,
391
+ curRoot && curVersion ? { root: curRoot, version: curVersion } : null
392
+ );
393
+ if (choice.repoint && currentIsLink()) {
394
+ try {
395
+ pointCurrentAt(own.root);
396
+ } catch {
397
+ }
398
+ }
399
+ const run = choice.install;
400
+ if (argv[0] === "start" && process.env.CABANE_COMPANION_SUPERVISED === "1") loadServiceEnv();
401
+ if (run.root === ownRoot) {
402
+ await import("./cli.js");
403
+ return;
404
+ }
405
+ await import(pathToFileURL(join5(run.root, "dist", "cli.js")).href);
406
+ }
407
+ export {
408
+ chooseInstall,
409
+ launch,
410
+ parseEnvFile
411
+ };
@@ -307,6 +307,15 @@ var companionConfigSchema = z2.object({
307
307
  // absent `claudeCode` block means "never offered"; without it, the block's
308
308
  // absence marks a config from before connectors were chosen.
309
309
  harnessesChosen: z2.boolean().optional(),
310
+ // Whether this companion keeps itself current: installs the version Cabane names, between
311
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
312
+ // which `start` offers; off either way until someone says yes.
313
+ update: z2.object({
314
+ auto: z2.boolean()
315
+ }).strict().optional(),
316
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
317
+ // `cabane service install` still installs it.
318
+ serviceDeclined: z2.boolean().optional(),
310
319
  // OpenCode's decline. Its block is an address, so a decline can't live there
311
320
  // the way `claudeCode`/`codex` `enabled: false` does; without it a declined
312
321
  // serve would be offered again on every `start`.