@2kw/ai 7.2.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
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* Kept as a TS constant: package.json `files` publishes only dist JS/d.ts.
|
|
6
6
|
*/
|
|
7
7
|
export declare const AGENT_SCHEMA_ID = "https://docs.2kw.ai/schemas/agent.v1.json";
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Types the backend injects or cannot execute; authoring them has no effect or breaks the run.
|
|
10
|
+
* The backend refuses the same map on save (NonAuthorableTools, #1263); its test fails if they differ.
|
|
11
|
+
*/
|
|
9
12
|
export declare const NON_AUTHORABLE_TOOL_TYPES: Record<string, string>;
|
|
10
13
|
export declare const agentSchema: {
|
|
11
14
|
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
@@ -209,6 +212,35 @@ export declare const agentSchema: {
|
|
|
209
212
|
};
|
|
210
213
|
};
|
|
211
214
|
};
|
|
215
|
+
readonly webFetchTool: {
|
|
216
|
+
readonly type: "object";
|
|
217
|
+
readonly additionalProperties: false;
|
|
218
|
+
readonly required: readonly ["type"];
|
|
219
|
+
readonly properties: {
|
|
220
|
+
readonly type: {
|
|
221
|
+
readonly const: "web_fetch";
|
|
222
|
+
};
|
|
223
|
+
readonly config: {
|
|
224
|
+
readonly type: "object";
|
|
225
|
+
readonly properties: {
|
|
226
|
+
readonly source: {
|
|
227
|
+
readonly enum: readonly ["proxy", "native"];
|
|
228
|
+
};
|
|
229
|
+
readonly allowedDomains: {
|
|
230
|
+
readonly type: "array";
|
|
231
|
+
readonly items: {
|
|
232
|
+
readonly type: "string";
|
|
233
|
+
readonly pattern: "^(?=.{1,253}$)([\\p{L}\\p{N}](?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?\\.)+\\p{L}(?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?$";
|
|
234
|
+
};
|
|
235
|
+
readonly maxItems: 10;
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
readonly requiresConfirmation: {
|
|
240
|
+
readonly type: "boolean";
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
};
|
|
212
244
|
readonly extractionTool: {
|
|
213
245
|
readonly type: "object";
|
|
214
246
|
readonly additionalProperties: false;
|
|
@@ -389,6 +421,18 @@ export declare const agentSchema: {
|
|
|
389
421
|
readonly then: {
|
|
390
422
|
readonly $ref: "#/$defs/webSearchTool";
|
|
391
423
|
};
|
|
424
|
+
}, {
|
|
425
|
+
readonly if: {
|
|
426
|
+
required: string[];
|
|
427
|
+
properties: {
|
|
428
|
+
type: {
|
|
429
|
+
const: string;
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
readonly then: {
|
|
434
|
+
readonly $ref: "#/$defs/webFetchTool";
|
|
435
|
+
};
|
|
392
436
|
}, {
|
|
393
437
|
readonly if: {
|
|
394
438
|
required: string[];
|
|
@@ -20,9 +20,13 @@ const MODEL_REF = {
|
|
|
20
20
|
// inner hyphens, Unicode or ASCII. Mirrors WebToolEntryValidator.HOST; needs a Unicode-aware regex engine.
|
|
21
21
|
const HOSTNAME = "^(?=.{1,253}$)([\\p{L}\\p{N}](?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?\\.)+\\p{L}(?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?$";
|
|
22
22
|
const oneOfLowercase = (...values) => ({ type: "string", enum: values });
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Types the backend injects or cannot execute; authoring them has no effect or breaks the run.
|
|
25
|
+
* The backend refuses the same map on save (NonAuthorableTools, #1263); its test fails if they differ.
|
|
26
|
+
*/
|
|
24
27
|
export const NON_AUTHORABLE_TOOL_TYPES = {
|
|
25
|
-
"backbone.tool_search": "
|
|
28
|
+
"backbone.tool_search": "it is added automatically when tool discovery is on",
|
|
29
|
+
"backbone.tool_call": "it is added automatically when tool discovery is on",
|
|
26
30
|
"backbone.skill_read": "it is added at run time when skills are pinned",
|
|
27
31
|
// #944: SkillsetPinner injects it for eligible callers and strips any stored copy (#779).
|
|
28
32
|
"backbone.skills_apply": "it is added at run time for callers allowed to author skills",
|
|
@@ -34,6 +38,7 @@ export const NON_AUTHORABLE_TOOL_TYPES = {
|
|
|
34
38
|
const BUILTIN_TYPES = [
|
|
35
39
|
"file_search",
|
|
36
40
|
"web_search",
|
|
41
|
+
"web_fetch",
|
|
37
42
|
"backbone.extraction",
|
|
38
43
|
"backbone.document_convert",
|
|
39
44
|
"backbone.todo",
|
|
@@ -150,6 +155,24 @@ export const agentSchema = {
|
|
|
150
155
|
requiresConfirmation: { type: "boolean" },
|
|
151
156
|
},
|
|
152
157
|
},
|
|
158
|
+
// Backbone's own fetcher reads a page the user or an earlier search named. A missing config, `config: {}`
|
|
159
|
+
// or a missing source is the proxy; `native` is accepted and answers provider_unavailable until it ships.
|
|
160
|
+
webFetchTool: {
|
|
161
|
+
type: "object",
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
required: ["type"],
|
|
164
|
+
properties: {
|
|
165
|
+
type: { const: "web_fetch" },
|
|
166
|
+
config: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
source: { enum: ["proxy", "native"] },
|
|
170
|
+
allowedDomains: { type: "array", items: { type: "string", pattern: HOSTNAME }, maxItems: 10 },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
requiresConfirmation: { type: "boolean" },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
153
176
|
extractionTool: {
|
|
154
177
|
type: "object",
|
|
155
178
|
additionalProperties: false,
|
|
@@ -237,6 +260,7 @@ export const agentSchema = {
|
|
|
237
260
|
allOf: [
|
|
238
261
|
{ if: typeIs("file_search"), then: { $ref: "#/$defs/fileSearchTool" } },
|
|
239
262
|
{ if: typeIs("web_search"), then: { $ref: "#/$defs/webSearchTool" } },
|
|
263
|
+
{ if: typeIs("web_fetch"), then: { $ref: "#/$defs/webFetchTool" } },
|
|
240
264
|
{ if: typeIs("backbone.extraction"), then: { $ref: "#/$defs/extractionTool" } },
|
|
241
265
|
{ if: typeIs("backbone.document_convert"), then: { $ref: "#/$defs/documentConvertTool" } },
|
|
242
266
|
{ if: typeIs("backbone.todo"), then: { $ref: "#/$defs/todoTool" } },
|
|
@@ -8,6 +8,8 @@ const NULLABLE_KEYS = ["description", "models", "instructions", "tools", "hitlPo
|
|
|
8
8
|
const TOOL_RULE_CLASSES = new Set(["read", "write", "destructive"]);
|
|
9
9
|
/** web_search config keys whose explicit `null` the backend reads as absent. */
|
|
10
10
|
const WEB_SEARCH_NULLABLE_KEYS = ["source", "fallback", "searchProviderId", "allowedDomains"];
|
|
11
|
+
/** web_fetch config keys whose explicit `null` the backend reads as absent. */
|
|
12
|
+
const WEB_FETCH_NULLABLE_KEYS = ["source", "allowedDomains"];
|
|
11
13
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
14
|
/**
|
|
13
15
|
* A deep copy of `doc` in the form the backend actually checks: explicit nulls it treats as absent
|
|
@@ -34,7 +36,9 @@ export function normalizeForValidation(doc) {
|
|
|
34
36
|
if (isPlainObject(tool) && tool.annotations === null)
|
|
35
37
|
delete tool.annotations;
|
|
36
38
|
if (isPlainObject(tool) && tool.type === "web_search")
|
|
37
|
-
|
|
39
|
+
normalizeWebTool(tool, WEB_SEARCH_NULLABLE_KEYS);
|
|
40
|
+
if (isPlainObject(tool) && tool.type === "web_fetch")
|
|
41
|
+
normalizeWebTool(tool, WEB_FETCH_NULLABLE_KEYS);
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
const policy = copy.hitlPolicy;
|
|
@@ -55,13 +59,13 @@ export function normalizeForValidation(doc) {
|
|
|
55
59
|
return copy;
|
|
56
60
|
}
|
|
57
61
|
/** WebToolEntryValidator reads a null config and null config fields as absent, and trims each domain. */
|
|
58
|
-
function
|
|
62
|
+
function normalizeWebTool(tool, nullableKeys) {
|
|
59
63
|
if (tool.config === null)
|
|
60
64
|
delete tool.config;
|
|
61
65
|
const config = tool.config;
|
|
62
66
|
if (!isPlainObject(config))
|
|
63
67
|
return;
|
|
64
|
-
for (const key of
|
|
68
|
+
for (const key of nullableKeys) {
|
|
65
69
|
if (config[key] === null)
|
|
66
70
|
delete config[key];
|
|
67
71
|
}
|
|
@@ -125,6 +129,7 @@ export function validateAgentConfig(raw) {
|
|
|
125
129
|
}
|
|
126
130
|
if (Array.isArray(record.tools)) {
|
|
127
131
|
let webSearchSeen = false;
|
|
132
|
+
let webFetchSeen = false;
|
|
128
133
|
record.tools.forEach((tool, i) => {
|
|
129
134
|
const type = tool?.type;
|
|
130
135
|
if (typeof type === "string" && Object.hasOwn(NON_AUTHORABLE_TOOL_TYPES, type)) {
|
|
@@ -136,6 +141,11 @@ export function validateAgentConfig(raw) {
|
|
|
136
141
|
issues.push({ pointer: `/tools/${i}/type`, message: "an agent may carry only one web_search tool" });
|
|
137
142
|
webSearchSeen = true;
|
|
138
143
|
}
|
|
144
|
+
if (type === "web_fetch") {
|
|
145
|
+
if (webFetchSeen)
|
|
146
|
+
issues.push({ pointer: `/tools/${i}/type`, message: "an agent may carry only one web_fetch tool" });
|
|
147
|
+
webFetchSeen = true;
|
|
148
|
+
}
|
|
139
149
|
});
|
|
140
150
|
}
|
|
141
151
|
if (Array.isArray(record.skills)) {
|
|
@@ -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
|