@cozibud/harness 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -24
- package/dist/cli.js +108 -19
- package/dist/contracts.d.ts +4 -0
- package/dist/cursor-workspace.d.ts +3 -0
- package/dist/cursor-workspace.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pairing-secret.d.ts +8 -0
- package/dist/pairing-secret.js +34 -0
- package/dist/pairing.d.ts +14 -0
- package/dist/pairing.js +89 -0
- package/dist/process-driver.d.ts +2 -2
- package/dist/process-driver.js +202 -124
- package/dist/relay-client.d.ts +69 -0
- package/dist/relay-client.js +166 -0
- package/dist/relay-protocol.d.ts +18 -0
- package/dist/relay-protocol.js +3 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +15 -7
- package/package.json +43 -43
package/README.md
CHANGED
|
@@ -1,24 +1,54 @@
|
|
|
1
|
-
# `@cozibud/harness`
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
# `@cozibud/harness`
|
|
2
|
+
|
|
3
|
+
Connect a signed-in Codex CLI to your Cozibud household for watch voice.
|
|
4
|
+
Keep the computer awake and the outbound relay running while using voice.
|
|
5
|
+
Watch voice requires Codex CLI 0.159.0 or newer and a server supporting relay
|
|
6
|
+
protocol 2. This source version still needs its package and server release.
|
|
7
|
+
|
|
8
|
+
## Watch voice
|
|
9
|
+
|
|
10
|
+
After installing the released harness, sign in to Codex with `codex login`.
|
|
11
|
+
Run this single command:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
cozibud-harness relay --provider codex
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On the first run, open the displayed Cozibud link and approve your computer
|
|
18
|
+
in the household you use on your watch. The command then connects automatically.
|
|
19
|
+
Later runs reuse the saved pairing. Keep this command running and the computer awake.
|
|
20
|
+
If the watch finds no connected computer, **Open guide on phone** shows the setup steps.
|
|
21
|
+
|
|
22
|
+
On the watch, select the child, speak, and check the transcript. **Send & save**
|
|
23
|
+
authorizes the server to validate and save one record. Ambiguous requests ask
|
|
24
|
+
for clarification. A saved-record receipt appears only after server readback.
|
|
25
|
+
No microphone audio is sent to the bridge; the confirmed transcript and selected
|
|
26
|
+
child context go to your signed-in Codex client and its model service.
|
|
27
|
+
|
|
28
|
+
Your Codex login stays local. Pairing credentials are protected with your
|
|
29
|
+
Windows account's DPAPI on Windows, or a user-only file on other systems.
|
|
30
|
+
Disconnect the computer in Cozibud's Watch settings to revoke its access.
|
|
31
|
+
Each Cozibud account can pair two computers. The one connected longest answers
|
|
32
|
+
requests from the web app, the phone app and the watch; the other is a backup
|
|
33
|
+
that takes over when the first stops reporting in. Run `pair` again on a computer
|
|
34
|
+
to replace its pairing, or choose the household and account by signing in to the
|
|
35
|
+
one you want when you approve the link. Codex uses whichever account `codex login`
|
|
36
|
+
signed in (set `CODEX_HOME` to keep a separate Codex profile).
|
|
37
|
+
Use `COZIBUD_RELAY_URL` to choose another HTTPS server before pairing; pairings
|
|
38
|
+
are scoped to that origin. The relay requires no inbound computer port.
|
|
39
|
+
The separate local HTTP adapter must remain on loopback.
|
|
40
|
+
|
|
41
|
+
## Maintainer smoke check
|
|
42
|
+
|
|
43
|
+
After building or installing an explicitly reviewed version:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
cozibud-harness --help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Provider setup and request commands are internal acceptance tools. They use the
|
|
50
|
+
provider's own installed client and sign-in state; the harness does not accept
|
|
51
|
+
or store provider passwords.
|
|
52
|
+
|
|
53
|
+
Release and bootstrap procedures live in the private repository's
|
|
54
|
+
`docs/NPM-HARNESS-PUBLISHING.md` operator runbook.
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { ensureRelayPairing, pairRelay, relayOrigin } from "./pairing.js";
|
|
3
7
|
import { createBuiltInDrivers } from "./process-driver.js";
|
|
8
|
+
import { FileRelayExecutionStore, OutboundRelayBridge, RelayTransportError } from "./relay-client.js";
|
|
4
9
|
import { HarnessRuntime } from "./runtime.js";
|
|
5
10
|
import { createHarnessHttpServer } from "./server.js";
|
|
6
11
|
import { DEFAULT_COZIBUD_MCP_URL, setupProvider } from "./setup.js";
|
|
@@ -20,26 +25,96 @@ function promptFrom(args) {
|
|
|
20
25
|
return parts.join(" ").trim();
|
|
21
26
|
}
|
|
22
27
|
function printHelp() {
|
|
23
|
-
console.log(`Cozibud local LLM harness
|
|
24
|
-
|
|
25
|
-
Usage:
|
|
26
|
-
cozibud-harness setup [--provider codex|claude|gemini|cursor] [--mcp-url https://cozibud.com/api/mcp]
|
|
27
|
-
cozibud-harness ask [--provider codex|claude|gemini|cursor] [--locale en-US] "When was the last bottle?"
|
|
28
|
-
cozibud-harness
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
console.log(`Cozibud local LLM harness
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
cozibud-harness setup [--provider codex|claude|gemini|cursor] [--mcp-url https://cozibud.com/api/mcp]
|
|
32
|
+
cozibud-harness ask [--provider codex|claude|gemini|cursor] [--locale en-US] "When was the last bottle?"
|
|
33
|
+
cozibud-harness relay [--provider codex]
|
|
34
|
+
cozibud-harness pair [--name "My computer"]
|
|
35
|
+
cozibud-harness serve [--host 127.0.0.1] [--port 8798]
|
|
36
|
+
|
|
37
|
+
Quick start:
|
|
38
|
+
setup prepares a signed-in local client and the MCP access its bridge turns need
|
|
39
|
+
ask sends one typed request through an already signed-in provider
|
|
40
|
+
relay pairs this computer if needed, then keeps watch voice connected
|
|
41
|
+
|
|
42
|
+
Environment:
|
|
43
|
+
COZIBUD_HARNESS_TOKEN bearer token (generated for this run when omitted)
|
|
44
|
+
COZIBUD_HARNESS_ORIGINS comma-separated browser origins allowed by CORS
|
|
45
|
+
COZIBUD_RELAY_URL canonical Cozibud origin (defaults to https://cozibud.com)
|
|
46
|
+
COZIBUD_RELAY_CREDENTIAL optional paired credential override (never printed)
|
|
47
|
+
COZIBUD_RELAY_PAIRING protected pairing file path
|
|
48
|
+
COZIBUD_RELAY_STATE local idempotency journal path
|
|
49
|
+
COZIBUD_CODEX_PATH optional Codex executable path
|
|
50
|
+
COZIBUD_CLAUDE_PATH optional Claude executable path
|
|
51
|
+
COZIBUD_GEMINI_PATH optional Gemini executable path
|
|
52
|
+
COZIBUD_CURSOR_PATH optional Cursor Agent executable path
|
|
53
|
+
COZIBUD_CURSOR_WORKSPACE stable dedicated empty Cursor bridge workspace
|
|
41
54
|
COZIBUD_CURSOR_MCP_CONFIG optional Cursor user MCP config path (test/development override)`);
|
|
42
55
|
}
|
|
56
|
+
async function relay(args) {
|
|
57
|
+
const provider = value(args, "--provider", "codex");
|
|
58
|
+
const driver = createBuiltInDrivers().find((candidate) => candidate.id === provider);
|
|
59
|
+
if (!driver)
|
|
60
|
+
throw new Error(`Unknown provider: ${provider}. Choose codex, claude, gemini, or cursor.`);
|
|
61
|
+
if (provider !== "codex")
|
|
62
|
+
throw new Error("Watch voice currently requires Codex CLI.");
|
|
63
|
+
const baseUrl = relayOrigin(process.env.COZIBUD_RELAY_URL?.trim() || "https://cozibud.com");
|
|
64
|
+
const available = await driver.probe();
|
|
65
|
+
if (available.state === "unavailable")
|
|
66
|
+
throw new Error(available.message);
|
|
67
|
+
const version = available.version?.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
68
|
+
if (!version || (Number(version[1]) === 0 && Number(version[2]) < 159))
|
|
69
|
+
throw new Error("Watch voice requires Codex CLI 0.159.0 or newer. Update Codex, sign in, and retry.");
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
process.once("SIGINT", () => controller.abort());
|
|
72
|
+
process.once("SIGTERM", () => controller.abort());
|
|
73
|
+
let credential = process.env.COZIBUD_RELAY_CREDENTIAL?.trim() || await ensureRelayPairing({ origin: baseUrl,
|
|
74
|
+
name: value(args, "--name", hostname()), path: pairingPath(), write: console.log, signal: controller.signal });
|
|
75
|
+
const createBridge = () => {
|
|
76
|
+
const namespace = createHash("sha256").update(`${baseUrl}\n${credential}`).digest("hex").slice(0, 20);
|
|
77
|
+
const statePath = process.env.COZIBUD_RELAY_STATE?.trim() || join(homedir(), ".cozibud", `relay-state-${namespace}.json`);
|
|
78
|
+
return new OutboundRelayBridge({ baseUrl, credential, driver, store: new FileRelayExecutionStore(statePath) });
|
|
79
|
+
};
|
|
80
|
+
let bridge = createBridge();
|
|
81
|
+
let repaired = false;
|
|
82
|
+
let connected = false;
|
|
83
|
+
let backoffMs = 1_000;
|
|
84
|
+
while (!controller.signal.aborted) {
|
|
85
|
+
try {
|
|
86
|
+
if (!connected) {
|
|
87
|
+
await bridge.connect();
|
|
88
|
+
connected = true;
|
|
89
|
+
backoffMs = 1_000;
|
|
90
|
+
console.log(`Cozibud relay connected with ${driver.label}. No local or LAN listener is open.`);
|
|
91
|
+
}
|
|
92
|
+
const state = await bridge.step();
|
|
93
|
+
if (state === "idle")
|
|
94
|
+
await pause(1_000, controller.signal);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error instanceof RelayTransportError && [401, 403, 404].includes(error.status)) {
|
|
98
|
+
await bridge.close();
|
|
99
|
+
if ([401, 403].includes(error.status) && !process.env.COZIBUD_RELAY_CREDENTIAL?.trim() && !repaired && !controller.signal.aborted) {
|
|
100
|
+
repaired = true;
|
|
101
|
+
console.log("This computer's pairing is no longer valid. Approve it again to reconnect.");
|
|
102
|
+
credential = await pairRelay({ origin: baseUrl, name: value(args, "--name", hostname()), path: pairingPath(), write: console.log, signal: controller.signal });
|
|
103
|
+
bridge = createBridge();
|
|
104
|
+
connected = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throw new Error("Desktop pairing is expired, revoked, or disabled. Run pair again on the selected server.");
|
|
108
|
+
}
|
|
109
|
+
connected = false;
|
|
110
|
+
console.error("Cozibud relay disconnected; retrying the outbound connection.");
|
|
111
|
+
await pause(backoffMs, controller.signal);
|
|
112
|
+
backoffMs = Math.min(15_000, backoffMs * 2);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await bridge.close();
|
|
116
|
+
}
|
|
117
|
+
function pairingPath() { return process.env.COZIBUD_RELAY_PAIRING?.trim() || join(homedir(), ".cozibud", "relay-pairing.json"); }
|
|
43
118
|
async function ask(args) {
|
|
44
119
|
const provider = value(args, "--provider", "codex");
|
|
45
120
|
const locale = value(args, "--locale", "en-US");
|
|
@@ -118,6 +193,11 @@ try {
|
|
|
118
193
|
}
|
|
119
194
|
else if (command === "ask")
|
|
120
195
|
await ask(args);
|
|
196
|
+
else if (command === "relay")
|
|
197
|
+
await relay(args);
|
|
198
|
+
else if (command === "pair")
|
|
199
|
+
await pairRelay({ origin: process.env.COZIBUD_RELAY_URL || "https://cozibud.com",
|
|
200
|
+
name: value(args, "--name", hostname()), path: pairingPath(), write: console.log });
|
|
121
201
|
else if (command === "serve")
|
|
122
202
|
await serve(args);
|
|
123
203
|
else
|
|
@@ -127,3 +207,12 @@ catch (error) {
|
|
|
127
207
|
console.error(error instanceof Error ? error.message : "The local harness failed.");
|
|
128
208
|
process.exitCode = 1;
|
|
129
209
|
}
|
|
210
|
+
function pause(ms, signal) {
|
|
211
|
+
return new Promise(resolve => {
|
|
212
|
+
const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); };
|
|
213
|
+
const timer = setTimeout(done, ms);
|
|
214
|
+
signal.addEventListener("abort", done, { once: true });
|
|
215
|
+
if (signal.aborted)
|
|
216
|
+
done();
|
|
217
|
+
});
|
|
218
|
+
}
|
package/dist/contracts.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface HarnessTurnInput {
|
|
|
7
7
|
source: HarnessInputSource;
|
|
8
8
|
client: HarnessClient;
|
|
9
9
|
locale?: string;
|
|
10
|
+
/** Server request-bound identity that every care-record write must use. */
|
|
11
|
+
careRecordIdempotencyKey?: string;
|
|
12
|
+
/** Server-supplied interpretation instructions; no MCP writes in this mode. */
|
|
13
|
+
interpretation?: string;
|
|
10
14
|
}
|
|
11
15
|
export interface HarnessTurnRequest extends HarnessTurnInput {
|
|
12
16
|
harness: string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
|
+
export const DEFAULT_CURSOR_WORKSPACE = join(homedir(), ".cozibud", "cursor-workspace");
|
|
5
|
+
export function cursorWorkspacePath(configured = process.env.COZIBUD_CURSOR_WORKSPACE) {
|
|
6
|
+
const override = configured?.trim();
|
|
7
|
+
if (override && !isAbsolute(override))
|
|
8
|
+
throw new Error("COZIBUD_CURSOR_WORKSPACE must be an absolute path.");
|
|
9
|
+
const candidate = override || DEFAULT_CURSOR_WORKSPACE;
|
|
10
|
+
return resolve(candidate);
|
|
11
|
+
}
|
|
12
|
+
export function ensureCursorWorkspace(configured) {
|
|
13
|
+
const workspace = cursorWorkspacePath(configured);
|
|
14
|
+
const root = parse(workspace).root;
|
|
15
|
+
const current = resolve(process.cwd());
|
|
16
|
+
const isWithin = (parent, candidate) => {
|
|
17
|
+
const path = relative(parent, candidate);
|
|
18
|
+
return path !== "" && !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`);
|
|
19
|
+
};
|
|
20
|
+
if (workspace === root || workspace === current || isWithin(current, workspace) || isWithin(workspace, current)) {
|
|
21
|
+
throw new Error("Cursor bridge workspace must be dedicated and outside the current repository or worktree.");
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const state = lstatSync(workspace);
|
|
25
|
+
if (state.isSymbolicLink() || !state.isDirectory()) {
|
|
26
|
+
throw new Error("Cursor bridge workspace must be a real directory, not a file or symbolic link.");
|
|
27
|
+
}
|
|
28
|
+
if (readdirSync(workspace).length !== 0) {
|
|
29
|
+
throw new Error("Cursor bridge workspace must be empty and dedicated to Cozibud.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT")
|
|
34
|
+
throw error;
|
|
35
|
+
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
|
36
|
+
}
|
|
37
|
+
chmodSync(workspace, 0o700);
|
|
38
|
+
return workspace;
|
|
39
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Windows DPAPI binds the pairing secret to the signed-in OS user. */
|
|
4
|
+
async function dpapi(value, decrypt) {
|
|
5
|
+
const script = [
|
|
6
|
+
"Add-Type -AssemblyName System.Security;",
|
|
7
|
+
"$inputValue = [Console]::In.ReadToEnd();",
|
|
8
|
+
decrypt
|
|
9
|
+
? "$bytes = [Convert]::FromBase64String($inputValue); $plain = [Security.Cryptography.ProtectedData]::Unprotect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($plain));"
|
|
10
|
+
: "$bytes = [Text.Encoding]::UTF8.GetBytes($inputValue); $protected = [Security.Cryptography.ProtectedData]::Protect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Convert]::ToBase64String($protected));",
|
|
11
|
+
].join("\n");
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, stdio: ["pipe", "pipe", "ignore"] });
|
|
14
|
+
let output = "";
|
|
15
|
+
const timer = setTimeout(() => child.kill(), 10_000);
|
|
16
|
+
child.stdout.on("data", chunk => { output += chunk.toString(); });
|
|
17
|
+
child.stdin.on("error", () => { });
|
|
18
|
+
child.once("error", () => { clearTimeout(timer); reject(new Error("Could not access the protected desktop pairing.")); });
|
|
19
|
+
child.once("close", code => { clearTimeout(timer); code === 0 ? resolve(output) : reject(new Error("Could not access the protected desktop pairing. Pair again on this computer.")); });
|
|
20
|
+
// Plaintext never appears in argv, environment values or diagnostics.
|
|
21
|
+
child.stdin.end(value);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export async function protectPairing(value) {
|
|
25
|
+
return process.platform === "win32" ? { protection: "dpapi", value: await dpapi(value, false) }
|
|
26
|
+
: { protection: "user-file", value };
|
|
27
|
+
}
|
|
28
|
+
export async function unprotectPairing(row) {
|
|
29
|
+
if (row.protection === "dpapi" && process.platform === "win32")
|
|
30
|
+
return dpapi(row.value, true);
|
|
31
|
+
if (row.protection === "user-file" && process.platform !== "win32")
|
|
32
|
+
return row.value;
|
|
33
|
+
throw new Error("This pairing cannot be used on this computer. Run pair again.");
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function relayOrigin(value: string): string;
|
|
2
|
+
export declare function readPairing(path: string, origin: string): Promise<string | null>;
|
|
3
|
+
export declare function pairRelay(options: {
|
|
4
|
+
origin: string;
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
write: (message: string) => void;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
wait?: (ms: number) => Promise<void>;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
/** Relay is also the first-run setup command; approval remains in the browser. */
|
|
14
|
+
export declare function ensureRelayPairing(options: Parameters<typeof pairRelay>[0]): Promise<string>;
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { protectPairing, unprotectPairing } from "./pairing-secret.js";
|
|
4
|
+
export function relayOrigin(value) {
|
|
5
|
+
const url = new URL(value);
|
|
6
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
|
|
7
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
|
|
8
|
+
throw new Error("Use an HTTPS server origin, or HTTP on loopback for local testing.");
|
|
9
|
+
}
|
|
10
|
+
return url.origin;
|
|
11
|
+
}
|
|
12
|
+
export async function readPairing(path, origin) {
|
|
13
|
+
try {
|
|
14
|
+
const row = JSON.parse(await unprotectPairing(JSON.parse(await readFile(path, "utf8"))));
|
|
15
|
+
if (row.origin !== relayOrigin(origin) || !Number.isFinite(Date.parse(row.expiresAt)) || Date.parse(row.expiresAt) <= Date.now())
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof row.credential !== "string" || !row.credential.startsWith("cbr_"))
|
|
18
|
+
throw new Error("Invalid local pairing. Run pair again.");
|
|
19
|
+
return row.credential;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function pairRelay(options) {
|
|
28
|
+
const origin = relayOrigin(options.origin), fetcher = options.fetchImpl ?? fetch;
|
|
29
|
+
const now = options.now ?? Date.now;
|
|
30
|
+
const wait = options.wait ?? (ms => new Promise((resolve, reject) => {
|
|
31
|
+
const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); };
|
|
32
|
+
const done = () => { cleanup(); resolve(); };
|
|
33
|
+
const abort = () => { cleanup(); reject(options.signal?.reason); };
|
|
34
|
+
const timer = setTimeout(done, ms);
|
|
35
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
36
|
+
if (options.signal?.aborted)
|
|
37
|
+
abort();
|
|
38
|
+
}));
|
|
39
|
+
const post = (path, body) => fetcher(`${origin}${path}`, { method: "POST", redirect: "error",
|
|
40
|
+
headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000) });
|
|
41
|
+
const response = await post("/api/watch-relay/v1/bridge-authorizations", { bridgeName: options.name });
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw new Error(response.status === 404 ? "Watch voice is not enabled on this server yet." : "Could not start desktop pairing. Try again later.");
|
|
44
|
+
const authorization = await response.json();
|
|
45
|
+
if (!authorization.device_code || !authorization.user_code || !Number.isFinite(authorization.expires_in) || !Number.isFinite(authorization.interval))
|
|
46
|
+
throw new Error("Invalid pairing response.");
|
|
47
|
+
const activation = new URL("/watch/activate", origin);
|
|
48
|
+
activation.searchParams.set("bridge", "1");
|
|
49
|
+
activation.searchParams.set("code", authorization.user_code);
|
|
50
|
+
options.write(`Open ${activation.href}\nSign in to your Cozibud household and approve ${options.name}.`);
|
|
51
|
+
const deadline = now() + authorization.expires_in * 1000;
|
|
52
|
+
let interval = Math.max(5, authorization.interval);
|
|
53
|
+
while (now() < deadline) {
|
|
54
|
+
await wait(interval * 1000);
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
if (now() >= deadline)
|
|
57
|
+
break;
|
|
58
|
+
const exchange = await post("/api/watch-relay/v1/token", { deviceCode: authorization.device_code });
|
|
59
|
+
const result = await exchange.json();
|
|
60
|
+
if (exchange.ok && result.credential?.startsWith("cbr_") && Number.isFinite(result.expiresIn)) {
|
|
61
|
+
await mkdir(dirname(options.path), { recursive: true, mode: 0o700 });
|
|
62
|
+
const protectedPairing = await protectPairing(JSON.stringify({ origin, credential: result.credential, bridgeId: result.bridgeId,
|
|
63
|
+
expiresAt: new Date(now() + result.expiresIn * 1000).toISOString() }));
|
|
64
|
+
await writeFile(options.path, JSON.stringify(protectedPairing), { mode: 0o600 });
|
|
65
|
+
if (process.platform !== "win32")
|
|
66
|
+
await chmod(options.path, 0o600);
|
|
67
|
+
options.write("Desktop paired.");
|
|
68
|
+
return result.credential;
|
|
69
|
+
}
|
|
70
|
+
if (result.error?.code === "authorization_pending")
|
|
71
|
+
continue;
|
|
72
|
+
if (result.error?.code === "slow_down" || exchange.status === 429) {
|
|
73
|
+
interval += 5;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (result.error?.code === "bridge_limit")
|
|
77
|
+
throw new Error("Your account already has two computers paired. Disconnect one in Cozibud under Settings → Assistants & API → Voice with a local agent, then run pair again.");
|
|
78
|
+
throw new Error("Pairing was denied or expired. Run pair again.");
|
|
79
|
+
}
|
|
80
|
+
throw new Error("Pairing expired. Run pair again.");
|
|
81
|
+
}
|
|
82
|
+
/** Relay is also the first-run setup command; approval remains in the browser. */
|
|
83
|
+
export async function ensureRelayPairing(options) {
|
|
84
|
+
const saved = await readPairing(options.path, options.origin);
|
|
85
|
+
if (saved)
|
|
86
|
+
return saved;
|
|
87
|
+
options.write("This computer needs pairing. Approve it in Cozibud; the relay will start automatically.");
|
|
88
|
+
return pairRelay(options);
|
|
89
|
+
}
|
package/dist/process-driver.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HarnessDriver } from "./contracts.js";
|
|
1
|
+
import type { HarnessDriver, HarnessTurnInput } from "./contracts.js";
|
|
2
2
|
declare const TEMP_DIRECTORY_CLEANUP_OPTIONS: {
|
|
3
3
|
readonly recursive: true;
|
|
4
4
|
readonly force: true;
|
|
@@ -13,7 +13,7 @@ export interface ProcessHarnessDefinition {
|
|
|
13
13
|
versionArgs?: string[];
|
|
14
14
|
/** Applied only to a fresh empty directory created by this driver. */
|
|
15
15
|
emptyDirectoryArgs?: string[];
|
|
16
|
-
args(): string[];
|
|
16
|
+
args(input?: HarnessTurnInput): string[];
|
|
17
17
|
}
|
|
18
18
|
declare function messageText(value: unknown): string | null;
|
|
19
19
|
declare function appendNovelText(current: string, candidate: string): {
|