@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 +3 -1
- package/dist/cli.js +308 -224
- package/dist/launch.js +38 -6
- package/dist/postinstall.js +38 -6
- package/package.json +1 -1
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
|
-
|
|
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
|
|
716
|
-
// device token and prompt on a plaintext channel.
|
|
717
|
-
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
718
|
-
message:
|
|
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 (
|
|
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 (
|
|
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
|
|
11589
|
-
let container;
|
|
11647
|
+
function containerHere() {
|
|
11590
11648
|
try {
|
|
11591
|
-
|
|
11649
|
+
return inContainer(readContainerSignals());
|
|
11592
11650
|
} catch {
|
|
11593
|
-
|
|
11651
|
+
return null;
|
|
11594
11652
|
}
|
|
11595
|
-
|
|
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 =
|
|
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:
|
|
15654
|
+
devicesUrl: devicesUrl(cfg.baseUrl)
|
|
15575
15655
|
})
|
|
15576
15656
|
)
|
|
15577
15657
|
);
|
|
15578
15658
|
for (const line of strays) out(line);
|
|
15579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
// device token and prompt on a plaintext channel.
|
|
126
|
-
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
127
|
-
message:
|
|
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 +
|
package/dist/postinstall.js
CHANGED
|
@@ -92,7 +92,8 @@ var ConfigError = class extends CompanionError {
|
|
|
92
92
|
};
|
|
93
93
|
|
|
94
94
|
// src/pairing.ts
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
175
|
-
// device token and prompt on a plaintext channel.
|
|
176
|
-
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
177
|
-
message:
|
|
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.
|
|
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",
|