@cabane/companion 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -9
- package/dist/cli.js +1150 -1226
- package/dist/pairing-config.js +58 -77
- package/dist/runtime.js +1008 -1043
- package/dist/static/app.js +22 -19
- package/dist/static/index.html +6 -7
- package/dist/static/styles.css +1 -1
- package/package.json +3 -4
package/dist/pairing-config.js
CHANGED
|
@@ -10,16 +10,16 @@ import {
|
|
|
10
10
|
} from "fs";
|
|
11
11
|
import { homedir, userInfo } from "os";
|
|
12
12
|
import { dirname, join } from "path";
|
|
13
|
-
import { z as
|
|
13
|
+
import { z as z2 } from "zod";
|
|
14
14
|
|
|
15
15
|
// src/errors.ts
|
|
16
|
-
var
|
|
16
|
+
var CompanionError = class extends Error {
|
|
17
17
|
constructor(message) {
|
|
18
18
|
super(message);
|
|
19
|
-
this.name = "
|
|
19
|
+
this.name = "CompanionError";
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
|
-
var ApiError = class extends
|
|
22
|
+
var ApiError = class extends CompanionError {
|
|
23
23
|
constructor(status, message, body) {
|
|
24
24
|
super(message);
|
|
25
25
|
this.status = status;
|
|
@@ -29,7 +29,7 @@ var ApiError = class extends BridgeError {
|
|
|
29
29
|
status;
|
|
30
30
|
body;
|
|
31
31
|
};
|
|
32
|
-
var ConfigError = class extends
|
|
32
|
+
var ConfigError = class extends CompanionError {
|
|
33
33
|
constructor(message) {
|
|
34
34
|
super(message);
|
|
35
35
|
this.name = "ConfigError";
|
|
@@ -37,9 +37,6 @@ var ConfigError = class extends BridgeError {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// src/pairing.ts
|
|
40
|
-
import { z } from "zod";
|
|
41
|
-
var PAIRING_VERSION = 1;
|
|
42
|
-
var DEVICE_TOKEN_PREFIX = "cabdev_";
|
|
43
40
|
function isAllowedBaseUrl(raw) {
|
|
44
41
|
let url;
|
|
45
42
|
try {
|
|
@@ -54,40 +51,24 @@ function isAllowedBaseUrl(raw) {
|
|
|
54
51
|
}
|
|
55
52
|
return false;
|
|
56
53
|
}
|
|
57
|
-
var pairingSchema = z.object({
|
|
58
|
-
// Bumped if the wire shape changes incompatibly. We only accept v1.
|
|
59
|
-
v: z.literal(PAIRING_VERSION),
|
|
60
|
-
baseUrl: z.string().url().refine(isAllowedBaseUrl, {
|
|
61
|
-
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
62
|
-
}),
|
|
63
|
-
// The `cabdev_` device token plaintext — the bridge's one durable credential.
|
|
64
|
-
deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
|
|
65
|
-
message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
|
|
66
|
-
}),
|
|
67
|
-
// Optional identity hints the app may include for nicer local display. The
|
|
68
|
-
// bridge also learns these from the first assignments pull, so they're not
|
|
69
|
-
// required.
|
|
70
|
-
deviceId: z.string().min(1).optional(),
|
|
71
|
-
deviceLabel: z.string().min(1).optional()
|
|
72
|
-
});
|
|
73
54
|
|
|
74
55
|
// src/prepare-hook.ts
|
|
75
56
|
import { spawn } from "child_process";
|
|
76
|
-
import { z
|
|
77
|
-
var prepareHookSchema =
|
|
78
|
-
command:
|
|
79
|
-
args:
|
|
57
|
+
import { z } from "zod";
|
|
58
|
+
var prepareHookSchema = z.object({
|
|
59
|
+
command: z.string().min(1),
|
|
60
|
+
args: z.array(z.string()).optional(),
|
|
80
61
|
// Extra env handed to the hook process itself (merged over process.env).
|
|
81
|
-
env:
|
|
62
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
82
63
|
// Wall-clock cap for the hook. Provisioning is slow (minutes), so the
|
|
83
64
|
// default is generous; a hook that hangs past this is killed and the turn
|
|
84
|
-
// fails with a clear timeout message rather than pinning the
|
|
85
|
-
timeoutMs:
|
|
65
|
+
// fails with a clear timeout message rather than pinning the companion.
|
|
66
|
+
timeoutMs: z.number().int().positive().optional()
|
|
86
67
|
}).strict();
|
|
87
68
|
var DEFAULT_TIMEOUT_MS = 10 * 6e4;
|
|
88
|
-
var prepareResultSchema =
|
|
89
|
-
cwd:
|
|
90
|
-
env:
|
|
69
|
+
var prepareResultSchema = z.object({
|
|
70
|
+
cwd: z.string().min(1),
|
|
71
|
+
env: z.record(z.string(), z.string()).optional()
|
|
91
72
|
});
|
|
92
73
|
|
|
93
74
|
// src/config.ts
|
|
@@ -103,12 +84,12 @@ function realAccountHome() {
|
|
|
103
84
|
return realHomeCache;
|
|
104
85
|
}
|
|
105
86
|
function cabaneDir() {
|
|
106
|
-
const dir = join(process.env.
|
|
87
|
+
const dir = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
|
|
107
88
|
if (process.env.VITEST) {
|
|
108
89
|
const real = realAccountHome();
|
|
109
90
|
if (real && dir === join(real, ".cabane")) {
|
|
110
91
|
throw new Error(
|
|
111
|
-
`cabaneDir() resolved to the real ${dir} during a test run.
|
|
92
|
+
`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).`
|
|
112
93
|
);
|
|
113
94
|
}
|
|
114
95
|
}
|
|
@@ -117,55 +98,55 @@ function cabaneDir() {
|
|
|
117
98
|
function configPath() {
|
|
118
99
|
return join(cabaneDir(), "config.json");
|
|
119
100
|
}
|
|
120
|
-
var localAgentConfigSchema =
|
|
121
|
-
cwd:
|
|
101
|
+
var localAgentConfigSchema = z2.object({
|
|
102
|
+
cwd: z2.string().optional(),
|
|
122
103
|
prepareHook: prepareHookSchema.optional(),
|
|
123
104
|
// CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
|
|
124
|
-
// by default on every
|
|
125
|
-
//
|
|
126
|
-
// On a
|
|
105
|
+
// by default on every companion (memory belongs in the Cabane workspace, and a
|
|
106
|
+
// shared companion would otherwise pool one cwd-keyed memory dir across users).
|
|
107
|
+
// On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
|
|
127
108
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
128
109
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
129
110
|
// (in coding mode, where the checkout's project settings are read).
|
|
130
|
-
claudeCode:
|
|
111
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
131
112
|
}).strict();
|
|
132
|
-
var
|
|
113
|
+
var companionConfigSchema = z2.object({
|
|
133
114
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
134
115
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
135
116
|
// base URL onto the MITM-able channel the device token + prompt ride.
|
|
136
|
-
baseUrl:
|
|
117
|
+
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
137
118
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
138
119
|
}),
|
|
139
|
-
// The `cabdev_` device token plaintext — the
|
|
120
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential,
|
|
140
121
|
// presented as `Authorization: Bearer <token>` on the device-facing pull +
|
|
141
122
|
// heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
|
|
142
123
|
// "paired but logged out" state the supervisor refuses to run) while keeping
|
|
143
124
|
// the rest of the config; `pair` always writes one.
|
|
144
|
-
deviceToken:
|
|
145
|
-
// Identity hints, learned from the
|
|
125
|
+
deviceToken: z2.string().optional(),
|
|
126
|
+
// Identity hints, learned from the device flow and refreshed on the first
|
|
146
127
|
// assignments pull. Cosmetic — used for `status`/dashboard display only.
|
|
147
|
-
deviceId:
|
|
148
|
-
deviceLabel:
|
|
128
|
+
deviceId: z2.string().optional(),
|
|
129
|
+
deviceLabel: z2.string().optional(),
|
|
149
130
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
150
|
-
// agentId / username / `slug/username`. Hand-added by the operator; the
|
|
131
|
+
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
151
132
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
152
|
-
agents:
|
|
133
|
+
agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
|
|
153
134
|
// Dashboard settings (all optional). dashboardPort: preferred bind port (next
|
|
154
135
|
// free one if taken); autoOpen: whether `start` opens the browser (the
|
|
155
|
-
// `--no-open` flag / `
|
|
136
|
+
// `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
|
|
156
137
|
// level, live-editable from the dashboard settings panel.
|
|
157
|
-
dashboardPort:
|
|
158
|
-
autoOpen:
|
|
159
|
-
logLevel:
|
|
138
|
+
dashboardPort: z2.number().int().min(1).max(65535).optional(),
|
|
139
|
+
autoOpen: z2.boolean().optional(),
|
|
140
|
+
logLevel: z2.enum(["warn", "info", "debug"]).optional(),
|
|
160
141
|
// CT270: the opencode runtime, when the operator runs one on this machine. The
|
|
161
142
|
// operator installs opencode, starts `opencode serve` (auth via opencode's own
|
|
162
|
-
// `/connect` — Cabane never sees provider keys), and points the
|
|
143
|
+
// `/connect` — Cabane never sees provider keys), and points the companion at it
|
|
163
144
|
// here. Setting this makes the device advertise the `opencode` runtime on its
|
|
164
145
|
// heartbeat manifest (so the server offers DeepSeek/opencode models here and
|
|
165
146
|
// routes those turns to this device) AND registers the opencode adapter in the
|
|
166
147
|
// dispatcher. Absent → the device is claude-code-only, exactly as before.
|
|
167
|
-
opencode:
|
|
168
|
-
serverUrl:
|
|
148
|
+
opencode: z2.object({
|
|
149
|
+
serverUrl: z2.string().url()
|
|
169
150
|
}).strict().optional(),
|
|
170
151
|
// CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
|
|
171
152
|
// opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
|
|
@@ -176,8 +157,8 @@ var bridgeConfigSchema = z3.object({
|
|
|
176
157
|
// keeps the block but turns it off). Enabling makes the device advertise the
|
|
177
158
|
// `codex` runtime on its heartbeat manifest AND registers the codex adapter in
|
|
178
159
|
// the dispatcher. Absent → the device doesn't offer codex, exactly as before.
|
|
179
|
-
codex:
|
|
180
|
-
enabled:
|
|
160
|
+
codex: z2.object({
|
|
161
|
+
enabled: z2.boolean().optional()
|
|
181
162
|
}).strict().optional()
|
|
182
163
|
});
|
|
183
164
|
function loadConfig() {
|
|
@@ -200,7 +181,7 @@ function loadConfig() {
|
|
|
200
181
|
`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
201
182
|
);
|
|
202
183
|
}
|
|
203
|
-
const result =
|
|
184
|
+
const result = companionConfigSchema.safeParse(parsed);
|
|
204
185
|
if (!result.success) {
|
|
205
186
|
const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
|
|
206
187
|
if (agentIssue) {
|
|
@@ -209,7 +190,7 @@ function loadConfig() {
|
|
|
209
190
|
);
|
|
210
191
|
}
|
|
211
192
|
throw new ConfigError(
|
|
212
|
-
`${path} is from an incompatible or older version of the
|
|
193
|
+
`${path} is from an incompatible or older version of the companion, or was hand-edited. Run \`cabane-companion pair\` to re-pair, or \`cabane-companion logout --purge\` to reset.`
|
|
213
194
|
);
|
|
214
195
|
}
|
|
215
196
|
return result.data;
|
|
@@ -231,7 +212,7 @@ function loadConfigTolerant() {
|
|
|
231
212
|
} catch {
|
|
232
213
|
return { local: empty, note: `${path} was unreadable (invalid JSON) and has been reset.` };
|
|
233
214
|
}
|
|
234
|
-
const strict =
|
|
215
|
+
const strict = companionConfigSchema.safeParse(parsed);
|
|
235
216
|
if (strict.success) {
|
|
236
217
|
const { agents: agents2, dashboardPort, autoOpen, logLevel } = strict.data;
|
|
237
218
|
return {
|
|
@@ -246,7 +227,7 @@ function loadConfigTolerant() {
|
|
|
246
227
|
}
|
|
247
228
|
const obj = parsed && typeof parsed === "object" ? parsed : {};
|
|
248
229
|
const local = {};
|
|
249
|
-
const agents =
|
|
230
|
+
const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
|
|
250
231
|
if (agents.success) local.agents = agents.data;
|
|
251
232
|
if (typeof obj.dashboardPort === "number") local.dashboardPort = obj.dashboardPort;
|
|
252
233
|
if (typeof obj.autoOpen === "boolean") local.autoOpen = obj.autoOpen;
|
|
@@ -255,7 +236,7 @@ function loadConfigTolerant() {
|
|
|
255
236
|
}
|
|
256
237
|
return {
|
|
257
238
|
local,
|
|
258
|
-
note: `the existing ${path} was from an older or incompatible
|
|
239
|
+
note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`
|
|
259
240
|
};
|
|
260
241
|
}
|
|
261
242
|
function saveConfig(cfg) {
|
|
@@ -295,7 +276,7 @@ async function postJson(baseUrl, path, body) {
|
|
|
295
276
|
body: JSON.stringify(body)
|
|
296
277
|
});
|
|
297
278
|
} catch (err) {
|
|
298
|
-
throw new
|
|
279
|
+
throw new CompanionError(
|
|
299
280
|
`couldn't reach cabane at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}. Check the server URL (pass --server <url>) and your connection.`
|
|
300
281
|
);
|
|
301
282
|
}
|
|
@@ -310,8 +291,8 @@ async function postJson(baseUrl, path, body) {
|
|
|
310
291
|
}
|
|
311
292
|
if (res.status >= 400) {
|
|
312
293
|
if (res.status === 404 && path.endsWith("/code")) {
|
|
313
|
-
throw new
|
|
314
|
-
`this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server
|
|
294
|
+
throw new CompanionError(
|
|
295
|
+
`this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server to a version that supports \`cabane-companion pair\`.`
|
|
315
296
|
);
|
|
316
297
|
}
|
|
317
298
|
const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
|
|
@@ -320,16 +301,16 @@ async function postJson(baseUrl, path, body) {
|
|
|
320
301
|
return parsed;
|
|
321
302
|
}
|
|
322
303
|
function requestEnrollmentCode(baseUrl) {
|
|
323
|
-
return postJson(baseUrl, "/api/
|
|
304
|
+
return postJson(baseUrl, "/api/device-enrollment/code", {});
|
|
324
305
|
}
|
|
325
306
|
function pollOnce(baseUrl, deviceCode) {
|
|
326
|
-
return postJson(baseUrl, "/api/
|
|
307
|
+
return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
|
|
327
308
|
}
|
|
328
309
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
329
310
|
async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
330
311
|
const now = opts.now ?? (() => Date.now());
|
|
331
312
|
const wait = opts.sleepMs ?? sleep;
|
|
332
|
-
if (opts.signal?.aborted) throw new
|
|
313
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
333
314
|
const code = await requestEnrollmentCode(baseUrl);
|
|
334
315
|
if (opts.onCode) await opts.onCode(code);
|
|
335
316
|
print("");
|
|
@@ -344,11 +325,11 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
344
325
|
let intervalMs = Math.max(1, code.interval) * 1e3;
|
|
345
326
|
while (now() < deadline) {
|
|
346
327
|
await wait(intervalMs);
|
|
347
|
-
if (opts.signal?.aborted) throw new
|
|
328
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
348
329
|
const res = await pollOnce(baseUrl, code.deviceCode);
|
|
349
330
|
if (res.status === "complete") {
|
|
350
331
|
if (!res.deviceToken || !res.baseUrl) {
|
|
351
|
-
throw new
|
|
332
|
+
throw new CompanionError("the server reported the pairing complete but returned no token.");
|
|
352
333
|
}
|
|
353
334
|
return {
|
|
354
335
|
baseUrl: res.baseUrl,
|
|
@@ -358,13 +339,13 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
358
339
|
};
|
|
359
340
|
}
|
|
360
341
|
if (res.status === "expired") {
|
|
361
|
-
throw new
|
|
342
|
+
throw new CompanionError(
|
|
362
343
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
363
344
|
);
|
|
364
345
|
}
|
|
365
346
|
if (res.status === "slow_down") intervalMs += 1e3;
|
|
366
347
|
}
|
|
367
|
-
throw new
|
|
348
|
+
throw new CompanionError(
|
|
368
349
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
369
350
|
);
|
|
370
351
|
}
|
|
@@ -374,7 +355,7 @@ var DEFAULT_BASE_URL = "https://cabane.ai";
|
|
|
374
355
|
function resolvePairBaseUrl(server) {
|
|
375
356
|
const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
|
|
376
357
|
if (!isAllowedBaseUrl(raw)) {
|
|
377
|
-
throw new
|
|
358
|
+
throw new CompanionError(
|
|
378
359
|
`invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`cabane-companion pair --server https://your-cabane\`.`
|
|
379
360
|
);
|
|
380
361
|
}
|
|
@@ -401,7 +382,7 @@ function isDevicePaired() {
|
|
|
401
382
|
}
|
|
402
383
|
export {
|
|
403
384
|
ApiError,
|
|
404
|
-
|
|
385
|
+
CompanionError,
|
|
405
386
|
isDevicePaired,
|
|
406
387
|
requestEnrollmentCode,
|
|
407
388
|
resolvePairBaseUrl,
|