@2kw/ai 7.3.0-dev.1 → 7.3.0-dev.10
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/dist/agent-config/schema.d.ts +45 -1
- package/dist/agent-config/schema.js +26 -2
- package/dist/agent-config/template.js +3 -0
- package/dist/agent-config/validate.js +13 -3
- package/dist/bridge/hub.d.ts +11 -0
- package/dist/bridge/hub.js +44 -0
- package/dist/bridge/inventory.d.ts +27 -0
- package/dist/bridge/inventory.js +41 -0
- package/dist/bridge/pairing.d.ts +41 -0
- package/dist/bridge/pairing.js +158 -0
- package/dist/bridge/paths.d.ts +7 -0
- package/dist/bridge/paths.js +16 -0
- package/dist/bridge/run.d.ts +12 -0
- package/dist/bridge/run.js +134 -0
- package/dist/bridge/shell.d.ts +17 -0
- package/dist/bridge/shell.js +48 -0
- package/dist/bridge/store.d.ts +33 -0
- package/dist/bridge/store.js +110 -0
- package/dist/bridge/toml.d.ts +16 -0
- package/dist/bridge/toml.js +39 -0
- package/dist/commands/bridge.d.ts +7 -0
- package/dist/commands/bridge.js +228 -0
- package/dist/commands/extractions.js +24 -5
- package/dist/index.js +2 -0
- package/dist/lib/agent-run.js +1 -1
- package/dist/relay-protocol/b64.d.ts +13 -0
- package/dist/relay-protocol/b64.js +34 -0
- package/dist/relay-protocol/canonical-json.d.ts +22 -0
- package/dist/relay-protocol/canonical-json.js +101 -0
- package/dist/relay-protocol/frames.d.ts +75 -0
- package/dist/relay-protocol/frames.js +224 -0
- package/dist/relay-protocol/headers.d.ts +18 -0
- package/dist/relay-protocol/headers.js +88 -0
- package/dist/relay-protocol/jws.d.ts +28 -0
- package/dist/relay-protocol/jws.js +113 -0
- package/dist/relay-protocol/keys.d.ts +26 -0
- package/dist/relay-protocol/keys.js +80 -0
- package/dist/relay-protocol/pairing.d.ts +26 -0
- package/dist/relay-protocol/pairing.js +49 -0
- package/dist/relay-protocol/wire.d.ts +101 -0
- package/dist/relay-protocol/wire.js +63 -0
- package/dist/relay-protocol/wordlist.d.ts +30 -0
- package/dist/relay-protocol/wordlist.js +289 -0
- package/package.json +4 -1
|
@@ -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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { CliUsageError } from "../lib/errors.js";
|
|
4
|
+
import { privateKeyFromPem, privateKeyToPem } from "../relay-protocol/keys.js";
|
|
5
|
+
import { MAX_WORKSPACES, WORKSPACE_NAME, parseBridgeToml, stringifyBridgeToml } from "./toml.js";
|
|
6
|
+
/**
|
|
7
|
+
* The bridge's three files (#1377, plan P14): `key.pem` and `identity.json`, readable by the user
|
|
8
|
+
* only, and `bridge.toml`. On Windows the mode bits are inert; the files rely on the profile
|
|
9
|
+
* directory's own permissions.
|
|
10
|
+
*/
|
|
11
|
+
const KEY_FILE = "key.pem";
|
|
12
|
+
const IDENTITY_FILE = "identity.json";
|
|
13
|
+
const CONFIG_FILE = "bridge.toml";
|
|
14
|
+
function writeSecretFile(path, data) {
|
|
15
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
16
|
+
const tmp = `${path}.tmp`;
|
|
17
|
+
// Owner-only from the first byte; the rename replaces the old file in one step.
|
|
18
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
19
|
+
renameSync(tmp, path);
|
|
20
|
+
chmodSync(path, 0o600);
|
|
21
|
+
}
|
|
22
|
+
export function writeKey(dir, key) {
|
|
23
|
+
writeSecretFile(join(dir, KEY_FILE), privateKeyToPem(key));
|
|
24
|
+
}
|
|
25
|
+
export function readKey(dir) {
|
|
26
|
+
return privateKeyFromPem(readFileSync(join(dir, KEY_FILE), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
export function writeIdentity(dir, identity) {
|
|
29
|
+
writeSecretFile(join(dir, IDENTITY_FILE), JSON.stringify(identity, null, 2));
|
|
30
|
+
}
|
|
31
|
+
/** identity.json is not JSON: `connect` pairs over it, every other command names the file to delete. */
|
|
32
|
+
export class UnreadableIdentityError extends CliUsageError {
|
|
33
|
+
path;
|
|
34
|
+
constructor(path) {
|
|
35
|
+
super(`${path} is unreadable. Delete ${path}, then run 2kw bridge connect again.`);
|
|
36
|
+
this.path = path;
|
|
37
|
+
this.name = "UnreadableIdentityError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** null when this computer is not connected. */
|
|
41
|
+
export function readIdentity(dir) {
|
|
42
|
+
const path = join(dir, IDENTITY_FILE);
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new UnreadableIdentityError(path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function deleteIdentity(dir) {
|
|
53
|
+
rmSync(join(dir, IDENTITY_FILE), { force: true });
|
|
54
|
+
}
|
|
55
|
+
export function configPath(dir) {
|
|
56
|
+
return join(dir, CONFIG_FILE);
|
|
57
|
+
}
|
|
58
|
+
export function readConfig(dir) {
|
|
59
|
+
const path = configPath(dir);
|
|
60
|
+
if (!existsSync(path))
|
|
61
|
+
return { workspaces: [] };
|
|
62
|
+
try {
|
|
63
|
+
return parseBridgeToml(readFileSync(path, "utf8"));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new CliUsageError(`${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function writeConfig(dir, config) {
|
|
70
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
writeFileSync(configPath(dir), stringifyBridgeToml(config));
|
|
72
|
+
}
|
|
73
|
+
/** The folder name, lowercased, with every character a workspace name cannot hold turned into `-`. */
|
|
74
|
+
function defaultName(path) {
|
|
75
|
+
return basename(path)
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
78
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
79
|
+
.slice(0, 64);
|
|
80
|
+
}
|
|
81
|
+
export function addWorkspace(dir, path, name) {
|
|
82
|
+
const resolved = resolve(path);
|
|
83
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
84
|
+
throw new CliUsageError(`${resolved} is not an existing folder`);
|
|
85
|
+
}
|
|
86
|
+
const workspaceName = name ?? defaultName(resolved);
|
|
87
|
+
if (!WORKSPACE_NAME.test(workspaceName)) {
|
|
88
|
+
throw new CliUsageError(`'${workspaceName}' cannot be a workspace name: use 1 to 64 lowercase letters, digits, '.', '_' or '-', ` +
|
|
89
|
+
"starting with a letter or digit (--name sets it)");
|
|
90
|
+
}
|
|
91
|
+
const config = readConfig(dir);
|
|
92
|
+
if (config.workspaces.length >= MAX_WORKSPACES) {
|
|
93
|
+
throw new CliUsageError(`This computer already offers ${MAX_WORKSPACES} workspaces, the most it can. Remove one first with 2kw bridge remove <name>.`);
|
|
94
|
+
}
|
|
95
|
+
if (config.workspaces.some((w) => w.name === workspaceName)) {
|
|
96
|
+
throw new CliUsageError(`A workspace named '${workspaceName}' already exists`);
|
|
97
|
+
}
|
|
98
|
+
const workspace = { name: workspaceName, path: resolved };
|
|
99
|
+
writeConfig(dir, { ...config, workspaces: [...config.workspaces, workspace] });
|
|
100
|
+
return workspace;
|
|
101
|
+
}
|
|
102
|
+
export function removeWorkspace(dir, name) {
|
|
103
|
+
const config = readConfig(dir);
|
|
104
|
+
const workspaces = config.workspaces.filter((w) => w.name !== name);
|
|
105
|
+
if (workspaces.length === config.workspaces.length) {
|
|
106
|
+
throw new CliUsageError(`No workspace named '${name}'`);
|
|
107
|
+
}
|
|
108
|
+
writeConfig(dir, { ...config, workspaces });
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface Workspace {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
/** bridge.toml: an optional top-level `shell` and `[[workspace]]` tables with `name` and `path`. */
|
|
6
|
+
export interface BridgeConfig {
|
|
7
|
+
shell?: string;
|
|
8
|
+
workspaces: Workspace[];
|
|
9
|
+
}
|
|
10
|
+
export declare const WORKSPACE_NAME: RegExp;
|
|
11
|
+
/** The most workspaces the backend takes in one inventory. */
|
|
12
|
+
export declare const MAX_WORKSPACES = 50;
|
|
13
|
+
/** Throws a plain message on anything that is not a bridge.toml this bridge wrote or could have. */
|
|
14
|
+
export declare function parseBridgeToml(text: string): BridgeConfig;
|
|
15
|
+
export declare function stringifyBridgeToml(config: BridgeConfig): string;
|
|
16
|
+
//# sourceMappingURL=toml.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { parse, stringify } from "smol-toml";
|
|
2
|
+
export const WORKSPACE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
3
|
+
/** The most workspaces the backend takes in one inventory. */
|
|
4
|
+
export const MAX_WORKSPACES = 50;
|
|
5
|
+
/** Throws a plain message on anything that is not a bridge.toml this bridge wrote or could have. */
|
|
6
|
+
export function parseBridgeToml(text) {
|
|
7
|
+
const raw = parse(text);
|
|
8
|
+
const shell = raw.shell;
|
|
9
|
+
if (shell !== undefined && typeof shell !== "string") {
|
|
10
|
+
throw new Error("shell must be a path");
|
|
11
|
+
}
|
|
12
|
+
const tables = raw.workspace ?? [];
|
|
13
|
+
if (!Array.isArray(tables)) {
|
|
14
|
+
throw new Error("workspace must be a list of [[workspace]] tables");
|
|
15
|
+
}
|
|
16
|
+
if (tables.length > MAX_WORKSPACES) {
|
|
17
|
+
throw new Error(`at most ${MAX_WORKSPACES} [[workspace]] tables`);
|
|
18
|
+
}
|
|
19
|
+
const names = new Set();
|
|
20
|
+
const workspaces = tables.map((table) => {
|
|
21
|
+
const { name, path } = (table ?? {});
|
|
22
|
+
if (typeof name !== "string" || !WORKSPACE_NAME.test(name) || typeof path !== "string" || path === "") {
|
|
23
|
+
throw new Error("every [[workspace]] needs a valid name and a path");
|
|
24
|
+
}
|
|
25
|
+
if (names.has(name))
|
|
26
|
+
throw new Error(`the workspace name '${name}' is used twice`);
|
|
27
|
+
names.add(name);
|
|
28
|
+
return { name, path };
|
|
29
|
+
});
|
|
30
|
+
return shell === undefined ? { workspaces } : { shell, workspaces };
|
|
31
|
+
}
|
|
32
|
+
export function stringifyBridgeToml(config) {
|
|
33
|
+
const doc = {};
|
|
34
|
+
if (config.shell !== undefined)
|
|
35
|
+
doc.shell = config.shell;
|
|
36
|
+
doc.workspace = config.workspaces.map(({ name, path }) => ({ name, path }));
|
|
37
|
+
return `${stringify(doc)}\n`;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=toml.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `2kw bridge`: lets chat run the commands you approve on this computer (#1377, spec §2.1, plan P14).
|
|
4
|
+
* S1 pairs, registers and stays present; commands arrive with #1378.
|
|
5
|
+
*/
|
|
6
|
+
export declare function makeBridgeCommand(): Command;
|
|
7
|
+
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { LABEL, collectInventory, deviceName, deviceOs } from "../bridge/inventory.js";
|
|
4
|
+
import { DeviceGoneError, awaitConfirmation, disconnectDevice, redeem, reportInventory } from "../bridge/pairing.js";
|
|
5
|
+
import { bridgeDir } from "../bridge/paths.js";
|
|
6
|
+
import { runBridge } from "../bridge/run.js";
|
|
7
|
+
import { findBash } from "../bridge/shell.js";
|
|
8
|
+
import { addWorkspace, configPath, deleteIdentity, readConfig, readIdentity, readKey, removeWorkspace, UnreadableIdentityError, writeIdentity, writeKey, } from "../bridge/store.js";
|
|
9
|
+
import { getClient, runAction } from "../lib/client.js";
|
|
10
|
+
import { DEFAULT_BASE_URL, getActiveContext, isJsonOutput } from "../lib/config.js";
|
|
11
|
+
import { CliUsageError } from "../lib/errors.js";
|
|
12
|
+
import { formatList, formatSuccess } from "../lib/output.js";
|
|
13
|
+
import { generatePrivateKey } from "../relay-protocol/keys.js";
|
|
14
|
+
/** Account endpoints take a browser sign-in; said where `--help` shows it, as in connectors.ts. */
|
|
15
|
+
const SESSION_ONLY = "Signed-in accounts only: run 2kw auth login and sign in through the browser";
|
|
16
|
+
/** The backend to pair with: `--base-url`, then AI_2KW_BASE_URL, the active context, the default. No credential needed. */
|
|
17
|
+
function baseUrlOf(command) {
|
|
18
|
+
let root = command;
|
|
19
|
+
while (root.parent)
|
|
20
|
+
root = root.parent;
|
|
21
|
+
const url = root.opts().baseUrl ?? process.env.AI_2KW_BASE_URL ?? getActiveContext()?.baseUrl;
|
|
22
|
+
return (url ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
23
|
+
}
|
|
24
|
+
/** Plain http only to this computer: the pairing answer carries the keys the bridge trusts. */
|
|
25
|
+
function requireHttps(baseUrl) {
|
|
26
|
+
const { protocol, hostname } = new URL(baseUrl);
|
|
27
|
+
if (protocol === "https:" || (protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(hostname))) {
|
|
28
|
+
return baseUrl;
|
|
29
|
+
}
|
|
30
|
+
throw new CliUsageError(`${baseUrl} is not an https address. The bridge connects only over https, or over http to this computer (localhost).`);
|
|
31
|
+
}
|
|
32
|
+
function checkLabel(value, what) {
|
|
33
|
+
if (!LABEL.test(value)) {
|
|
34
|
+
throw new CliUsageError(`${what} must be 1 to 64 characters on one line, without control or invisible characters`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/** Loads the key and identity of a connected computer, or says how to connect one. */
|
|
39
|
+
function connected(dir) {
|
|
40
|
+
const identity = readIdentity(dir);
|
|
41
|
+
if (identity === null) {
|
|
42
|
+
throw new CliUsageError("This computer is not connected. Run 2kw bridge connect <code> with the code from chat.");
|
|
43
|
+
}
|
|
44
|
+
return { identity, key: readKey(dir) };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `2kw bridge`: lets chat run the commands you approve on this computer (#1377, spec §2.1, plan P14).
|
|
48
|
+
* S1 pairs, registers and stays present; commands arrive with #1378.
|
|
49
|
+
*/
|
|
50
|
+
export function makeBridgeCommand() {
|
|
51
|
+
const cmd = new Command("bridge").description("Connect this computer to chat, so commands you approve in a conversation can run here");
|
|
52
|
+
cmd
|
|
53
|
+
.command("connect")
|
|
54
|
+
.description("Connect this computer with the code shown in chat under Devices")
|
|
55
|
+
.argument("<code>", "Pairing code from chat (bbp_…)")
|
|
56
|
+
.option("--name <name>", "Name chat shows for this computer; the computer name when omitted")
|
|
57
|
+
.action(async (code, opts, command) => {
|
|
58
|
+
await runAction(command, async () => {
|
|
59
|
+
const dir = bridgeDir();
|
|
60
|
+
let existing;
|
|
61
|
+
try {
|
|
62
|
+
existing = readIdentity(dir);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!(error instanceof UnreadableIdentityError))
|
|
66
|
+
throw error;
|
|
67
|
+
console.error(chalk.yellow(`${error.path} is unreadable; this pairing replaces it once you confirm.`));
|
|
68
|
+
existing = null;
|
|
69
|
+
}
|
|
70
|
+
if (existing !== null) {
|
|
71
|
+
throw new CliUsageError(`This computer is already connected as '${existing.name}'. Run 2kw bridge disconnect first.`);
|
|
72
|
+
}
|
|
73
|
+
const baseUrl = requireHttps(baseUrlOf(command));
|
|
74
|
+
const name = checkLabel(opts.name ?? deviceName(), "--name");
|
|
75
|
+
const key = generatePrivateKey();
|
|
76
|
+
const redeemed = await redeem(baseUrl, key, code.trim(), name, deviceOs());
|
|
77
|
+
console.log(`Check that chat shows these six words for '${name}':\n`);
|
|
78
|
+
console.log(` ${chalk.bold(redeemed.fingerprint.join(" "))}\n`);
|
|
79
|
+
console.log("If they match, confirm in chat. If they do not, do not confirm. Waiting…");
|
|
80
|
+
const confirmed = await awaitConfirmation(baseUrl, key, redeemed);
|
|
81
|
+
if (confirmed === null) {
|
|
82
|
+
throw new Error("The code expired before it was confirmed. Create a new one in chat and run this again.");
|
|
83
|
+
}
|
|
84
|
+
const identity = { ...confirmed, baseUrl, name, pairedAt: new Date().toISOString() };
|
|
85
|
+
writeKey(dir, key);
|
|
86
|
+
writeIdentity(dir, identity);
|
|
87
|
+
try {
|
|
88
|
+
await reportInventory(identity, key, await collectInventory(readConfig(dir)));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Paired all the same; `2kw bridge run` reports again when it starts.
|
|
92
|
+
console.error(chalk.yellow(`Could not send this computer's folders yet: ${String(error)}`));
|
|
93
|
+
}
|
|
94
|
+
formatSuccess(`Connected as '${name}'. Add folders with 2kw bridge add <path>, then keep 2kw bridge run open.`, command);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
cmd
|
|
98
|
+
.command("add")
|
|
99
|
+
.description("Make a folder on this computer available as a workspace")
|
|
100
|
+
.argument("<path>", "An existing folder")
|
|
101
|
+
.option("--name <name>", "Workspace name; the folder name when omitted")
|
|
102
|
+
.action(async (path, opts, command) => {
|
|
103
|
+
await runAction(command, async () => {
|
|
104
|
+
const workspace = addWorkspace(bridgeDir(), path, opts.name);
|
|
105
|
+
formatSuccess(`Added workspace '${workspace.name}' (${workspace.path}).`, command);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
cmd
|
|
109
|
+
.command("remove")
|
|
110
|
+
.description("Stop offering a workspace; the folder itself is untouched")
|
|
111
|
+
.argument("<name>", "Workspace name, from 2kw bridge list")
|
|
112
|
+
.action(async (name, _opts, command) => {
|
|
113
|
+
await runAction(command, async () => {
|
|
114
|
+
removeWorkspace(bridgeDir(), name);
|
|
115
|
+
formatSuccess(`Removed workspace '${name}'.`, command);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
cmd
|
|
119
|
+
.command("list")
|
|
120
|
+
.description("Show whether this computer is connected, its shell and its workspaces")
|
|
121
|
+
.action(async (_opts, command) => {
|
|
122
|
+
await runAction(command, async () => {
|
|
123
|
+
const dir = bridgeDir();
|
|
124
|
+
const identity = readIdentity(dir);
|
|
125
|
+
const config = readConfig(dir);
|
|
126
|
+
const shell = findBash(config.shell);
|
|
127
|
+
if (isJsonOutput(command)) {
|
|
128
|
+
console.log(JSON.stringify({ connected: identity !== null, name: identity?.name, deviceId: identity?.relayId, shell, workspaces: config.workspaces }, null, 2));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(identity === null
|
|
132
|
+
? "Not connected. Run 2kw bridge connect <code> with the code from chat."
|
|
133
|
+
: `Connected as '${identity.name}' to ${identity.baseUrl} (device ${identity.relayId}).`);
|
|
134
|
+
if (shell !== null) {
|
|
135
|
+
console.log(`Shell: ${shell}`);
|
|
136
|
+
}
|
|
137
|
+
else if (config.shell !== undefined) {
|
|
138
|
+
console.log(chalk.yellow(`The shell set in ${configPath(dir)} does not exist: ${config.shell}`));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
console.log(chalk.yellow(process.platform === "win32"
|
|
142
|
+
? "No bash found: install Git for Windows, or set shell = \"<path to bash.exe>\" in " + configPath(dir)
|
|
143
|
+
: "No bash found on PATH: install bash, or set shell = \"<path>\" in " + configPath(dir)));
|
|
144
|
+
}
|
|
145
|
+
if (config.workspaces.length === 0) {
|
|
146
|
+
console.log("No workspaces. Add one with 2kw bridge add <path>.");
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
formatList(config.workspaces, command, ["name", "path"]);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
cmd
|
|
154
|
+
.command("run")
|
|
155
|
+
.description("Keep this computer reachable from chat while this command runs (Ctrl+C stops)")
|
|
156
|
+
.action(async (_opts, command) => {
|
|
157
|
+
await runAction(command, async () => {
|
|
158
|
+
const dir = bridgeDir();
|
|
159
|
+
const { identity, key } = connected(dir);
|
|
160
|
+
requireHttps(identity.baseUrl);
|
|
161
|
+
const stop = new AbortController();
|
|
162
|
+
const onSignal = () => stop.abort();
|
|
163
|
+
process.once("SIGINT", onSignal);
|
|
164
|
+
process.once("SIGTERM", onSignal);
|
|
165
|
+
console.log(`'${identity.name}' is reachable from chat while this runs. Press Ctrl+C to stop.`);
|
|
166
|
+
try {
|
|
167
|
+
await runBridge(dir, identity, key, stop.signal);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
// Revoked: these files can never authenticate again.
|
|
171
|
+
if (error instanceof DeviceGoneError)
|
|
172
|
+
deleteIdentity(dir);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
process.off("SIGINT", onSignal);
|
|
177
|
+
process.off("SIGTERM", onSignal);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
cmd
|
|
182
|
+
.command("disconnect")
|
|
183
|
+
.description("Disconnect this computer; chat can no longer reach it")
|
|
184
|
+
.action(async (_opts, command) => {
|
|
185
|
+
await runAction(command, async () => {
|
|
186
|
+
const dir = bridgeDir();
|
|
187
|
+
const { identity, key } = connected(dir);
|
|
188
|
+
try {
|
|
189
|
+
await disconnectDevice(identity, key);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Already revoked elsewhere: forgetting it here is all that is left to do.
|
|
193
|
+
if (!(error instanceof DeviceGoneError))
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
deleteIdentity(dir);
|
|
197
|
+
formatSuccess(`Disconnected '${identity.name}'.`, command);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
cmd
|
|
201
|
+
.command("devices")
|
|
202
|
+
.description(`List the computers connected to your account. ${SESSION_ONLY}`)
|
|
203
|
+
.option("--all", "Every computer in the organization, without its folders (admins only)")
|
|
204
|
+
.action(async (opts, command) => {
|
|
205
|
+
await runAction(command, async () => {
|
|
206
|
+
const client = getClient(command);
|
|
207
|
+
const { data } = await client.GET("/v1/local-bridge/devices", {
|
|
208
|
+
params: { query: opts.all ? { all: true } : {} },
|
|
209
|
+
});
|
|
210
|
+
formatList(data, command, opts.all
|
|
211
|
+
? ["id", "name", "ownerMemberId", "os", "status", "lastSeenAt"]
|
|
212
|
+
: ["id", "name", "os", "status", "lastSeenAt", "workspaces"]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
cmd
|
|
216
|
+
.command("revoke")
|
|
217
|
+
.description(`Disconnect one of your computers; an admin can disconnect any. ${SESSION_ONLY}`)
|
|
218
|
+
.argument("<deviceId>", "Device ID, from 2kw bridge devices")
|
|
219
|
+
.action(async (deviceId, _opts, command) => {
|
|
220
|
+
await runAction(command, async () => {
|
|
221
|
+
const client = getClient(command);
|
|
222
|
+
await client.DELETE("/v1/local-bridge/devices/{id}", { params: { path: { id: deviceId } } });
|
|
223
|
+
formatSuccess(`Device ${deviceId} disconnected.`, command);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
return cmd;
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=bridge.js.map
|