@cabane/companion 0.6.238 → 0.6.240

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 CHANGED
@@ -38,6 +38,8 @@ Enter the code in Cabane, signed in as yourself: from Your settings → Devices
38
38
 
39
39
  Without a terminal, setup prints the same lines once and waits, so a container's logs carry the code. An expired code is replaced by a fresh one, up to three; Ctrl-C stops cleanly. The link is on the Cabane the device pairs with. There is no default: setup pairs with the Cabane named by `--server <url>`, by `CABANE_BASE_URL`, or by the device's config, and stops if none names one. Once a Cabane has issued a code, setup keeps its address, so after a cancelled or expired pairing a bare `cabane setup` pairs with the same one.
40
40
 
41
+ The address must be https, or plain http to this machine (`localhost`, `127.0.0.1`). A Companion that reaches its Cabane across a private network where the server has no certificate, such as a container on a Docker network it shares only with the server, can be allowed plain http to that one host with `CABANE_ALLOW_PLAIN_HTTP_HOST`: for example `CABANE_ALLOW_PLAIN_HTTP_HOST=app` with `CABANE_BASE_URL=http://app:8788`. The value must be a name with no dot, which only the local network's resolver answers, or a private address (`10.x.x.x`, `172.16.x.x`–`172.31.x.x`, `192.168.x.x`, or IPv6 `fc00::/7`); anything else is ignored. A Companion that pairs over plain http to that host keeps that address, instead of the public one Cabane names, since that is the only way it reaches Cabane. A browser can't open that address, so the Companion doesn't print it: where it would name the device's page in Cabane, it says Your settings → Devices instead. Over plain http, anything else on that network can read the device token and the agents' work, so use it only on a network nothing else joins.
42
+
41
43
  From an interactive terminal, setup then starts the Companion in the background, waits for its first check-in and reports the device's state. Without an interactive terminal, setup stays attached to the running Companion.
42
44
 
43
45
  If no harness is usable, interactive setup asks which one you want to set up. Follow the complete CLI installation and sign-in steps for [Mac setup](https://cabane.ai/docs/mac-setup#2-sign-in), [Windows setup](https://cabane.ai/docs/windows-setup#2-sign-in) or [Server and VPS setup](https://cabane.ai/docs/server-and-vps-setup#2-sign-in-without-a-browser), then check `cabane status`.
@@ -57,7 +59,7 @@ If no harness is usable, interactive setup asks which one you want to set up. Fo
57
59
  In Cabane: https://cabane.example.com/settings/devices
58
60
  ```
59
61
 
60
- Setup never installs the service. On macOS or Linux, run `cabane service install` when you want the Companion to start at login and keep itself updated; the closing block names it. Native Windows uses the background process and has no service installer.
62
+ Setup never installs the service. On macOS or Linux, run `cabane service install` when you want the Companion to start at login and keep itself updated; the closing block names it. Native Windows uses the background process and has no service installer. Inside a container the Companion names neither the service nor automatic updates, in the closing block, `cabane status` or `cabane update`: whatever runs the container keeps it running, and you update it by replacing the container with a newer image.
61
63
 
62
64
  If `cabane` isn't found afterwards, open a new terminal, or run the `source` line setup printed.
63
65
 
package/dist/cli.js CHANGED
@@ -279,7 +279,7 @@ var init_errors = __esm({
279
279
  });
280
280
 
281
281
  // src/pairing.ts
282
- function isAllowedBaseUrl(raw) {
282
+ function isAllowedBaseUrl(raw, env = process.env) {
283
283
  let url;
284
284
  try {
285
285
  url = new URL(raw);
@@ -289,13 +289,63 @@ function isAllowedBaseUrl(raw) {
289
289
  if (url.protocol === "https:") return true;
290
290
  if (url.protocol === "http:") {
291
291
  const host = url.hostname.toLowerCase();
292
- return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
292
+ if (host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]")
293
+ return true;
294
+ const allowed = plainHttpHost(env);
295
+ return allowed !== null && host === allowed;
293
296
  }
294
297
  return false;
295
298
  }
299
+ function plainHttpHost(env = process.env) {
300
+ const raw = env[ALLOW_PLAIN_HTTP_HOST_ENV]?.trim().toLowerCase();
301
+ if (!raw) return null;
302
+ if (isSingleLabel(raw) || isPrivateIpv4(raw)) return raw;
303
+ const bare = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
304
+ return privateIpv6(bare);
305
+ }
306
+ function isSingleLabel(host) {
307
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host) && !/^\d+$/.test(host);
308
+ }
309
+ function isPrivateIpv4(host) {
310
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
311
+ if (!m) return false;
312
+ const [a, b, c, d] = m.slice(1).map(Number);
313
+ if ([a, b, c, d].some((n) => n > 255)) return false;
314
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
315
+ }
316
+ function privateIpv6(host) {
317
+ if (!host.includes(":")) return null;
318
+ let bracketed;
319
+ try {
320
+ bracketed = new URL(`http://[${host}]/`).hostname;
321
+ } catch {
322
+ return null;
323
+ }
324
+ const first = Number.parseInt(bracketed.slice(1).split(":")[0] || "0", 16);
325
+ return (first & 65024) === 64512 ? bracketed : null;
326
+ }
327
+ function addressToKeep(pairedOver, cabaneNamed, env = process.env) {
328
+ return onPlainHttpHost(pairedOver, env) ? pairedOver.replace(/\/+$/, "") : cabaneNamed;
329
+ }
330
+ function browserAddress(baseUrl, env = process.env) {
331
+ return onPlainHttpHost(baseUrl, env) ? null : baseUrl.replace(/\/+$/, "");
332
+ }
333
+ function onPlainHttpHost(raw, env) {
334
+ const allowed = plainHttpHost(env);
335
+ if (allowed === null) return false;
336
+ let url;
337
+ try {
338
+ url = new URL(raw);
339
+ } catch {
340
+ return false;
341
+ }
342
+ return url.protocol === "http:" && url.hostname.toLowerCase() === allowed;
343
+ }
344
+ var ALLOW_PLAIN_HTTP_HOST_ENV;
296
345
  var init_pairing = __esm({
297
346
  "src/pairing.ts"() {
298
347
  "use strict";
348
+ ALLOW_PLAIN_HTTP_HOST_ENV = "CABANE_ALLOW_PLAIN_HTTP_HOST";
299
349
  }
300
350
  });
301
351
 
@@ -712,10 +762,10 @@ var init_config = __esm({
712
762
  }).strict();
713
763
  agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema);
714
764
  companionConfigSchema = z2.object({
715
- // https-enforced (loopback exempt), so a hand-edited config can't put the
716
- // device token and prompt on a plaintext channel.
717
- baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
718
- message: "baseUrl must be https (loopback http is allowed for local dev only)"
765
+ // https-enforced (loopback, and the one private host a person names, exempt), so a
766
+ // hand-edited config can't put the device token and prompt on a plaintext channel.
767
+ baseUrl: z2.string().url().refine((url) => isAllowedBaseUrl(url), {
768
+ message: `baseUrl must be https (plain http only to this machine, or to the private host ${ALLOW_PLAIN_HTTP_HOST_ENV} names)`
719
769
  }),
720
770
  // The `cabdev_` device token plaintext — the companion's one durable credential,
721
771
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
@@ -10065,7 +10115,12 @@ async function awaitEnrollment(baseUrl, code, opts = {}) {
10065
10115
  init_cabane_command();
10066
10116
  init_device_state();
10067
10117
  init_harness_copy();
10118
+ init_pairing();
10068
10119
  var UNPAIRED = `This device isn't paired yet. Run ${cabane("setup")} on it.`;
10120
+ function devicesUrl(baseUrl, env = process.env) {
10121
+ const base = browserAddress(baseUrl, env);
10122
+ return base === null ? null : `${base}/settings/devices`;
10123
+ }
10069
10124
  var lease = DEFAULT_DEVICE_OFFLINE_THRESHOLD_MS;
10070
10125
  function age(at, now) {
10071
10126
  const seconds = Math.max(0, Math.floor((now - Date.parse(at)) / 1e3));
@@ -10074,7 +10129,7 @@ function age(at, now) {
10074
10129
  function deviceLine(f, now = Date.now()) {
10075
10130
  if (!f.running) return `not running \u2014 start it with ${cabane("start")}`;
10076
10131
  if (f.lastHeartbeatRefused)
10077
- return `refused by the server \u2014 it may be deactivated; see ${f.devicesUrl}`;
10132
+ return f.devicesUrl ? `refused by the server \u2014 it may be deactivated; see ${f.devicesUrl}` : "refused by the server \u2014 it may be deactivated; see Your settings \u2192 Devices in Cabane";
10078
10133
  if (!f.lastHeartbeatAckAt) return "starting \u2014 hasn't checked in yet";
10079
10134
  const since = age(f.lastHeartbeatAckAt, now);
10080
10135
  if (now - Date.parse(f.lastHeartbeatAckAt) > lease)
@@ -10123,7 +10178,8 @@ function closingBlock(input, now = Date.now()) {
10123
10178
  "",
10124
10179
  ` Status ${cabane("status")}${mode === "attached" ? "" : ` Stop ${cabane("stop")}`} Log ${cabane("logs")}`
10125
10180
  );
10126
- if (!input.service && input.servicePlatform) {
10181
+ if (input.container) {
10182
+ } else if (!input.service && input.servicePlatform) {
10127
10183
  lines2.push(
10128
10184
  ` Start at login and keep Cabane updating itself: ${cabane("service install")}${input.servicePlatform === "launchd" ? " (macOS will ask you to allow a background item)" : ""}`
10129
10185
  );
@@ -10287,6 +10343,9 @@ function openLink(link) {
10287
10343
  });
10288
10344
  }
10289
10345
 
10346
+ // src/commands/setup.ts
10347
+ init_pairing();
10348
+
10290
10349
  // src/commands/start.ts
10291
10350
  init_cabane_command();
10292
10351
  init_config();
@@ -10302,7 +10361,7 @@ function resolvePairBaseUrl(server) {
10302
10361
  if (!raw) throw new NoPairAddressError();
10303
10362
  if (!isAllowedBaseUrl(raw)) {
10304
10363
  throw new CompanionError(
10305
- `invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`${cabane("setup --server https://your-cabane")}\`.`
10364
+ `invalid server URL "${raw}": must be an https URL (plain http only to this machine, or to the private host ${ALLOW_PLAIN_HTTP_HOST_ENV} names). Pass it as \`${cabane("setup --server https://your-cabane")}\`.`
10306
10365
  );
10307
10366
  }
10308
10367
  return raw;
@@ -11585,14 +11644,15 @@ function osUser() {
11585
11644
  return typeof process.getuid === "function" ? `uid ${process.getuid()}` : "unknown";
11586
11645
  }
11587
11646
  }
11588
- function hostReport() {
11589
- let container;
11647
+ function containerHere() {
11590
11648
  try {
11591
- container = inContainer(readContainerSignals());
11649
+ return inContainer(readContainerSignals());
11592
11650
  } catch {
11593
- container = null;
11651
+ return null;
11594
11652
  }
11595
- return { user: osUser(), container };
11653
+ }
11654
+ function hostReport() {
11655
+ return { user: osUser(), container: containerHere() };
11596
11656
  }
11597
11657
 
11598
11658
  // src/providers/local-process.ts
@@ -14785,6 +14845,217 @@ function defaultSpawnDetached(args) {
14785
14845
  }
14786
14846
  }
14787
14847
 
14848
+ // src/commands/service.ts
14849
+ init_cabane_command();
14850
+ init_config();
14851
+ init_term();
14852
+
14853
+ // src/commands/stop.ts
14854
+ var TERM_GRACE_MS = 6e3;
14855
+ var POLL_INTERVAL_MS = 150;
14856
+ async function stop(deps = {}) {
14857
+ if ((deps.serviceInstalled ?? serviceInstalled)()) {
14858
+ const outcome = await (deps.stopService ?? stopService)();
14859
+ for (const line of outcome.lines) process.stdout.write(`${line}
14860
+ `);
14861
+ if (!outcome.ok) {
14862
+ process.exitCode = 1;
14863
+ return;
14864
+ }
14865
+ process.stdout.write(
14866
+ "companion: stopped the service. It starts again at boot, or with `cabane start`.\n"
14867
+ );
14868
+ return;
14869
+ }
14870
+ const readState = deps.readState ?? readLiveRuntimeState;
14871
+ const verify = deps.verify ?? ((s) => verifyRuntime(s));
14872
+ const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
14873
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
14874
+ const now = deps.now ?? (() => Date.now());
14875
+ const state = readState();
14876
+ if (!state) {
14877
+ process.stdout.write("companion: not running (nothing to stop).\n");
14878
+ return;
14879
+ }
14880
+ if (await verify(state) === "stale") {
14881
+ clearRuntimeState();
14882
+ process.stdout.write("companion: not running (stale marker swept).\n");
14883
+ return;
14884
+ }
14885
+ const { pid } = state;
14886
+ process.stdout.write(`companion: stopping (pid ${pid})\u2026
14887
+ `);
14888
+ try {
14889
+ kill(pid, "SIGTERM");
14890
+ } catch {
14891
+ clearRuntimeState();
14892
+ process.stdout.write("companion: already stopped.\n");
14893
+ return;
14894
+ }
14895
+ const deadline = now() + TERM_GRACE_MS;
14896
+ while (now() < deadline) {
14897
+ if (!isAlive(kill, pid)) {
14898
+ process.stdout.write("companion: stopped.\n");
14899
+ return;
14900
+ }
14901
+ await sleep4(POLL_INTERVAL_MS);
14902
+ }
14903
+ process.stdout.write(
14904
+ `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
14905
+ `
14906
+ );
14907
+ try {
14908
+ kill(pid, "SIGKILL");
14909
+ } catch {
14910
+ }
14911
+ clearRuntimeState();
14912
+ process.stdout.write("companion: force-stopped.\n");
14913
+ }
14914
+ function isAlive(kill, pid) {
14915
+ try {
14916
+ kill(pid, 0);
14917
+ return true;
14918
+ } catch {
14919
+ return false;
14920
+ }
14921
+ }
14922
+
14923
+ // src/commands/service.ts
14924
+ var STARTUP_WAIT_MS = 2e4;
14925
+ async function installAndStartService(deps = {}) {
14926
+ const readState = deps.readState ?? readLiveRuntimeState;
14927
+ const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
14928
+ const now = deps.now ?? Date.now;
14929
+ const before = await serviceState(deps);
14930
+ const running = readState();
14931
+ if (running && running.pid !== before.pid) await (deps.stopRunning ?? (() => stop()))();
14932
+ setRunsAsService(true);
14933
+ const outcome = await installService(deps);
14934
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
14935
+ if (!outcome.ok) {
14936
+ setRunsAsService(false);
14937
+ return false;
14938
+ }
14939
+ const deadline = now() + STARTUP_WAIT_MS;
14940
+ while (now() < deadline) {
14941
+ const state = readState();
14942
+ if (state?.socket) return true;
14943
+ await sleep4(250);
14944
+ }
14945
+ write(
14946
+ `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
14947
+ );
14948
+ return false;
14949
+ }
14950
+ async function serviceInstall(deps = {}) {
14951
+ if (!await installAndStartService(deps)) {
14952
+ process.exitCode = 1;
14953
+ return;
14954
+ }
14955
+ write(
14956
+ INDENT + tick(
14957
+ "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself, usually between turns."
14958
+ )
14959
+ );
14960
+ write(`${INDENT} Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
14961
+ }
14962
+ async function serviceUninstall(deps = {}) {
14963
+ const outcome = await uninstallService(deps);
14964
+ for (const line of outcome.lines) write(`${INDENT}${line}`);
14965
+ if (outcome.lines.length > 0) return;
14966
+ setRunsAsService(false);
14967
+ write(
14968
+ INDENT + tick("Removed the service. Cabane won't start at boot, and it isn't running now.")
14969
+ );
14970
+ write(`${INDENT} Start it again with ${cabane("start")}`);
14971
+ }
14972
+ async function serviceStatus(deps = {}) {
14973
+ const state = await serviceState(deps);
14974
+ const container = containerWords(containerHere() === true, state);
14975
+ for (const line of serviceRows(state, { container })) write(line);
14976
+ for (const line of updateRows(readUpdateState(), safeConfig()?.update?.auto, {
14977
+ service: state.installed,
14978
+ container
14979
+ }))
14980
+ write(line);
14981
+ if (state.installed && state.state !== "running") process.exitCode = 1;
14982
+ }
14983
+ var LABEL_WIDTH = 13;
14984
+ var row = (label, value) => ` ${label.padEnd(LABEL_WIDTH)}${value}`;
14985
+ function containerWords(container, state, opts = {}) {
14986
+ if (!container || opts.supervisedHere) return false;
14987
+ const serviceRuns = state.installed && state.state === "running";
14988
+ if (!serviceRuns) return true;
14989
+ return opts.companion !== void 0 && state.pid !== opts.companion;
14990
+ }
14991
+ function serviceRows(state, opts = {}) {
14992
+ if (opts.container) return [row("Service", "not used in a container")];
14993
+ if (!state.installed) {
14994
+ return [
14995
+ row(
14996
+ "Service",
14997
+ state.platform ? `not installed \u2014 ${cabane("service install")} starts Cabane at boot` : "not available on this system"
14998
+ )
14999
+ ];
15000
+ }
15001
+ const via = state.platform === "systemd" ? "systemd" : "launchd";
15002
+ const rows = [
15003
+ row(
15004
+ "Service",
15005
+ state.state === "running" ? `running (${via}${state.pid ? `, pid ${state.pid}` : ""})` : `installed, not running (${via}: ${state.state})`
15006
+ )
15007
+ ];
15008
+ if (!state.nodeOk) {
15009
+ rows.push(
15010
+ ` ! The service's Node at ${tildePath(state.node ?? "")} is gone. Run ${cabane("service install")} again.`
15011
+ );
15012
+ }
15013
+ return rows;
15014
+ }
15015
+ function updateRows(state, auto, opts = {}) {
15016
+ const rows = [];
15017
+ if (opts.version !== false) rows.push(row("Version", COMPANION_VERSION));
15018
+ if (state.server) {
15019
+ rows.push(
15020
+ row(
15021
+ "Cabane wants",
15022
+ `${state.server.latest} (the latest), ${state.server.minimum} or newer to run turns`
15023
+ )
15024
+ );
15025
+ }
15026
+ rows.push(
15027
+ row(
15028
+ "Auto-update",
15029
+ opts.container ? "off \u2014 to update, replace the container with a newer image" : !opts.service ? `off \u2014 runs with the service: ${cabane("service install")}` : auto === true ? "on \u2014 updates install between turns when they can, and roll back if they fail" : `off \u2014 ${cabane("update --auto on")} turns it on`
15030
+ )
15031
+ );
15032
+ const last = state.lastResult;
15033
+ if (last) {
15034
+ const when = new Date(last.at).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
15035
+ const why = last.reason ? `: ${last.reason}` : "";
15036
+ rows.push(
15037
+ row(
15038
+ "Last update",
15039
+ last.kind === "updated" ? `${last.from} \u2192 ${last.to} on ${when}` : last.kind === "rolled_back" ? `${last.to} rolled back to ${last.from} on ${when}${why}` : `${last.to} failed on ${when}${why}`
15040
+ )
15041
+ );
15042
+ }
15043
+ return rows;
15044
+ }
15045
+ function safeConfig() {
15046
+ try {
15047
+ return loadConfig();
15048
+ } catch {
15049
+ return null;
15050
+ }
15051
+ }
15052
+ function setRunsAsService(on) {
15053
+ const cfg = safeConfig();
15054
+ if (!cfg) return;
15055
+ const { serviceDeclined: _declined, ...rest } = cfg;
15056
+ saveConfig({ ...rest, update: { auto: on } });
15057
+ }
15058
+
14788
15059
  // src/terminal-status.ts
14789
15060
  async function readTerminalStatus(socket, timeoutMs = 1e3) {
14790
15061
  if (!socket) return emptyHeartbeatStatus();
@@ -14810,6 +15081,14 @@ async function waitForAcknowledgement(socket) {
14810
15081
  }
14811
15082
 
14812
15083
  // src/commands/start.ts
15084
+ var NO_SERVICE = {
15085
+ installed: false,
15086
+ platform: null,
15087
+ state: "not installed",
15088
+ pid: null,
15089
+ node: null,
15090
+ nodeOk: true
15091
+ };
14813
15092
  async function start(opts = {}, deps = {}) {
14814
15093
  setLogOverrides(opts);
14815
15094
  setConsoleLogging(false);
@@ -14923,7 +15202,11 @@ async function startScript(opts, deps) {
14923
15202
  if (service2.state !== "running" || service2.pid !== current.pid) mode = "existing";
14924
15203
  }
14925
15204
  }
14926
- const address = `${cfg.baseUrl.replace(/\/$/, "")}/settings/devices`;
15205
+ const address = devicesUrl(cfg.baseUrl);
15206
+ const container = containerHere() === true && containerWords(true, installed ? await serviceState() : NO_SERVICE, {
15207
+ companion: runtime ? process.pid : running?.pid ?? null,
15208
+ supervisedHere: runtime !== null && process.env.CABANE_COMPANION_SUPERVISED === "1"
15209
+ });
14927
15210
  if (!daemon)
14928
15211
  for (const line of closingBlock({
14929
15212
  mode,
@@ -14933,6 +15216,7 @@ async function startScript(opts, deps) {
14933
15216
  service: installed,
14934
15217
  servicePlatform: servicePlatform(),
14935
15218
  autoUpdate: cfg.update?.auto !== false,
15219
+ container,
14936
15220
  address
14937
15221
  }))
14938
15222
  write(line);
@@ -15124,7 +15408,7 @@ async function setupScript(opts, deps) {
15124
15408
  onCode: () => rememberPairAddress(baseUrl)
15125
15409
  });
15126
15410
  if (!device) return;
15127
- writePairedConfig(device);
15411
+ writePairedConfig({ ...device, baseUrl: addressToKeep(baseUrl, device.baseUrl) });
15128
15412
  justPaired = true;
15129
15413
  }
15130
15414
  const detection = await start(
@@ -15324,210 +15608,6 @@ init_harness_copy();
15324
15608
  init_secrets();
15325
15609
  init_term();
15326
15610
  init_transcript_writer();
15327
-
15328
- // src/commands/service.ts
15329
- init_cabane_command();
15330
- init_config();
15331
- init_term();
15332
-
15333
- // src/commands/stop.ts
15334
- var TERM_GRACE_MS = 6e3;
15335
- var POLL_INTERVAL_MS = 150;
15336
- async function stop(deps = {}) {
15337
- if ((deps.serviceInstalled ?? serviceInstalled)()) {
15338
- const outcome = await (deps.stopService ?? stopService)();
15339
- for (const line of outcome.lines) process.stdout.write(`${line}
15340
- `);
15341
- if (!outcome.ok) {
15342
- process.exitCode = 1;
15343
- return;
15344
- }
15345
- process.stdout.write(
15346
- "companion: stopped the service. It starts again at boot, or with `cabane start`.\n"
15347
- );
15348
- return;
15349
- }
15350
- const readState = deps.readState ?? readLiveRuntimeState;
15351
- const verify = deps.verify ?? ((s) => verifyRuntime(s));
15352
- const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
15353
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
15354
- const now = deps.now ?? (() => Date.now());
15355
- const state = readState();
15356
- if (!state) {
15357
- process.stdout.write("companion: not running (nothing to stop).\n");
15358
- return;
15359
- }
15360
- if (await verify(state) === "stale") {
15361
- clearRuntimeState();
15362
- process.stdout.write("companion: not running (stale marker swept).\n");
15363
- return;
15364
- }
15365
- const { pid } = state;
15366
- process.stdout.write(`companion: stopping (pid ${pid})\u2026
15367
- `);
15368
- try {
15369
- kill(pid, "SIGTERM");
15370
- } catch {
15371
- clearRuntimeState();
15372
- process.stdout.write("companion: already stopped.\n");
15373
- return;
15374
- }
15375
- const deadline = now() + TERM_GRACE_MS;
15376
- while (now() < deadline) {
15377
- if (!isAlive(kill, pid)) {
15378
- process.stdout.write("companion: stopped.\n");
15379
- return;
15380
- }
15381
- await sleep4(POLL_INTERVAL_MS);
15382
- }
15383
- process.stdout.write(
15384
- `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
15385
- `
15386
- );
15387
- try {
15388
- kill(pid, "SIGKILL");
15389
- } catch {
15390
- }
15391
- clearRuntimeState();
15392
- process.stdout.write("companion: force-stopped.\n");
15393
- }
15394
- function isAlive(kill, pid) {
15395
- try {
15396
- kill(pid, 0);
15397
- return true;
15398
- } catch {
15399
- return false;
15400
- }
15401
- }
15402
-
15403
- // src/commands/service.ts
15404
- var STARTUP_WAIT_MS = 2e4;
15405
- async function installAndStartService(deps = {}) {
15406
- const readState = deps.readState ?? readLiveRuntimeState;
15407
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
15408
- const now = deps.now ?? Date.now;
15409
- const before = await serviceState(deps);
15410
- const running = readState();
15411
- if (running && running.pid !== before.pid) await (deps.stopRunning ?? (() => stop()))();
15412
- setRunsAsService(true);
15413
- const outcome = await installService(deps);
15414
- for (const line of outcome.lines) write(`${INDENT}${line}`);
15415
- if (!outcome.ok) {
15416
- setRunsAsService(false);
15417
- return false;
15418
- }
15419
- const deadline = now() + STARTUP_WAIT_MS;
15420
- while (now() < deadline) {
15421
- const state = readState();
15422
- if (state?.socket) return true;
15423
- await sleep4(250);
15424
- }
15425
- write(
15426
- `${INDENT}The service is installed but Cabane hasn't come up yet. ${cabane("service status")} and ${cabane("logs")} say why.`
15427
- );
15428
- return false;
15429
- }
15430
- async function serviceInstall(deps = {}) {
15431
- if (!await installAndStartService(deps)) {
15432
- process.exitCode = 1;
15433
- return;
15434
- }
15435
- write(
15436
- INDENT + tick(
15437
- "Cabane runs as a service. It starts at boot, restarts if it stops, and updates itself, usually between turns."
15438
- )
15439
- );
15440
- write(`${INDENT} Status: ${cabane("service status")} Logs: ${cabane("logs")}`);
15441
- }
15442
- async function serviceUninstall(deps = {}) {
15443
- const outcome = await uninstallService(deps);
15444
- for (const line of outcome.lines) write(`${INDENT}${line}`);
15445
- if (outcome.lines.length > 0) return;
15446
- setRunsAsService(false);
15447
- write(
15448
- INDENT + tick("Removed the service. Cabane won't start at boot, and it isn't running now.")
15449
- );
15450
- write(`${INDENT} Start it again with ${cabane("start")}`);
15451
- }
15452
- async function serviceStatus(deps = {}) {
15453
- const state = await serviceState(deps);
15454
- for (const line of serviceRows(state)) write(line);
15455
- for (const line of updateRows(readUpdateState(), safeConfig()?.update?.auto, {
15456
- service: state.installed
15457
- }))
15458
- write(line);
15459
- if (state.installed && state.state !== "running") process.exitCode = 1;
15460
- }
15461
- var LABEL_WIDTH = 13;
15462
- var row = (label, value) => ` ${label.padEnd(LABEL_WIDTH)}${value}`;
15463
- function serviceRows(state) {
15464
- if (!state.installed) {
15465
- return [
15466
- row(
15467
- "Service",
15468
- state.platform ? `not installed \u2014 ${cabane("service install")} starts Cabane at boot` : "not available on this system"
15469
- )
15470
- ];
15471
- }
15472
- const via = state.platform === "systemd" ? "systemd" : "launchd";
15473
- const rows = [
15474
- row(
15475
- "Service",
15476
- state.state === "running" ? `running (${via}${state.pid ? `, pid ${state.pid}` : ""})` : `installed, not running (${via}: ${state.state})`
15477
- )
15478
- ];
15479
- if (!state.nodeOk) {
15480
- rows.push(
15481
- ` ! The service's Node at ${tildePath(state.node ?? "")} is gone. Run ${cabane("service install")} again.`
15482
- );
15483
- }
15484
- return rows;
15485
- }
15486
- function updateRows(state, auto, opts = {}) {
15487
- const rows = [];
15488
- if (opts.version !== false) rows.push(row("Version", COMPANION_VERSION));
15489
- if (state.server) {
15490
- rows.push(
15491
- row(
15492
- "Cabane wants",
15493
- `${state.server.latest} (the latest), ${state.server.minimum} or newer to run turns`
15494
- )
15495
- );
15496
- }
15497
- rows.push(
15498
- row(
15499
- "Auto-update",
15500
- !opts.service ? `off \u2014 runs with the service: ${cabane("service install")}` : auto === true ? "on \u2014 updates install between turns when they can, and roll back if they fail" : `off \u2014 ${cabane("update --auto on")} turns it on`
15501
- )
15502
- );
15503
- const last = state.lastResult;
15504
- if (last) {
15505
- const when = new Date(last.at).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
15506
- const why = last.reason ? `: ${last.reason}` : "";
15507
- rows.push(
15508
- row(
15509
- "Last update",
15510
- last.kind === "updated" ? `${last.from} \u2192 ${last.to} on ${when}` : last.kind === "rolled_back" ? `${last.to} rolled back to ${last.from} on ${when}${why}` : `${last.to} failed on ${when}${why}`
15511
- )
15512
- );
15513
- }
15514
- return rows;
15515
- }
15516
- function safeConfig() {
15517
- try {
15518
- return loadConfig();
15519
- } catch {
15520
- return null;
15521
- }
15522
- }
15523
- function setRunsAsService(on) {
15524
- const cfg = safeConfig();
15525
- if (!cfg) return;
15526
- const { serviceDeclined: _declined, ...rest } = cfg;
15527
- saveConfig({ ...rest, update: { auto: on } });
15528
- }
15529
-
15530
- // src/commands/status.ts
15531
15611
  var LABEL_WIDTH2 = 13;
15532
15612
  function row2(label, value) {
15533
15613
  return ` ${label.padEnd(LABEL_WIDTH2)}${value}`;
@@ -15571,15 +15651,19 @@ async function status(deps = {}) {
15571
15651
  deviceLine({
15572
15652
  ...await readTerminalStatus(running?.socket),
15573
15653
  running: !!running,
15574
- devicesUrl: `${cfg.baseUrl.replace(/\/$/, "")}/settings/devices`
15654
+ devicesUrl: devicesUrl(cfg.baseUrl)
15575
15655
  })
15576
15656
  )
15577
15657
  );
15578
15658
  for (const line of strays) out(line);
15579
- for (const line of serviceRows(service2)) out(line);
15659
+ const container = containerWords(containerHere() === true, service2, {
15660
+ companion: running?.pid ?? null
15661
+ });
15662
+ for (const line of serviceRows(service2, { container })) out(line);
15580
15663
  for (const line of updateRows(readUpdateState(), cfg.update?.auto, {
15581
15664
  version: false,
15582
- service: service2.installed
15665
+ service: service2.installed,
15666
+ container
15583
15667
  }))
15584
15668
  out(line);
15585
15669
  for (const line of await agentLines2(cfg, detection)) out(line);
@@ -15713,7 +15797,7 @@ async function update(opts = {}) {
15713
15797
  const auto = opts.auto === "on";
15714
15798
  if (auto && !serviceInstalled()) {
15715
15799
  write(
15716
- `${INDENT}Cabane updates itself only when it runs as a service. Run ${cabane("service install")} to run it as one, with updates on.`
15800
+ containerHere() === true ? `${INDENT}Cabane doesn't update itself in a container. To update it, replace the container with a newer image.` : `${INDENT}Cabane updates itself only when it runs as a service. Run ${cabane("service install")} to run it as one, with updates on.`
15717
15801
  );
15718
15802
  process.exitCode = 1;
15719
15803
  return;
package/dist/launch.js CHANGED
@@ -45,7 +45,8 @@ import { dirname as dirname3, isAbsolute, join as join4 } from "path";
45
45
  import { z as z2 } from "zod";
46
46
 
47
47
  // src/pairing.ts
48
- function isAllowedBaseUrl(raw) {
48
+ var ALLOW_PLAIN_HTTP_HOST_ENV = "CABANE_ALLOW_PLAIN_HTTP_HOST";
49
+ function isAllowedBaseUrl(raw, env = process.env) {
49
50
  let url;
50
51
  try {
51
52
  url = new URL(raw);
@@ -55,10 +56,41 @@ function isAllowedBaseUrl(raw) {
55
56
  if (url.protocol === "https:") return true;
56
57
  if (url.protocol === "http:") {
57
58
  const host = url.hostname.toLowerCase();
58
- return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
59
+ if (host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]")
60
+ return true;
61
+ const allowed = plainHttpHost(env);
62
+ return allowed !== null && host === allowed;
59
63
  }
60
64
  return false;
61
65
  }
66
+ function plainHttpHost(env = process.env) {
67
+ const raw = env[ALLOW_PLAIN_HTTP_HOST_ENV]?.trim().toLowerCase();
68
+ if (!raw) return null;
69
+ if (isSingleLabel(raw) || isPrivateIpv4(raw)) return raw;
70
+ const bare = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
71
+ return privateIpv6(bare);
72
+ }
73
+ function isSingleLabel(host) {
74
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host) && !/^\d+$/.test(host);
75
+ }
76
+ function isPrivateIpv4(host) {
77
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
78
+ if (!m) return false;
79
+ const [a, b, c, d] = m.slice(1).map(Number);
80
+ if ([a, b, c, d].some((n) => n > 255)) return false;
81
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
82
+ }
83
+ function privateIpv6(host) {
84
+ if (!host.includes(":")) return null;
85
+ let bracketed;
86
+ try {
87
+ bracketed = new URL(`http://[${host}]/`).hostname;
88
+ } catch {
89
+ return null;
90
+ }
91
+ const first = Number.parseInt(bracketed.slice(1).split(":")[0] || "0", 16);
92
+ return (first & 65024) === 64512 ? bracketed : null;
93
+ }
62
94
 
63
95
  // src/prepare-hook.ts
64
96
  import { spawn } from "child_process";
@@ -121,10 +153,10 @@ function isRootPath(value) {
121
153
  return isAbsolute(value) || value === "~" || value.startsWith("~/");
122
154
  }
123
155
  var companionConfigSchema = z2.object({
124
- // https-enforced (loopback exempt), so a hand-edited config can't put the
125
- // device token and prompt on a plaintext channel.
126
- baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
127
- message: "baseUrl must be https (loopback http is allowed for local dev only)"
156
+ // https-enforced (loopback, and the one private host a person names, exempt), so a
157
+ // hand-edited config can't put the device token and prompt on a plaintext channel.
158
+ baseUrl: z2.string().url().refine((url) => isAllowedBaseUrl(url), {
159
+ message: `baseUrl must be https (plain http only to this machine, or to the private host ${ALLOW_PLAIN_HTTP_HOST_ENV} names)`
128
160
  }),
129
161
  // The `cabdev_` device token plaintext — the companion's one durable credential,
130
162
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
@@ -92,7 +92,8 @@ var ConfigError = class extends CompanionError {
92
92
  };
93
93
 
94
94
  // src/pairing.ts
95
- function isAllowedBaseUrl(raw) {
95
+ var ALLOW_PLAIN_HTTP_HOST_ENV = "CABANE_ALLOW_PLAIN_HTTP_HOST";
96
+ function isAllowedBaseUrl(raw, env = process.env) {
96
97
  let url;
97
98
  try {
98
99
  url = new URL(raw);
@@ -102,10 +103,41 @@ function isAllowedBaseUrl(raw) {
102
103
  if (url.protocol === "https:") return true;
103
104
  if (url.protocol === "http:") {
104
105
  const host = url.hostname.toLowerCase();
105
- return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
106
+ if (host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]")
107
+ return true;
108
+ const allowed = plainHttpHost(env);
109
+ return allowed !== null && host === allowed;
106
110
  }
107
111
  return false;
108
112
  }
113
+ function plainHttpHost(env = process.env) {
114
+ const raw = env[ALLOW_PLAIN_HTTP_HOST_ENV]?.trim().toLowerCase();
115
+ if (!raw) return null;
116
+ if (isSingleLabel(raw) || isPrivateIpv4(raw)) return raw;
117
+ const bare = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
118
+ return privateIpv6(bare);
119
+ }
120
+ function isSingleLabel(host) {
121
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host) && !/^\d+$/.test(host);
122
+ }
123
+ function isPrivateIpv4(host) {
124
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
125
+ if (!m) return false;
126
+ const [a, b, c, d] = m.slice(1).map(Number);
127
+ if ([a, b, c, d].some((n) => n > 255)) return false;
128
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
129
+ }
130
+ function privateIpv6(host) {
131
+ if (!host.includes(":")) return null;
132
+ let bracketed;
133
+ try {
134
+ bracketed = new URL(`http://[${host}]/`).hostname;
135
+ } catch {
136
+ return null;
137
+ }
138
+ const first = Number.parseInt(bracketed.slice(1).split(":")[0] || "0", 16);
139
+ return (first & 65024) === 64512 ? bracketed : null;
140
+ }
109
141
 
110
142
  // src/prepare-hook.ts
111
143
  import { spawn } from "child_process";
@@ -171,10 +203,10 @@ function isRootPath(value) {
171
203
  return isAbsolute(value) || value === "~" || value.startsWith("~/");
172
204
  }
173
205
  var companionConfigSchema = z2.object({
174
- // https-enforced (loopback exempt), so a hand-edited config can't put the
175
- // device token and prompt on a plaintext channel.
176
- baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
177
- message: "baseUrl must be https (loopback http is allowed for local dev only)"
206
+ // https-enforced (loopback, and the one private host a person names, exempt), so a
207
+ // hand-edited config can't put the device token and prompt on a plaintext channel.
208
+ baseUrl: z2.string().url().refine((url) => isAllowedBaseUrl(url), {
209
+ message: `baseUrl must be https (plain http only to this machine, or to the private host ${ALLOW_PLAIN_HTTP_HOST_ENV} names)`
178
210
  }),
179
211
  // The `cabdev_` device token plaintext — the companion's one durable credential,
180
212
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.238",
3
+ "version": "0.6.240",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",