harnery 0.9.0 → 0.11.0
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 +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +6 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +53 -17
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +4 -2
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +11 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +3 -3
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +191 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +280 -63
- package/dist/core/hooks/events/schema.d.ts +38 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +3 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +1 -1
- package/dist/core/workflow/billing.d.ts.map +1 -1
- package/dist/core/workflow/billing.js +7 -1
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +164 -16
- package/dist/core/workflow/harnesses.d.ts +3 -3
- package/dist/core/workflow/harnesses.d.ts.map +1 -1
- package/dist/core/workflow/harnesses.js +8 -17
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +42 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +342 -0
- package/dist/core/workflow/spawn-claude.d.ts +4 -1
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +39 -18
- package/dist/core/workflow/spawn-codex.d.ts +4 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +44 -22
- package/dist/core/workflow/spawn-cursor.d.ts +4 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +35 -16
- package/dist/core/workflow/types.d.ts +156 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +22 -6
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +6 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +2 -3
- package/src/commands/eml.ts +22 -4
- package/src/commands/harness.ts +147 -0
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +62 -19
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +4 -2
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +10 -1
- package/src/core/agents/state/heartbeat-writer.ts +3 -2
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +248 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +331 -62
- package/src/core/hooks/events/schema.ts +68 -0
- package/src/core/hooks/harness/events.ts +3 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +8 -2
- package/src/core/workflow/engine.ts +182 -16
- package/src/core/workflow/harnesses.ts +14 -19
- package/src/core/workflow/index.ts +43 -0
- package/src/core/workflow/proof.ts +434 -0
- package/src/core/workflow/spawn-claude.ts +39 -20
- package/src/core/workflow/spawn-codex.ts +47 -28
- package/src/core/workflow/spawn-cursor.ts +37 -18
- package/src/core/workflow/types.ts +176 -2
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-client.d.ts","sourceRoot":"","sources":["../../../src/core/presence/relay-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAwFH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAwBzD;AAED,sEAAsE;AACtE,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqKvE;AAED,0DAA0D;AAC1D,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAMA"}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay client transport for cross-machine presence (ADR 0016, phase 2c).
|
|
3
|
+
*
|
|
4
|
+
* The hooks are short-lived processes, so a live WebSocket needs a home: a
|
|
5
|
+
* small per-repo-per-machine DAEMON that the hooks lazy-start
|
|
6
|
+
* (`ensureRelayDaemon`) and that exits itself when the machine goes idle.
|
|
7
|
+
* The daemon:
|
|
8
|
+
*
|
|
9
|
+
* - derives the room credentials from the repo identity (root-commit SHA +
|
|
10
|
+
* origin URL + optional committed `.harnery/presence-salt`)
|
|
11
|
+
* - holds the socket open with auto-reconnect + jittered backoff
|
|
12
|
+
* - watches `.harnery/active/` and publishes the encrypted presence blob on
|
|
13
|
+
* change (debounced), plus a 60s keepalive so peers' staleness math works
|
|
14
|
+
* - decrypts received peer frames and writes them to
|
|
15
|
+
* `.harnery/presence/remote/<machine>.json`, where `readRemoteMachines`
|
|
16
|
+
* merges them with the git-refs floor (freshest source per machine wins)
|
|
17
|
+
* - exits after ~5 minutes with no live local sessions (hooks restart it
|
|
18
|
+
* when sessions return), and exits if its pid file is superseded
|
|
19
|
+
*
|
|
20
|
+
* Everything fails silent; the git-refs transport is always the floor.
|
|
21
|
+
* Requires a global WebSocket (Bun, Node >= 21, Workers) — on older Nodes the
|
|
22
|
+
* daemon refuses to start and presence stays on git-refs.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, watch, writeFileSync, } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { resolveMachineLabel } from "../../lib/machine.js";
|
|
28
|
+
import { presenceRelayUrl } from "../config.js";
|
|
29
|
+
import { buildPresenceBlob } from "./blob.js";
|
|
30
|
+
import { originUrl, rootCommitSha, sanitizeRefComponent } from "./git.js";
|
|
31
|
+
import { computeSenderId, decryptPayload, deriveRoomCredentials, encryptPayload, parseRelayFrame, } from "./relay-protocol.js";
|
|
32
|
+
const KEEPALIVE_MS = 60_000;
|
|
33
|
+
const WATCH_DEBOUNCE_MS = 500;
|
|
34
|
+
const IDLE_EXIT_MS = 5 * 60_000;
|
|
35
|
+
const BACKOFF_MIN_MS = 1_000;
|
|
36
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
37
|
+
function presenceDir(coordRoot) {
|
|
38
|
+
return join(coordRoot, ".harnery", "presence");
|
|
39
|
+
}
|
|
40
|
+
function remoteCacheDir(coordRoot) {
|
|
41
|
+
return join(presenceDir(coordRoot), "remote");
|
|
42
|
+
}
|
|
43
|
+
function pidFilePath(coordRoot) {
|
|
44
|
+
return join(presenceDir(coordRoot), "relay-daemon.json");
|
|
45
|
+
}
|
|
46
|
+
function logFilePath(coordRoot) {
|
|
47
|
+
return join(presenceDir(coordRoot), "relay-daemon.log");
|
|
48
|
+
}
|
|
49
|
+
function readPidRecord(coordRoot) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(pidFilePath(coordRoot), "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function pidAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The committed rotatable room salt, when the repo minted one. */
|
|
67
|
+
function readRoomSalt(coordRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const s = readFileSync(join(coordRoot, ".harnery", "presence-salt"), "utf8").trim();
|
|
70
|
+
return s || undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function atomicWriteJson(path, value) {
|
|
77
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
78
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(value), "utf8");
|
|
80
|
+
renameSync(tmp, path);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lazy-start the relay daemon for this repo if a relay is configured and no
|
|
84
|
+
* live daemon already owns the pid file. Called from the hook path — must
|
|
85
|
+
* return fast and never throw.
|
|
86
|
+
*/
|
|
87
|
+
export function ensureRelayDaemon(coordRoot) {
|
|
88
|
+
try {
|
|
89
|
+
const url = presenceRelayUrl(coordRoot);
|
|
90
|
+
if (!url)
|
|
91
|
+
return;
|
|
92
|
+
const rec = readPidRecord(coordRoot);
|
|
93
|
+
if (rec && rec.url === url && pidAlive(rec.pid))
|
|
94
|
+
return;
|
|
95
|
+
// Resolve the harn bin at the package root (works from src/ under Bun and
|
|
96
|
+
// dist/ under Node — both sit two levels below the root).
|
|
97
|
+
const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
|
|
98
|
+
if (!existsSync(harnBin))
|
|
99
|
+
return;
|
|
100
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
101
|
+
const log = openSync(logFilePath(coordRoot), "a");
|
|
102
|
+
const child = spawn(harnBin, ["presence", "relay-daemon"], {
|
|
103
|
+
cwd: coordRoot,
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: ["ignore", log, log],
|
|
106
|
+
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
107
|
+
});
|
|
108
|
+
child.unref();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* best-effort */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Foreground daemon loop (spawned detached by ensureRelayDaemon). */
|
|
115
|
+
export async function runRelayDaemon(coordRoot) {
|
|
116
|
+
const url = presenceRelayUrl(coordRoot);
|
|
117
|
+
if (!url) {
|
|
118
|
+
console.error("relay-daemon: no relay configured (presence.relay)");
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
if (typeof WebSocket === "undefined") {
|
|
122
|
+
console.error("relay-daemon: no global WebSocket in this runtime (need Bun or Node >= 21)");
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const sha = rootCommitSha(coordRoot);
|
|
126
|
+
const origin = originUrl(coordRoot);
|
|
127
|
+
if (!sha || !origin) {
|
|
128
|
+
console.error("relay-daemon: repo has no commits or no origin remote");
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
// Take (or take over) the pid file. A superseded daemon notices and exits.
|
|
132
|
+
const myRecord = {
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
url,
|
|
135
|
+
started_at: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
atomicWriteJson(pidFilePath(coordRoot), myRecord);
|
|
138
|
+
const creds = await deriveRoomCredentials({
|
|
139
|
+
rootCommitSha: sha,
|
|
140
|
+
originUrl: origin,
|
|
141
|
+
salt: readRoomSalt(coordRoot),
|
|
142
|
+
});
|
|
143
|
+
const machine = sanitizeRefComponent(resolveMachineLabel());
|
|
144
|
+
const senderId = await computeSenderId(creds, machine);
|
|
145
|
+
const wsUrl = `${url.replace(/\/+$/, "")}/v1/room/${creds.roomId}`;
|
|
146
|
+
let ws = null;
|
|
147
|
+
let backoff = BACKOFF_MIN_MS;
|
|
148
|
+
let lastBasisHash = "";
|
|
149
|
+
let lastSentAt = 0;
|
|
150
|
+
let idleSince = null;
|
|
151
|
+
let shuttingDown = false;
|
|
152
|
+
const log = (msg) => {
|
|
153
|
+
console.log(`[${new Date().toISOString()}] ${msg}`);
|
|
154
|
+
};
|
|
155
|
+
const publishNow = async (force = false) => {
|
|
156
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
|
|
160
|
+
const keepaliveDue = Date.now() - lastSentAt >= KEEPALIVE_MS;
|
|
161
|
+
if (!force && basisHash === lastBasisHash && !keepaliveDue)
|
|
162
|
+
return;
|
|
163
|
+
const enc = await encryptPayload(creds, json);
|
|
164
|
+
ws.send(JSON.stringify({ t: "pub", sender: senderId, ...enc }));
|
|
165
|
+
lastBasisHash = basisHash;
|
|
166
|
+
lastSentAt = Date.now();
|
|
167
|
+
idleSince = blob.agents.length === 0 ? (idleSince ?? Date.now()) : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* next trigger retries */
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const handleFrame = async (raw) => {
|
|
174
|
+
const frame = parseRelayFrame(raw);
|
|
175
|
+
if (frame?.t !== "pub")
|
|
176
|
+
return;
|
|
177
|
+
if (frame.sender === senderId)
|
|
178
|
+
return; // our own warm-join echo
|
|
179
|
+
const pt = await decryptPayload(creds, frame);
|
|
180
|
+
if (!pt)
|
|
181
|
+
return;
|
|
182
|
+
let blob;
|
|
183
|
+
try {
|
|
184
|
+
blob = JSON.parse(pt);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (blob?.v !== 1 || typeof blob.machine !== "string")
|
|
190
|
+
return;
|
|
191
|
+
const m = sanitizeRefComponent(blob.machine);
|
|
192
|
+
if (m === machine)
|
|
193
|
+
return;
|
|
194
|
+
try {
|
|
195
|
+
atomicWriteJson(join(remoteCacheDir(coordRoot), `${m}.json`), blob);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* best-effort */
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const connect = () => {
|
|
202
|
+
if (shuttingDown)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
const sock = new WebSocket(wsUrl);
|
|
206
|
+
ws = sock;
|
|
207
|
+
sock.onopen = () => {
|
|
208
|
+
backoff = BACKOFF_MIN_MS;
|
|
209
|
+
log(`connected room=${creds.roomId.slice(0, 8)}… as ${machine}`);
|
|
210
|
+
void publishNow(true);
|
|
211
|
+
};
|
|
212
|
+
sock.onmessage = (ev) => {
|
|
213
|
+
void handleFrame(String(ev.data));
|
|
214
|
+
};
|
|
215
|
+
const scheduleReconnect = () => {
|
|
216
|
+
if (shuttingDown)
|
|
217
|
+
return;
|
|
218
|
+
const jitter = Math.random() * backoff * 0.3;
|
|
219
|
+
const delay = Math.min(BACKOFF_MAX_MS, backoff) + jitter;
|
|
220
|
+
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
|
|
221
|
+
setTimeout(connect, delay);
|
|
222
|
+
};
|
|
223
|
+
sock.onclose = scheduleReconnect;
|
|
224
|
+
sock.onerror = () => {
|
|
225
|
+
try {
|
|
226
|
+
sock.close();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* ignore */
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
setTimeout(connect, backoff);
|
|
235
|
+
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
connect();
|
|
239
|
+
// Publish on heartbeat change (debounced).
|
|
240
|
+
let debounce = null;
|
|
241
|
+
const activeDir = join(coordRoot, ".harnery", "active");
|
|
242
|
+
mkdirSync(activeDir, { recursive: true });
|
|
243
|
+
try {
|
|
244
|
+
watch(activeDir, () => {
|
|
245
|
+
if (debounce)
|
|
246
|
+
clearTimeout(debounce);
|
|
247
|
+
debounce = setTimeout(() => void publishNow(), WATCH_DEBOUNCE_MS);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* keepalive interval still covers publication */
|
|
252
|
+
}
|
|
253
|
+
// Keepalive + lifecycle checks.
|
|
254
|
+
const interval = setInterval(() => {
|
|
255
|
+
void publishNow();
|
|
256
|
+
// Superseded by a newer daemon → exit quietly.
|
|
257
|
+
const rec = readPidRecord(coordRoot);
|
|
258
|
+
if (!rec || rec.pid !== process.pid) {
|
|
259
|
+
log("pid file superseded; exiting");
|
|
260
|
+
shuttingDown = true;
|
|
261
|
+
clearInterval(interval);
|
|
262
|
+
try {
|
|
263
|
+
ws?.close();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
// Idle machine (no live sessions for a while) → exit; hooks restart us.
|
|
271
|
+
if (idleSince && Date.now() - idleSince > IDLE_EXIT_MS) {
|
|
272
|
+
log("no live sessions; idle exit");
|
|
273
|
+
shuttingDown = true;
|
|
274
|
+
clearInterval(interval);
|
|
275
|
+
try {
|
|
276
|
+
rmSync(pidFilePath(coordRoot), { force: true });
|
|
277
|
+
ws?.close();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* ignore */
|
|
281
|
+
}
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
}, KEEPALIVE_MS);
|
|
285
|
+
// Never resolves in normal operation; exits happen above.
|
|
286
|
+
return await new Promise(() => { });
|
|
287
|
+
}
|
|
288
|
+
/** Daemon status for `presence peers`/doctor surfaces. */
|
|
289
|
+
export function relayDaemonStatus(coordRoot) {
|
|
290
|
+
const rec = readPidRecord(coordRoot);
|
|
291
|
+
if (rec && pidAlive(rec.pid)) {
|
|
292
|
+
return { running: true, pid: rec.pid, url: rec.url, started_at: rec.started_at };
|
|
293
|
+
}
|
|
294
|
+
return { running: false };
|
|
295
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay protocol for cross-machine presence (ADR 0016, phase 2): the shared,
|
|
3
|
+
* host-agnostic logic both relay hosts (the Cloudflare Durable Objects worker
|
|
4
|
+
* and `harn relay serve`) and the client transport build on.
|
|
5
|
+
*
|
|
6
|
+
* Capability-based rooms: the room id and the E2E symmetric key are BOTH
|
|
7
|
+
* derived client-side via HKDF-SHA256 from (repo root-commit SHA, normalized
|
|
8
|
+
* origin URL, a rotatable salt). Anyone who can read the repo can derive them;
|
|
9
|
+
* nobody else can — including the relay operator, who sees only an opaque
|
|
10
|
+
* room id, opaque sender ids, and AES-GCM ciphertext. Rotating the salt
|
|
11
|
+
* rotates the room.
|
|
12
|
+
*
|
|
13
|
+
* The committed salt is NOT a secret and must never be treated as one: the
|
|
14
|
+
* derivation needs the root-commit SHA + origin URL too, which only
|
|
15
|
+
* repo-readers have. Repo access is the trust boundary (same as the git-refs
|
|
16
|
+
* transport).
|
|
17
|
+
*
|
|
18
|
+
* Wire format (JSON text frames over WebSocket):
|
|
19
|
+
* client → relay: { t: "pub", sender, iv, ct } broadcast + cache
|
|
20
|
+
* relay → client: { t: "pub", sender, iv, ct } live or warm-join replay
|
|
21
|
+
* relay → client: { t: "hello", peers } on join (peer count)
|
|
22
|
+
* The relay never sees plaintext: `ct` is AES-GCM over the presence-blob JSON,
|
|
23
|
+
* `sender` is an HMAC-derived opaque id, `iv` is the per-message nonce.
|
|
24
|
+
*
|
|
25
|
+
* Uses WebCrypto only (globalThis.crypto.subtle) so the same module runs on
|
|
26
|
+
* Bun, Node ≥ 20, and Cloudflare Workers.
|
|
27
|
+
*/
|
|
28
|
+
/** Default derivation salt when a repo hasn't minted `.harnery/presence-salt`.
|
|
29
|
+
* Public by design (see module docs); committing a random per-repo salt just
|
|
30
|
+
* rotates the room away from anything derived before. */
|
|
31
|
+
export declare const DEFAULT_ROOM_SALT = "harnery-presence-salt/v1";
|
|
32
|
+
export interface RoomInputs {
|
|
33
|
+
/** SHA of the repo's root commit (`git rev-list --max-parents=0 HEAD`,
|
|
34
|
+
* first line). Stable for the repo's whole life. */
|
|
35
|
+
rootCommitSha: string;
|
|
36
|
+
/** The origin remote URL, any format — normalized internally so ssh and
|
|
37
|
+
* https clones of the same repo land in the same room. */
|
|
38
|
+
originUrl: string;
|
|
39
|
+
/** Rotatable room salt (default DEFAULT_ROOM_SALT). */
|
|
40
|
+
salt?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RoomCredentials {
|
|
43
|
+
/** Opaque 32-hex-char room id — the only room identity the relay sees. */
|
|
44
|
+
roomId: string;
|
|
45
|
+
/** AES-GCM-256 key for payload E2E encryption. Never leaves the client. */
|
|
46
|
+
key: CryptoKey;
|
|
47
|
+
/** Raw bytes for deriving per-machine opaque sender ids. */
|
|
48
|
+
senderKeyBytes: Uint8Array;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Normalize a git remote URL so every clone shape of the same repo derives
|
|
52
|
+
* the same room: `git@github.com:Org/Repo.git`, `ssh://git@github.com/org/repo`,
|
|
53
|
+
* and `https://github.com/org/repo.git` all → `github.com/org/repo`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function normalizeOriginUrl(raw: string): string;
|
|
56
|
+
/** Derive the room id, E2E key, and sender-id key from the repo identity. */
|
|
57
|
+
export declare function deriveRoomCredentials(inputs: RoomInputs): Promise<RoomCredentials>;
|
|
58
|
+
/** Opaque, stable per-machine sender id: HMAC(senderKey, machineLabel). The
|
|
59
|
+
* relay caches last-message-per-sender by this without learning the label. */
|
|
60
|
+
export declare function computeSenderId(creds: RoomCredentials, machineLabel: string): Promise<string>;
|
|
61
|
+
/** AES-GCM-encrypt a plaintext payload → { iv, ct } base64 pair. */
|
|
62
|
+
export declare function encryptPayload(creds: RoomCredentials, plaintext: string): Promise<{
|
|
63
|
+
iv: string;
|
|
64
|
+
ct: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** Decrypt an { iv, ct } pair. Returns null on any failure (wrong room key,
|
|
67
|
+
* tampered frame) — callers drop the frame silently. */
|
|
68
|
+
export declare function decryptPayload(creds: RoomCredentials, frame: {
|
|
69
|
+
iv: string;
|
|
70
|
+
ct: string;
|
|
71
|
+
}): Promise<string | null>;
|
|
72
|
+
/** Client → relay and relay → client publication frame. */
|
|
73
|
+
export interface PubFrame {
|
|
74
|
+
t: "pub";
|
|
75
|
+
/** Opaque sender id (computeSenderId). The relay keys its warm-join cache on this. */
|
|
76
|
+
sender: string;
|
|
77
|
+
iv: string;
|
|
78
|
+
ct: string;
|
|
79
|
+
}
|
|
80
|
+
/** Relay → client greeting on join. */
|
|
81
|
+
export interface HelloFrame {
|
|
82
|
+
t: "hello";
|
|
83
|
+
/** Live sockets in the room at join time (including the joiner). */
|
|
84
|
+
peers: number;
|
|
85
|
+
}
|
|
86
|
+
export type RelayFrame = PubFrame | HelloFrame;
|
|
87
|
+
/** Hard cap a relay enforces per text frame (ciphertext of a ~2KB blob is far
|
|
88
|
+
* smaller; anything bigger is abuse or a bug). */
|
|
89
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
90
|
+
/** Parse + shape-validate an incoming relay frame. Null on anything off. */
|
|
91
|
+
export declare function parseRelayFrame(raw: string): RelayFrame | null;
|
|
92
|
+
/** Room ids are 32 lowercase hex chars; relays reject anything else. */
|
|
93
|
+
export declare function isValidRoomId(roomId: string): boolean;
|
|
94
|
+
//# sourceMappingURL=relay-protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../../../src/core/presence/relay-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH;;yDAEyD;AACzD,eAAO,MAAM,iBAAiB,6BAA6B,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB;wDACoD;IACpD,aAAa,EAAE,MAAM,CAAC;IACtB;8DAC0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,GAAG,EAAE,SAAS,CAAC;IACf,4DAA4D;IAC5D,cAAc,EAAE,UAAU,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBtD;AAyBD,6EAA6E;AAC7E,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CAgBxF;AAED;8EAC8E;AAC9E,wBAAsB,eAAe,CACnC,KAAK,EAAE,eAAe,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAeD,oEAAoE;AACpE,wBAAsB,cAAc,CAClC,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAQrC;AAED;wDACwD;AACxD,wBAAsB,cAAc,CAClC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWxB;AAID,2DAA2D;AAC3D,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,KAAK,CAAC;IACT,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,uCAAuC;AACvC,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,OAAO,CAAC;IACX,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE/C;kDACkD;AAClD,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B9D;AAED,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAErD"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay protocol for cross-machine presence (ADR 0016, phase 2): the shared,
|
|
3
|
+
* host-agnostic logic both relay hosts (the Cloudflare Durable Objects worker
|
|
4
|
+
* and `harn relay serve`) and the client transport build on.
|
|
5
|
+
*
|
|
6
|
+
* Capability-based rooms: the room id and the E2E symmetric key are BOTH
|
|
7
|
+
* derived client-side via HKDF-SHA256 from (repo root-commit SHA, normalized
|
|
8
|
+
* origin URL, a rotatable salt). Anyone who can read the repo can derive them;
|
|
9
|
+
* nobody else can — including the relay operator, who sees only an opaque
|
|
10
|
+
* room id, opaque sender ids, and AES-GCM ciphertext. Rotating the salt
|
|
11
|
+
* rotates the room.
|
|
12
|
+
*
|
|
13
|
+
* The committed salt is NOT a secret and must never be treated as one: the
|
|
14
|
+
* derivation needs the root-commit SHA + origin URL too, which only
|
|
15
|
+
* repo-readers have. Repo access is the trust boundary (same as the git-refs
|
|
16
|
+
* transport).
|
|
17
|
+
*
|
|
18
|
+
* Wire format (JSON text frames over WebSocket):
|
|
19
|
+
* client → relay: { t: "pub", sender, iv, ct } broadcast + cache
|
|
20
|
+
* relay → client: { t: "pub", sender, iv, ct } live or warm-join replay
|
|
21
|
+
* relay → client: { t: "hello", peers } on join (peer count)
|
|
22
|
+
* The relay never sees plaintext: `ct` is AES-GCM over the presence-blob JSON,
|
|
23
|
+
* `sender` is an HMAC-derived opaque id, `iv` is the per-message nonce.
|
|
24
|
+
*
|
|
25
|
+
* Uses WebCrypto only (globalThis.crypto.subtle) so the same module runs on
|
|
26
|
+
* Bun, Node ≥ 20, and Cloudflare Workers.
|
|
27
|
+
*/
|
|
28
|
+
const ROOM_INFO = "harnery-presence/room-id/v1";
|
|
29
|
+
const KEY_INFO = "harnery-presence/room-key/v1";
|
|
30
|
+
const SENDER_INFO = "harnery-presence/sender-id/v1";
|
|
31
|
+
const HKDF_SALT = "harnery-presence/hkdf-salt/v1";
|
|
32
|
+
/** Default derivation salt when a repo hasn't minted `.harnery/presence-salt`.
|
|
33
|
+
* Public by design (see module docs); committing a random per-repo salt just
|
|
34
|
+
* rotates the room away from anything derived before. */
|
|
35
|
+
export const DEFAULT_ROOM_SALT = "harnery-presence-salt/v1";
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a git remote URL so every clone shape of the same repo derives
|
|
38
|
+
* the same room: `git@github.com:Org/Repo.git`, `ssh://git@github.com/org/repo`,
|
|
39
|
+
* and `https://github.com/org/repo.git` all → `github.com/org/repo`.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeOriginUrl(raw) {
|
|
42
|
+
let s = raw.trim();
|
|
43
|
+
// scp-like syntax: [user@]host:path
|
|
44
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(s);
|
|
45
|
+
if (scp) {
|
|
46
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// URL syntax: strip scheme, then credentials.
|
|
50
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
51
|
+
s = s.replace(/^[^@/]+@/, "");
|
|
52
|
+
}
|
|
53
|
+
// Drop an explicit port, trailing slashes, trailing .git; lowercase.
|
|
54
|
+
// (Slashes first so `…/repo.git/` still sheds its .git suffix.)
|
|
55
|
+
s = s.replace(/^([^/:]+):\d+\//, "$1/");
|
|
56
|
+
s = s.replace(/\/+$/, "");
|
|
57
|
+
s = s.replace(/\.git$/i, "");
|
|
58
|
+
return s.toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
async function hkdfBits(ikm, info, bits) {
|
|
61
|
+
const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, [
|
|
62
|
+
"deriveBits",
|
|
63
|
+
]);
|
|
64
|
+
return crypto.subtle.deriveBits({
|
|
65
|
+
name: "HKDF",
|
|
66
|
+
hash: "SHA-256",
|
|
67
|
+
salt: new TextEncoder().encode(HKDF_SALT),
|
|
68
|
+
info: new TextEncoder().encode(info),
|
|
69
|
+
}, key, bits);
|
|
70
|
+
}
|
|
71
|
+
function hex(buf) {
|
|
72
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
73
|
+
return Array.from(bytes)
|
|
74
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
75
|
+
.join("");
|
|
76
|
+
}
|
|
77
|
+
/** Derive the room id, E2E key, and sender-id key from the repo identity. */
|
|
78
|
+
export async function deriveRoomCredentials(inputs) {
|
|
79
|
+
const ikm = new TextEncoder().encode([
|
|
80
|
+
inputs.rootCommitSha.trim().toLowerCase(),
|
|
81
|
+
normalizeOriginUrl(inputs.originUrl),
|
|
82
|
+
inputs.salt ?? DEFAULT_ROOM_SALT,
|
|
83
|
+
].join("\n"));
|
|
84
|
+
const roomBits = await hkdfBits(ikm, ROOM_INFO, 128);
|
|
85
|
+
const keyBits = await hkdfBits(ikm, KEY_INFO, 256);
|
|
86
|
+
const senderBits = await hkdfBits(ikm, SENDER_INFO, 256);
|
|
87
|
+
const key = await crypto.subtle.importKey("raw", keyBits, { name: "AES-GCM" }, false, [
|
|
88
|
+
"encrypt",
|
|
89
|
+
"decrypt",
|
|
90
|
+
]);
|
|
91
|
+
return { roomId: hex(roomBits), key, senderKeyBytes: new Uint8Array(senderBits) };
|
|
92
|
+
}
|
|
93
|
+
/** Opaque, stable per-machine sender id: HMAC(senderKey, machineLabel). The
|
|
94
|
+
* relay caches last-message-per-sender by this without learning the label. */
|
|
95
|
+
export async function computeSenderId(creds, machineLabel) {
|
|
96
|
+
const hmacKey = await crypto.subtle.importKey("raw", creds.senderKeyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
97
|
+
const sig = await crypto.subtle.sign("HMAC", hmacKey, new TextEncoder().encode(machineLabel));
|
|
98
|
+
return hex(sig).slice(0, 32);
|
|
99
|
+
}
|
|
100
|
+
function toB64(bytes) {
|
|
101
|
+
let bin = "";
|
|
102
|
+
for (const b of bytes)
|
|
103
|
+
bin += String.fromCharCode(b);
|
|
104
|
+
return btoa(bin);
|
|
105
|
+
}
|
|
106
|
+
function fromB64(s) {
|
|
107
|
+
const bin = atob(s);
|
|
108
|
+
const out = new Uint8Array(bin.length);
|
|
109
|
+
for (let i = 0; i < bin.length; i++)
|
|
110
|
+
out[i] = bin.charCodeAt(i);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** AES-GCM-encrypt a plaintext payload → { iv, ct } base64 pair. */
|
|
114
|
+
export async function encryptPayload(creds, plaintext) {
|
|
115
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
116
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, creds.key, new TextEncoder().encode(plaintext));
|
|
117
|
+
return { iv: toB64(iv), ct: toB64(new Uint8Array(ct)) };
|
|
118
|
+
}
|
|
119
|
+
/** Decrypt an { iv, ct } pair. Returns null on any failure (wrong room key,
|
|
120
|
+
* tampered frame) — callers drop the frame silently. */
|
|
121
|
+
export async function decryptPayload(creds, frame) {
|
|
122
|
+
try {
|
|
123
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(frame.iv) }, creds.key, fromB64(frame.ct));
|
|
124
|
+
return new TextDecoder().decode(pt);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Hard cap a relay enforces per text frame (ciphertext of a ~2KB blob is far
|
|
131
|
+
* smaller; anything bigger is abuse or a bug). */
|
|
132
|
+
export const MAX_FRAME_BYTES = 16 * 1024;
|
|
133
|
+
/** Parse + shape-validate an incoming relay frame. Null on anything off. */
|
|
134
|
+
export function parseRelayFrame(raw) {
|
|
135
|
+
if (raw.length > MAX_FRAME_BYTES)
|
|
136
|
+
return null;
|
|
137
|
+
let j;
|
|
138
|
+
try {
|
|
139
|
+
j = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!j || typeof j !== "object")
|
|
145
|
+
return null;
|
|
146
|
+
const f = j;
|
|
147
|
+
if (f.t === "pub") {
|
|
148
|
+
if (typeof f.sender !== "string" ||
|
|
149
|
+
f.sender.length === 0 ||
|
|
150
|
+
f.sender.length > 64 ||
|
|
151
|
+
typeof f.iv !== "string" ||
|
|
152
|
+
typeof f.ct !== "string" ||
|
|
153
|
+
f.iv.length > 64 ||
|
|
154
|
+
f.ct.length > MAX_FRAME_BYTES) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return { t: "pub", sender: f.sender, iv: f.iv, ct: f.ct };
|
|
158
|
+
}
|
|
159
|
+
if (f.t === "hello") {
|
|
160
|
+
if (typeof f.peers !== "number")
|
|
161
|
+
return null;
|
|
162
|
+
return { t: "hello", peers: f.peers };
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/** Room ids are 32 lowercase hex chars; relays reject anything else. */
|
|
167
|
+
export function isValidRoomId(roomId) {
|
|
168
|
+
return /^[0-9a-f]{32}$/.test(roomId);
|
|
169
|
+
}
|
|
@@ -20,7 +20,7 @@ import type { HarnessName } from "./types.js";
|
|
|
20
20
|
/** The env var each harness CLI reads as an API key (preferred over a stored
|
|
21
21
|
* login when set). Shared with the child-env builder's subscription-only
|
|
22
22
|
* scrub. */
|
|
23
|
-
export declare const API_KEY_VARS: Record<
|
|
23
|
+
export declare const API_KEY_VARS: Record<string, string>;
|
|
24
24
|
export type LoginState = "present" | "absent" | "unknown";
|
|
25
25
|
export type BillingMode =
|
|
26
26
|
/** No API key exported; the child rides the stored (subscription) login. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"billing.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/billing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;YAEY;AACZ,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"billing.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/billing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;YAEY;AACZ,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAI/C,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE1D,MAAM,MAAM,WAAW;AACrB,4EAA4E;AAC1E,cAAc;AAChB,6EAA6E;GAC3E,SAAS;AACX;yEACyE;GACvE,kBAAkB,CAAC;AAEvB,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,WAAW,CAAC;IACrB;gEAC4D;IAC5D,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,OAAO,CAAC;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,OAAO;IACtB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,WAAW,KAAK,YAAY,CAAC;AAEnE,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,EAAE,EAAE,GAAE,OAAY,GAAG,YAAY,CA+CjF"}
|
|
@@ -31,7 +31,7 @@ export function probeBilling(harness, io = {}) {
|
|
|
31
31
|
const env = io.env ?? process.env;
|
|
32
32
|
const home = io.home ?? homedir();
|
|
33
33
|
const keyVar = API_KEY_VARS[harness];
|
|
34
|
-
const envKey = Boolean(env[keyVar]?.trim());
|
|
34
|
+
const envKey = keyVar ? Boolean(env[keyVar]?.trim()) : false;
|
|
35
35
|
let login;
|
|
36
36
|
let apiKeyPresent = envKey;
|
|
37
37
|
let apiKeySource = envKey ? keyVar : null;
|
|
@@ -56,6 +56,12 @@ export function probeBilling(harness, io = {}) {
|
|
|
56
56
|
// presence or absence we can't prove.
|
|
57
57
|
login = "unknown";
|
|
58
58
|
break;
|
|
59
|
+
default:
|
|
60
|
+
// External adapters own their authentication. The generic engine must
|
|
61
|
+
// not guess credential locations or fail a plugin on a built-in-only
|
|
62
|
+
// heuristic.
|
|
63
|
+
login = "unknown";
|
|
64
|
+
break;
|
|
59
65
|
}
|
|
60
66
|
const mode = apiKeyPresent && login === "present"
|
|
61
67
|
? "api-key-override"
|
|
@@ -17,5 +17,10 @@
|
|
|
17
17
|
* cost, duration, and child session id (the resume + web-UI substrate).
|
|
18
18
|
*/
|
|
19
19
|
import type { EngineOpts, RunReport } from "./types.js";
|
|
20
|
+
export declare class WorkflowRunError extends Error {
|
|
21
|
+
readonly runId: string;
|
|
22
|
+
readonly proofPath: string;
|
|
23
|
+
constructor(message: string, runId: string, proofPath: string, cause?: unknown);
|
|
24
|
+
}
|
|
20
25
|
export declare function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport>;
|
|
21
26
|
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAeH,OAAO,KAAK,EAEV,UAAU,EAEV,SAAS,EAOV,MAAM,YAAY,CAAC;AAUpB,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEf,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAM/E;AAED,wBAAsB,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAqa1F"}
|