@2kw/ai 7.3.0-dev.1 → 7.3.0-dev.8

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.
Files changed (39) hide show
  1. package/dist/bridge/hub.d.ts +11 -0
  2. package/dist/bridge/hub.js +44 -0
  3. package/dist/bridge/inventory.d.ts +27 -0
  4. package/dist/bridge/inventory.js +41 -0
  5. package/dist/bridge/pairing.d.ts +41 -0
  6. package/dist/bridge/pairing.js +158 -0
  7. package/dist/bridge/paths.d.ts +7 -0
  8. package/dist/bridge/paths.js +16 -0
  9. package/dist/bridge/run.d.ts +12 -0
  10. package/dist/bridge/run.js +134 -0
  11. package/dist/bridge/shell.d.ts +17 -0
  12. package/dist/bridge/shell.js +48 -0
  13. package/dist/bridge/store.d.ts +33 -0
  14. package/dist/bridge/store.js +110 -0
  15. package/dist/bridge/toml.d.ts +16 -0
  16. package/dist/bridge/toml.js +39 -0
  17. package/dist/commands/bridge.d.ts +7 -0
  18. package/dist/commands/bridge.js +228 -0
  19. package/dist/commands/extractions.js +24 -5
  20. package/dist/index.js +2 -0
  21. package/dist/relay-protocol/b64.d.ts +13 -0
  22. package/dist/relay-protocol/b64.js +34 -0
  23. package/dist/relay-protocol/canonical-json.d.ts +22 -0
  24. package/dist/relay-protocol/canonical-json.js +101 -0
  25. package/dist/relay-protocol/frames.d.ts +75 -0
  26. package/dist/relay-protocol/frames.js +224 -0
  27. package/dist/relay-protocol/headers.d.ts +18 -0
  28. package/dist/relay-protocol/headers.js +88 -0
  29. package/dist/relay-protocol/jws.d.ts +28 -0
  30. package/dist/relay-protocol/jws.js +113 -0
  31. package/dist/relay-protocol/keys.d.ts +26 -0
  32. package/dist/relay-protocol/keys.js +80 -0
  33. package/dist/relay-protocol/pairing.d.ts +26 -0
  34. package/dist/relay-protocol/pairing.js +49 -0
  35. package/dist/relay-protocol/wire.d.ts +101 -0
  36. package/dist/relay-protocol/wire.js +63 -0
  37. package/dist/relay-protocol/wordlist.d.ts +30 -0
  38. package/dist/relay-protocol/wordlist.js +289 -0
  39. package/package.json +4 -1
@@ -0,0 +1,11 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import type { BridgeIdentity } from "./store.js";
3
+ /**
4
+ * One long poll at `{baseUrl}/v1/mcp-relay/hub/poll`, the call that keeps a paired computer
5
+ * present (#1377, plan P14). The headers are the network relay's (relay/src/relay/hub-client.ts):
6
+ * a relay JWT for `<issuer>/v1/mcp-relay/hub`, the protocol version and a replica id. It asks for
7
+ * no work (`calls=0`): S1 only stays reachable. Resolves with the HTTP status; throws on a
8
+ * transport failure or the 35 s timeout, and a `ClockSkewError` for a 401 a wrong clock caused.
9
+ */
10
+ export declare function pollHub(identity: BridgeIdentity, key: KeyObject, replicaId: string, signal: AbortSignal): Promise<number>;
11
+ //# sourceMappingURL=hub.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { mintRelayJwt } from "../relay-protocol/jws.js";
2
+ import { kidOf, rawPublicKey } from "../relay-protocol/keys.js";
3
+ import { POLL_WAIT_SECONDS, PROTOCOL_VERSION } from "../relay-protocol/wire.js";
4
+ import { BRIDGE_VERSION } from "./inventory.js";
5
+ import { clockSkew } from "./pairing.js";
6
+ /**
7
+ * One long poll at `{baseUrl}/v1/mcp-relay/hub/poll`, the call that keeps a paired computer
8
+ * present (#1377, plan P14). The headers are the network relay's (relay/src/relay/hub-client.ts):
9
+ * a relay JWT for `<issuer>/v1/mcp-relay/hub`, the protocol version and a replica id. It asks for
10
+ * no work (`calls=0`): S1 only stays reachable. Resolves with the HTTP status; throws on a
11
+ * transport failure or the 35 s timeout, and a `ClockSkewError` for a 401 a wrong clock caused.
12
+ */
13
+ export async function pollHub(identity, key, replicaId, signal) {
14
+ const query = new URLSearchParams({ calls: "0", listen: "0", control: "0", wait: String(POLL_WAIT_SECONDS) });
15
+ const kid = kidOf(rawPublicKey(key));
16
+ const audience = `${identity.issuer}/v1/mcp-relay/hub`;
17
+ // AbortSignal.any needs Node 20; the cli still runs on 18.
18
+ const timeout = new AbortController();
19
+ const abort = () => timeout.abort();
20
+ const timer = setTimeout(abort, (POLL_WAIT_SECONDS + 10) * 1000);
21
+ signal.addEventListener("abort", abort, { once: true });
22
+ try {
23
+ const response = await fetch(`${identity.baseUrl}/v1/mcp-relay/hub/poll?${query}`, {
24
+ headers: {
25
+ authorization: `Bearer ${mintRelayJwt(key, kid, identity.relayId, audience, Math.floor(Date.now() / 1000))}`,
26
+ "mcprelay-protocol": String(PROTOCOL_VERSION),
27
+ "mcprelay-version": BRIDGE_VERSION,
28
+ "mcprelay-replica": replicaId,
29
+ },
30
+ redirect: "error",
31
+ signal: timeout.signal,
32
+ });
33
+ const skew = response.status === 401 ? clockSkew(response) : null;
34
+ await response.body?.cancel();
35
+ if (skew)
36
+ throw skew;
37
+ return response.status;
38
+ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ signal.removeEventListener("abort", abort);
42
+ }
43
+ }
44
+ //# sourceMappingURL=hub.js.map
@@ -0,0 +1,27 @@
1
+ import type { BridgeConfig, Workspace } from "./toml.js";
2
+ /** What `PUT /v1/local-bridge/inventory` takes (#1377, plan P10). */
3
+ export interface DeviceInventory {
4
+ bridgeVersion: string;
5
+ protocolVersion: number;
6
+ os: string;
7
+ shell: {
8
+ path: string;
9
+ version: string | null;
10
+ } | null;
11
+ workspaces: Workspace[];
12
+ configHash: string;
13
+ }
14
+ export declare const BRIDGE_VERSION: string;
15
+ /**
16
+ * What redeem takes as `name` and `os`: 1 to 64 characters, none of them a control, format
17
+ * (bidi, zero-width) or line/paragraph separator, the backend's `[^\p{C}\p{Zl}\p{Zp}]{1,64}`.
18
+ */
19
+ export declare const LABEL: RegExp;
20
+ /** `value` made into a {@link LABEL}, or `fallback` when nothing is left. */
21
+ export declare function printable(value: string, fallback: string): string;
22
+ export declare function deviceName(): string;
23
+ /** "Windows 11 Pro" on Windows; the kernel name and release elsewhere. */
24
+ export declare function deviceOs(): string;
25
+ export declare function inventoryOf(input: Omit<DeviceInventory, "protocolVersion" | "configHash">): DeviceInventory;
26
+ export declare function collectInventory(config: BridgeConfig): Promise<DeviceInventory>;
27
+ //# sourceMappingURL=inventory.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { createRequire } from "node:module";
2
+ import { hostname, release, type, version } from "node:os";
3
+ import { deviceConfigHash } from "../relay-protocol/pairing.js";
4
+ import { PROTOCOL_VERSION } from "../relay-protocol/wire.js";
5
+ import { bashVersion, findBash } from "./shell.js";
6
+ export const BRIDGE_VERSION = createRequire(import.meta.url)("../../package.json").version;
7
+ /**
8
+ * What redeem takes as `name` and `os`: 1 to 64 characters, none of them a control, format
9
+ * (bidi, zero-width) or line/paragraph separator, the backend's `[^\p{C}\p{Zl}\p{Zp}]{1,64}`.
10
+ */
11
+ export const LABEL = /^[^\p{C}\p{Zl}\p{Zp}]{1,64}$/u;
12
+ /** `value` made into a {@link LABEL}, or `fallback` when nothing is left. */
13
+ export function printable(value, fallback) {
14
+ // Code points, not UTF-16 units: a cut surrogate pair would itself be a control character.
15
+ const clean = Array.from(value.replace(/[\p{C}\p{Zl}\p{Zp}]/gu, " ").trim()).slice(0, 64).join("");
16
+ return clean === "" ? fallback : clean;
17
+ }
18
+ export function deviceName() {
19
+ return printable(hostname(), "computer");
20
+ }
21
+ /** "Windows 11 Pro" on Windows; the kernel name and release elsewhere. */
22
+ export function deviceOs() {
23
+ return printable(process.platform === "win32" ? version() : `${type()} ${release()}`, process.platform);
24
+ }
25
+ export function inventoryOf(input) {
26
+ return {
27
+ ...input,
28
+ protocolVersion: PROTOCOL_VERSION,
29
+ configHash: deviceConfigHash({ shell: input.shell?.path ?? null, workspaces: input.workspaces }),
30
+ };
31
+ }
32
+ export async function collectInventory(config) {
33
+ const shellPath = findBash(config.shell);
34
+ return inventoryOf({
35
+ bridgeVersion: BRIDGE_VERSION,
36
+ os: deviceOs(),
37
+ shell: shellPath === null ? null : { path: shellPath, version: await bashVersion(shellPath) },
38
+ workspaces: config.workspaces,
39
+ });
40
+ }
41
+ //# sourceMappingURL=inventory.js.map
@@ -0,0 +1,41 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import { type Jwk } from "../relay-protocol/keys.js";
3
+ import type { DeviceInventory } from "./inventory.js";
4
+ import type { BridgeIdentity } from "./store.js";
5
+ /** The computer was revoked or is unknown to the backend: final, never retried. */
6
+ export declare class DeviceGoneError extends Error {
7
+ constructor();
8
+ }
9
+ /** The 401 came from this computer's clock, not from a revocation: nothing is deleted. */
10
+ export declare class ClockSkewError extends Error {
11
+ readonly offsetSeconds: number;
12
+ constructor(offsetSeconds: number, then?: string);
13
+ }
14
+ /** For a 401: the clock error when the response's `Date` and this computer's clock differ by more than 30 s. */
15
+ export declare function clockSkew(response: Response, then?: string): ClockSkewError | null;
16
+ export interface Redeemed {
17
+ pairingId: string;
18
+ expiresAt: string;
19
+ issuer: string;
20
+ fingerprint: string[];
21
+ }
22
+ /**
23
+ * Redeems a pairing code with a fresh key. Returns the words this key makes, checked against the
24
+ * backend's: those are the words the member compares with chat.
25
+ */
26
+ export declare function redeem(baseUrl: string, key: KeyObject, code: string, name: string, os: string): Promise<Redeemed>;
27
+ export type Confirmed = Pick<BridgeIdentity, "relayId" | "organizationId" | "ownerMemberId" | "issuer"> & {
28
+ jwks: {
29
+ keys: Jwk[];
30
+ };
31
+ };
32
+ /**
33
+ * Asks every 2 s whether the member confirmed the pairing in chat. Resolves with the device's
34
+ * identity, or null when the pairing expired. A failed request is asked again on the next tick.
35
+ */
36
+ export declare function awaitConfirmation(baseUrl: string, key: KeyObject, redeemed: Redeemed): Promise<Confirmed | null>;
37
+ /** A relay JWT for the backend's device endpoints. */
38
+ export declare function relayAuthorization(identity: BridgeIdentity, key: KeyObject): string;
39
+ export declare function reportInventory(identity: BridgeIdentity, key: KeyObject, inventory: DeviceInventory): Promise<void>;
40
+ export declare function disconnectDevice(identity: BridgeIdentity, key: KeyObject): Promise<void>;
41
+ //# sourceMappingURL=pairing.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { BackboneApiError } from "../lib/errors.js";
2
+ import { b64url } from "../relay-protocol/b64.js";
3
+ import { mintRelayJwt } from "../relay-protocol/jws.js";
4
+ import { kidOf, rawPublicKey } from "../relay-protocol/keys.js";
5
+ import { fingerprintWords, mintPairingJwt, signPairProof } from "../relay-protocol/pairing.js";
6
+ /**
7
+ * The bridge's own calls to the backend (#1377, plan P3, P4, P10). None carries an account
8
+ * credential: redeem is open, the pairing poll signs with the new key, and once paired every call
9
+ * carries a relay JWT signed by the device key.
10
+ */
11
+ const POLL_INTERVAL_MS = 2_000;
12
+ /** The computer was revoked or is unknown to the backend: final, never retried. */
13
+ export class DeviceGoneError extends Error {
14
+ constructor() {
15
+ super("This computer was disconnected. Run 2kw bridge connect to pair it again.");
16
+ this.name = "DeviceGoneError";
17
+ }
18
+ }
19
+ /** The skew the backend allows on a signed token's `iat`/`exp`; past it every signed call gets a 401. */
20
+ const MAX_SKEW_S = 30;
21
+ /** The 401 came from this computer's clock, not from a revocation: nothing is deleted. */
22
+ export class ClockSkewError extends Error {
23
+ offsetSeconds;
24
+ constructor(offsetSeconds, then = "Set it to the right time, then run this again.") {
25
+ super(`This computer's clock is off by ${Math.abs(offsetSeconds)} s and must be corrected. ${then}`);
26
+ this.offsetSeconds = offsetSeconds;
27
+ this.name = "ClockSkewError";
28
+ }
29
+ }
30
+ /** For a 401: the clock error when the response's `Date` and this computer's clock differ by more than 30 s. */
31
+ export function clockSkew(response, then) {
32
+ const server = Date.parse(response.headers.get("date") ?? "");
33
+ if (Number.isNaN(server))
34
+ return null;
35
+ const offset = Math.round((Date.now() - server) / 1000);
36
+ return Math.abs(offset) > MAX_SKEW_S ? new ClockSkewError(offset, then) : null;
37
+ }
38
+ const nowSec = () => Math.floor(Date.now() / 1000);
39
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
40
+ async function apiError(response) {
41
+ let body = {};
42
+ try {
43
+ body = (await response.json());
44
+ }
45
+ catch {
46
+ // Not JSON: the status line is all there is.
47
+ }
48
+ const str = (v) => (typeof v === "string" ? v : undefined);
49
+ return new BackboneApiError({
50
+ status: response.status,
51
+ error: str(body.errorCode) ?? str(body.title) ?? str(body.error) ?? response.statusText,
52
+ message: str(body.detail) ?? str(body.message) ?? `HTTP ${response.status}: ${response.statusText}`,
53
+ timestamp: new Date().toISOString(),
54
+ errorCode: str(body.errorCode),
55
+ });
56
+ }
57
+ /**
58
+ * Redeems a pairing code with a fresh key. Returns the words this key makes, checked against the
59
+ * backend's: those are the words the member compares with chat.
60
+ */
61
+ export async function redeem(baseUrl, key, code, name, os) {
62
+ const raw = rawPublicKey(key);
63
+ const publicKey = b64url(raw);
64
+ const kid = kidOf(raw);
65
+ const proof = signPairProof(key, code, kid, publicKey, name, os);
66
+ const response = await fetch(`${baseUrl}/v1/local-bridge/pairings/redeem`, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ code, publicKey, kid, proof, name, os }),
70
+ redirect: "error",
71
+ });
72
+ if (!response.ok) {
73
+ const error = await apiError(response);
74
+ // Someone who saw the code may have used it first; chat would then show their computer.
75
+ if (error.errorCode === "PAIRING_CODE_INVALID") {
76
+ error.message += " If chat now asks you to confirm a computer, do not confirm it; start a new pairing in chat.";
77
+ }
78
+ throw error;
79
+ }
80
+ const redeemed = (await response.json());
81
+ const words = fingerprintWords(raw);
82
+ if (!Array.isArray(redeemed.fingerprint) || redeemed.fingerprint.join(" ") !== words.join(" ")) {
83
+ throw new Error("The server reported a different key for this computer. Nothing was connected.");
84
+ }
85
+ return { ...redeemed, fingerprint: words };
86
+ }
87
+ /**
88
+ * Asks every 2 s whether the member confirmed the pairing in chat. Resolves with the device's
89
+ * identity, or null when the pairing expired. A failed request is asked again on the next tick.
90
+ */
91
+ export async function awaitConfirmation(baseUrl, key, redeemed) {
92
+ const kid = kidOf(rawPublicKey(key));
93
+ // Past this a failing backend cannot leave the command waiting forever.
94
+ const deadline = Date.parse(redeemed.expiresAt) + 60_000;
95
+ for (;;) {
96
+ if (Date.now() > deadline)
97
+ return null;
98
+ let response;
99
+ try {
100
+ response = await fetch(`${baseUrl}/v1/local-bridge/pairings/${encodeURIComponent(redeemed.pairingId)}`, {
101
+ headers: { authorization: `Bearer ${mintPairingJwt(key, kid, redeemed.pairingId, redeemed.issuer, nowSec())}` },
102
+ redirect: "error",
103
+ });
104
+ }
105
+ catch {
106
+ response = undefined;
107
+ }
108
+ if (response?.ok) {
109
+ const body = (await response.json());
110
+ if (body.status === "CONFIRMED") {
111
+ const { relayId, organizationId, ownerMemberId, issuer, jwks } = body;
112
+ return { relayId, organizationId, ownerMemberId, issuer, jwks };
113
+ }
114
+ if (body.status === "EXPIRED")
115
+ return null;
116
+ }
117
+ else if (response?.status === 401 || response?.status === 404) {
118
+ const skew = response.status === 401
119
+ ? clockSkew(response, "Do not confirm this computer in chat; set the clock right, then start a new pairing.")
120
+ : null;
121
+ if (skew)
122
+ throw skew;
123
+ // A newer pairing replaced this one, or it lapsed: the same outcome for this computer.
124
+ return null;
125
+ }
126
+ else if (response && response.status < 500) {
127
+ throw await apiError(response);
128
+ }
129
+ await sleep(POLL_INTERVAL_MS);
130
+ }
131
+ }
132
+ /** A relay JWT for the backend's device endpoints. */
133
+ export function relayAuthorization(identity, key) {
134
+ return `Bearer ${mintRelayJwt(key, kidOf(rawPublicKey(key)), identity.relayId, `${identity.issuer}/v1/mcp-relay`, nowSec())}`;
135
+ }
136
+ async function deviceCall(identity, key, method, path, body) {
137
+ const response = await fetch(`${identity.baseUrl}${path}`, {
138
+ method,
139
+ headers: {
140
+ authorization: relayAuthorization(identity, key),
141
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
142
+ },
143
+ body: body === undefined ? undefined : JSON.stringify(body),
144
+ redirect: "error",
145
+ });
146
+ if (response.status === 401)
147
+ throw clockSkew(response) ?? new DeviceGoneError();
148
+ if (!response.ok)
149
+ throw await apiError(response);
150
+ await response.body?.cancel();
151
+ }
152
+ export function reportInventory(identity, key, inventory) {
153
+ return deviceCall(identity, key, "PUT", "/v1/local-bridge/inventory", inventory);
154
+ }
155
+ export function disconnectDevice(identity, key) {
156
+ return deviceCall(identity, key, "POST", "/v1/local-bridge/device/disconnect");
157
+ }
158
+ //# sourceMappingURL=pairing.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Where the local bridge keeps its key, identity and bridge.toml (#1377, plan P14):
3
+ * `%APPDATA%\2kw\bridge` on Windows, `~/.config/2kw/bridge` on Linux and macOS.
4
+ * `AI_2KW_BRIDGE_DIR` overrides it. Built here because `conf`'s own directories differ.
5
+ */
6
+ export declare function bridgeDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, home?: string): string;
7
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { posix, win32 } from "node:path";
3
+ /**
4
+ * Where the local bridge keeps its key, identity and bridge.toml (#1377, plan P14):
5
+ * `%APPDATA%\2kw\bridge` on Windows, `~/.config/2kw/bridge` on Linux and macOS.
6
+ * `AI_2KW_BRIDGE_DIR` overrides it. Built here because `conf`'s own directories differ.
7
+ */
8
+ export function bridgeDir(env = process.env, platform = process.platform, home = homedir()) {
9
+ if (env.AI_2KW_BRIDGE_DIR)
10
+ return env.AI_2KW_BRIDGE_DIR;
11
+ if (platform === "win32") {
12
+ return win32.join(env.APPDATA ?? win32.join(home, "AppData", "Roaming"), "2kw", "bridge");
13
+ }
14
+ return posix.join(home, ".config", "2kw", "bridge");
15
+ }
16
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,12 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import { type BridgeIdentity } from "./store.js";
3
+ /**
4
+ * The foreground loop of `2kw bridge run` (#1377, plan P14). Reports the inventory at start, when
5
+ * bridge.toml (or the bash it names) changes, and every 10 minutes, and long-polls the hub so the
6
+ * computer shows as present. A 401 from either is final and rejects with {@link DeviceGoneError}:
7
+ * unlike the network relay, a device does not wait for its credential to come back. A 401 a wrong
8
+ * clock caused rejects with {@link ClockSkewError}, and a 426 from either with the update message.
9
+ * Resolves when `signal` aborts.
10
+ */
11
+ export declare function runBridge(dir: string, identity: BridgeIdentity, key: KeyObject, signal: AbortSignal): Promise<void>;
12
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import chalk from "chalk";
3
+ import { deviceConfigHash } from "../relay-protocol/pairing.js";
4
+ import { pollHub } from "./hub.js";
5
+ import { collectInventory } from "./inventory.js";
6
+ import { BackboneApiError } from "../lib/errors.js";
7
+ import { ClockSkewError, DeviceGoneError, reportInventory } from "./pairing.js";
8
+ import { findBash } from "./shell.js";
9
+ import { readConfig } from "./store.js";
10
+ const REPORT_EVERY_MS = 10 * 60_000;
11
+ const CONFIG_CHECK_MS = 2_000;
12
+ const MIN_POLL_MS = 1_000;
13
+ const MAX_BACKOFF_MS = 30_000;
14
+ const OUTDATED = "This version of 2kw is too old for this server. Update with npm install -g @2kw/ai";
15
+ function sleep(ms, signal) {
16
+ return new Promise((done) => {
17
+ const finish = () => {
18
+ clearTimeout(timer);
19
+ signal.removeEventListener("abort", finish);
20
+ done();
21
+ };
22
+ const timer = setTimeout(finish, ms);
23
+ signal.addEventListener("abort", finish, { once: true });
24
+ });
25
+ }
26
+ /**
27
+ * The foreground loop of `2kw bridge run` (#1377, plan P14). Reports the inventory at start, when
28
+ * bridge.toml (or the bash it names) changes, and every 10 minutes, and long-polls the hub so the
29
+ * computer shows as present. A 401 from either is final and rejects with {@link DeviceGoneError}:
30
+ * unlike the network relay, a device does not wait for its credential to come back. A 401 a wrong
31
+ * clock caused rejects with {@link ClockSkewError}, and a 426 from either with the update message.
32
+ * Resolves when `signal` aborts.
33
+ */
34
+ export async function runBridge(dir, identity, key, signal) {
35
+ const stop = new AbortController();
36
+ const onAbort = () => stop.abort();
37
+ signal.addEventListener("abort", onAbort, { once: true });
38
+ let fatal;
39
+ // The hash of what was last sent, so an unchanged bridge.toml is not re-sent and a failed send
40
+ // waits for the 10-minute report instead of retrying every check.
41
+ let attempted;
42
+ let reporting;
43
+ const report = async () => {
44
+ try {
45
+ const inventory = await collectInventory(readConfig(dir));
46
+ attempted = inventory.configHash;
47
+ await reportInventory(identity, key, inventory);
48
+ }
49
+ catch (error) {
50
+ if (error instanceof DeviceGoneError || error instanceof ClockSkewError) {
51
+ fatal ??= error;
52
+ stop.abort();
53
+ }
54
+ else if (error instanceof BackboneApiError && error.status === 426) {
55
+ fatal ??= new Error(OUTDATED);
56
+ stop.abort();
57
+ }
58
+ else {
59
+ console.error(chalk.yellow(`Could not send this computer's folders: ${String(error)}`));
60
+ }
61
+ }
62
+ };
63
+ const trigger = () => {
64
+ reporting ??= report().finally(() => {
65
+ reporting = undefined;
66
+ });
67
+ return reporting;
68
+ };
69
+ const timers = [];
70
+ try {
71
+ await trigger();
72
+ timers.push(setInterval(trigger, REPORT_EVERY_MS));
73
+ timers.push(setInterval(() => {
74
+ try {
75
+ const config = readConfig(dir);
76
+ const hash = deviceConfigHash({ shell: findBash(config.shell), workspaces: config.workspaces });
77
+ if (hash !== attempted)
78
+ void trigger();
79
+ }
80
+ catch {
81
+ // A half-saved bridge.toml: the next check reads it again.
82
+ }
83
+ }, CONFIG_CHECK_MS));
84
+ const replicaId = randomBytes(8).toString("hex");
85
+ let backoff = MIN_POLL_MS;
86
+ let toldInUse = false;
87
+ while (!stop.signal.aborted) {
88
+ const started = Date.now();
89
+ let status;
90
+ try {
91
+ status = await pollHub(identity, key, replicaId, stop.signal);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof ClockSkewError) {
95
+ fatal ??= error;
96
+ break;
97
+ }
98
+ status = undefined;
99
+ }
100
+ if (stop.signal.aborted)
101
+ break;
102
+ if (status === 401) {
103
+ fatal ??= new DeviceGoneError();
104
+ break;
105
+ }
106
+ if (status === 426) {
107
+ fatal ??= new Error(OUTDATED);
108
+ break;
109
+ }
110
+ if (status === 409 && !toldInUse) {
111
+ toldInUse = true;
112
+ console.error(chalk.yellow("Another 2kw bridge run keeps this computer reachable, or one stopped moments ago. This one takes over when it stops."));
113
+ }
114
+ if (status === 200 || status === 204) {
115
+ backoff = MIN_POLL_MS;
116
+ // A poll that comes back at once is not held by the server; never spin on it.
117
+ if (Date.now() - started < MIN_POLL_MS)
118
+ await sleep(MIN_POLL_MS, stop.signal);
119
+ continue;
120
+ }
121
+ await sleep(backoff, stop.signal);
122
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
123
+ }
124
+ }
125
+ finally {
126
+ timers.forEach(clearInterval);
127
+ signal.removeEventListener("abort", onAbort);
128
+ stop.abort();
129
+ await reporting;
130
+ }
131
+ if (fatal !== undefined)
132
+ throw fatal;
133
+ }
134
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1,17 @@
1
+ /** The seams of the bash lookup: the platform, its environment, and which files exist. */
2
+ export interface ShellLookup {
3
+ platform: NodeJS.Platform;
4
+ env: NodeJS.ProcessEnv;
5
+ isFile: (path: string) => boolean;
6
+ }
7
+ /**
8
+ * Finds the POSIX `bash` device commands run in (#1377, plan P15, spec D7). The `shell` from
9
+ * bridge.toml wins, and when it does not exist nothing else is taken. Otherwise: on Windows, Git
10
+ * for Windows in its usual places, then next to the `git` on PATH, then a `bash.exe` on PATH; on
11
+ * Linux and macOS, `bash` on PATH. The WSL launcher in System32 is never taken: it runs commands
12
+ * in a Linux distribution, not on this computer's files.
13
+ */
14
+ export declare function findBash(override: string | undefined, lookup?: ShellLookup): string | null;
15
+ /** The first line of `bash --version`, or null when it does not answer within 2 s. */
16
+ export declare function bashVersion(path: string): Promise<string | null>;
17
+ //# sourceMappingURL=shell.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { execFile } from "node:child_process";
2
+ import { statSync } from "node:fs";
3
+ import { posix, win32 } from "node:path";
4
+ function isFile(path) {
5
+ try {
6
+ return statSync(path).isFile();
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ const defaultLookup = () => ({ platform: process.platform, env: process.env, isFile });
13
+ /**
14
+ * Finds the POSIX `bash` device commands run in (#1377, plan P15, spec D7). The `shell` from
15
+ * bridge.toml wins, and when it does not exist nothing else is taken. Otherwise: on Windows, Git
16
+ * for Windows in its usual places, then next to the `git` on PATH, then a `bash.exe` on PATH; on
17
+ * Linux and macOS, `bash` on PATH. The WSL launcher in System32 is never taken: it runs commands
18
+ * in a Linux distribution, not on this computer's files.
19
+ */
20
+ export function findBash(override, lookup = defaultLookup()) {
21
+ if (override !== undefined)
22
+ return lookup.isFile(override) ? override : null;
23
+ const { platform, env } = lookup;
24
+ const path = platform === "win32" ? win32 : posix;
25
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter((d) => d !== "");
26
+ const onPath = (file) => dirs.map((d) => path.join(d, file)).filter(lookup.isFile);
27
+ if (platform !== "win32")
28
+ return onPath("bash")[0] ?? null;
29
+ const wsl = path.join(env.SystemRoot ?? "C:\\Windows", "System32", "bash.exe").toLowerCase();
30
+ const candidates = [
31
+ env.ProgramFiles && path.join(env.ProgramFiles, "Git", "bin", "bash.exe"),
32
+ env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "Git", "bin", "bash.exe"),
33
+ env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe"),
34
+ // git.exe sits in <root>\cmd or <root>\bin; bash.exe in <root>\bin.
35
+ ...onPath("git.exe").map((git) => path.join(path.dirname(path.dirname(git)), "bin", "bash.exe")),
36
+ ...onPath("bash.exe"),
37
+ ];
38
+ return (candidates.find((candidate) => !!candidate && candidate.toLowerCase() !== wsl && lookup.isFile(candidate)) ?? null);
39
+ }
40
+ /** The first line of `bash --version`, or null when it does not answer within 2 s. */
41
+ export function bashVersion(path) {
42
+ return new Promise((done) => {
43
+ execFile(path, ["--version"], { timeout: 2_000, windowsHide: true }, (error, stdout) => {
44
+ done(error ? null : (stdout.split(/\r?\n/)[0]?.trim() ?? null) || null);
45
+ });
46
+ });
47
+ }
48
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1,33 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import { CliUsageError } from "../lib/errors.js";
3
+ import type { Jwk } from "../relay-protocol/keys.js";
4
+ import { type BridgeConfig, type Workspace } from "./toml.js";
5
+ /** What a confirmed pairing hands the bridge, plus where it was paired. Never an account token. */
6
+ export interface BridgeIdentity {
7
+ relayId: string;
8
+ organizationId: string;
9
+ ownerMemberId: string;
10
+ issuer: string;
11
+ jwks: {
12
+ keys: Jwk[];
13
+ };
14
+ baseUrl: string;
15
+ name: string;
16
+ pairedAt: string;
17
+ }
18
+ export declare function writeKey(dir: string, key: KeyObject): void;
19
+ export declare function readKey(dir: string): KeyObject;
20
+ export declare function writeIdentity(dir: string, identity: BridgeIdentity): void;
21
+ /** identity.json is not JSON: `connect` pairs over it, every other command names the file to delete. */
22
+ export declare class UnreadableIdentityError extends CliUsageError {
23
+ readonly path: string;
24
+ constructor(path: string);
25
+ }
26
+ /** null when this computer is not connected. */
27
+ export declare function readIdentity(dir: string): BridgeIdentity | null;
28
+ export declare function deleteIdentity(dir: string): void;
29
+ export declare function configPath(dir: string): string;
30
+ export declare function readConfig(dir: string): BridgeConfig;
31
+ export declare function addWorkspace(dir: string, path: string, name?: string): Workspace;
32
+ export declare function removeWorkspace(dir: string, name: string): void;
33
+ //# sourceMappingURL=store.d.ts.map