@cabane/companion 0.6.238 → 0.6.239

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. 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`.
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,57 @@ 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
+ const allowed = plainHttpHost(env);
329
+ if (allowed === null) return cabaneNamed;
330
+ let url;
331
+ try {
332
+ url = new URL(pairedOver);
333
+ } catch {
334
+ return cabaneNamed;
335
+ }
336
+ return url.protocol === "http:" && url.hostname.toLowerCase() === allowed ? pairedOver.replace(/\/+$/, "") : cabaneNamed;
337
+ }
338
+ var ALLOW_PLAIN_HTTP_HOST_ENV;
296
339
  var init_pairing = __esm({
297
340
  "src/pairing.ts"() {
298
341
  "use strict";
342
+ ALLOW_PLAIN_HTTP_HOST_ENV = "CABANE_ALLOW_PLAIN_HTTP_HOST";
299
343
  }
300
344
  });
301
345
 
@@ -712,10 +756,10 @@ var init_config = __esm({
712
756
  }).strict();
713
757
  agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema);
714
758
  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)"
759
+ // https-enforced (loopback, and the one private host a person names, exempt), so a
760
+ // hand-edited config can't put the device token and prompt on a plaintext channel.
761
+ baseUrl: z2.string().url().refine((url) => isAllowedBaseUrl(url), {
762
+ message: `baseUrl must be https (plain http only to this machine, or to the private host ${ALLOW_PLAIN_HTTP_HOST_ENV} names)`
719
763
  }),
720
764
  // The `cabdev_` device token plaintext — the companion's one durable credential,
721
765
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
@@ -10287,6 +10331,9 @@ function openLink(link) {
10287
10331
  });
10288
10332
  }
10289
10333
 
10334
+ // src/commands/setup.ts
10335
+ init_pairing();
10336
+
10290
10337
  // src/commands/start.ts
10291
10338
  init_cabane_command();
10292
10339
  init_config();
@@ -10302,7 +10349,7 @@ function resolvePairBaseUrl(server) {
10302
10349
  if (!raw) throw new NoPairAddressError();
10303
10350
  if (!isAllowedBaseUrl(raw)) {
10304
10351
  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")}\`.`
10352
+ `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
10353
  );
10307
10354
  }
10308
10355
  return raw;
@@ -15124,7 +15171,7 @@ async function setupScript(opts, deps) {
15124
15171
  onCode: () => rememberPairAddress(baseUrl)
15125
15172
  });
15126
15173
  if (!device) return;
15127
- writePairedConfig(device);
15174
+ writePairedConfig({ ...device, baseUrl: addressToKeep(baseUrl, device.baseUrl) });
15128
15175
  justPaired = true;
15129
15176
  }
15130
15177
  const detection = await start(
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.239",
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",