@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Control-plane work-queue poller (E2/E4 node side).
|
|
5
|
+
*
|
|
6
|
+
* The hosted control plane is the inbound front door: a GitHub issue webhook
|
|
7
|
+
* (E2) or a Slack command (E4) enqueues a WORK ITEM there. The node dials
|
|
8
|
+
* outbound only (invariant #4), so the control plane notifies it over the
|
|
9
|
+
* already-open relay socket when possible and it falls back to POLLING the
|
|
10
|
+
* control plane for pending items. It claims one (atomically — only one node
|
|
11
|
+
* wins), runs it on its own machine with its own token, then marks it done.
|
|
12
|
+
* Content never reaches the control plane; only the branch + PR go to GitHub.
|
|
13
|
+
*
|
|
14
|
+
* This complements the legacy direct GitHub polling in github-tasks.ts: that
|
|
15
|
+
* path is for a single self-hosted repo; this path lets the hosted service route
|
|
16
|
+
* issues (and Slack) across many repos/nodes. Pure HTTP + claim/loop logic lives
|
|
17
|
+
* here; the actual run is injected so the daemon keeps the agent wiring.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Build config from the relay enrollment + node label. Returns null if disabled.
|
|
21
|
+
*
|
|
22
|
+
* `nodeName` is the node's own registered name (identity.name). The control plane
|
|
23
|
+
* routes targeted work to `bivy/<name>` — a `bivy/<node>` label, an `@bot on
|
|
24
|
+
* <node>` directive, or the account's default-node setting all resolve to the
|
|
25
|
+
* node's *name*. So the node automatically serves `bivy/<its-name>` without any
|
|
26
|
+
* manual `--node-label`/`BIVY_NODE_LABEL`; that env var stays supported as an
|
|
27
|
+
* extra/override for serving a different label.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveControlPlaneTaskConfig(relay, env = process.env, nodeName) {
|
|
30
|
+
// Enrollment opts the node into the hosted work queue. This cannot be gated
|
|
31
|
+
// on GitHub configuration: Slack, signed webhooks, schedules, and manually
|
|
32
|
+
// dispatched runs use the same queue and may be the only integration enabled.
|
|
33
|
+
if (!relay?.controlPlaneUrl || !relay.enrollmentToken)
|
|
34
|
+
return null;
|
|
35
|
+
const base = (env.BIVY_GITHUB_LABEL?.trim() || "bivy");
|
|
36
|
+
// The label the node serves for its own name, e.g. name "hetzner" → "bivy/hetzner".
|
|
37
|
+
const nameLabel = nodeName?.trim() ? `${base}/${nodeName.trim()}` : undefined;
|
|
38
|
+
// BIVY_NODE_LABEL may be a full label ("bivy/x") or a bare suffix ("x").
|
|
39
|
+
const rawEnvLabel = env.BIVY_NODE_LABEL?.trim();
|
|
40
|
+
const envLabel = rawEnvLabel ? (rawEnvLabel.includes("/") ? rawEnvLabel : `${base}/${rawEnvLabel}`) : undefined;
|
|
41
|
+
const labels = Array.from(new Set([base, nameLabel, envLabel].filter(Boolean)));
|
|
42
|
+
return {
|
|
43
|
+
controlPlaneUrl: relay.controlPlaneUrl.replace(/\/$/, ""),
|
|
44
|
+
enrollmentToken: relay.enrollmentToken,
|
|
45
|
+
labels: labels.length ? labels : ["bivy"],
|
|
46
|
+
pollMs: Math.max(Number(env.BIVY_GITHUB_POLL_MS) || 60_000, 10_000),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function cp(cfg, method, path) {
|
|
50
|
+
return fetch(`${cfg.controlPlaneUrl}${path}`, {
|
|
51
|
+
method,
|
|
52
|
+
headers: { authorization: `Bearer ${cfg.enrollmentToken}` },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function transitionWork(cfg, id, action) {
|
|
56
|
+
await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/${action}`).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
export async function fetchPendingWork(cfg) {
|
|
59
|
+
const res = await cp(cfg, "GET", `/node/work?labels=${encodeURIComponent(cfg.labels.join(","))}`);
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
return [];
|
|
62
|
+
const data = (await res.json().catch(() => ({})));
|
|
63
|
+
return Array.isArray(data.items) ? data.items : [];
|
|
64
|
+
}
|
|
65
|
+
/** Atomically claim an item. Returns true only if THIS node won the claim. */
|
|
66
|
+
export async function claimWork(cfg, id) {
|
|
67
|
+
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`);
|
|
68
|
+
return res.ok;
|
|
69
|
+
}
|
|
70
|
+
export async function completeWork(cfg, id) {
|
|
71
|
+
await transitionWork(cfg, id, "complete");
|
|
72
|
+
}
|
|
73
|
+
export async function failWork(cfg, id) {
|
|
74
|
+
await transitionWork(cfg, id, "fail");
|
|
75
|
+
}
|
|
76
|
+
/** Park a run for a human (dormant `needs_attention` status). Best-effort. */
|
|
77
|
+
export async function needsAttentionWork(cfg, id) {
|
|
78
|
+
await transitionWork(cfg, id, "needs-attention");
|
|
79
|
+
}
|
|
80
|
+
/** Report privacy-safe run evidence (issue #153) — routing reason, output refs
|
|
81
|
+
* (branch/PR/checkpoint/commit/...), check results, and new timeline events.
|
|
82
|
+
* Best-effort: a dropped report loses one evidence update, never the run
|
|
83
|
+
* itself, so failures here are swallowed like the other transition calls. */
|
|
84
|
+
export async function reportEvidence(cfg, id, patch) {
|
|
85
|
+
await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify(patch),
|
|
89
|
+
}).catch(() => { });
|
|
90
|
+
}
|
|
91
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
92
|
+
export class ControlPlaneTaskPoller {
|
|
93
|
+
cfg;
|
|
94
|
+
runItem;
|
|
95
|
+
maxConcurrent;
|
|
96
|
+
timer;
|
|
97
|
+
inFlight = new Set();
|
|
98
|
+
policy;
|
|
99
|
+
sleep;
|
|
100
|
+
constructor(cfg, runItem,
|
|
101
|
+
/** Node's cap on concurrently-running queue sessions (0/undefined = unlimited).
|
|
102
|
+
* Read fresh each tick so the Settings → Nodes value takes effect live. */
|
|
103
|
+
maxConcurrent, options = {}) {
|
|
104
|
+
this.cfg = cfg;
|
|
105
|
+
this.runItem = runItem;
|
|
106
|
+
this.maxConcurrent = maxConcurrent;
|
|
107
|
+
this.policy = options.policy;
|
|
108
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
109
|
+
}
|
|
110
|
+
start() {
|
|
111
|
+
void this.tick();
|
|
112
|
+
this.timer = setInterval(() => void this.tick(), this.cfg.pollMs);
|
|
113
|
+
this.timer.unref?.();
|
|
114
|
+
console.log(`[control-plane-tasks] watching hosted queue for labels [${this.cfg.labels.join(", ")}] (relay push + ${Math.round(this.cfg.pollMs / 1000)}s fallback poll)`);
|
|
115
|
+
}
|
|
116
|
+
/** Trigger an immediate fetch after a relay push says work may be available. */
|
|
117
|
+
poke() {
|
|
118
|
+
void this.tick();
|
|
119
|
+
}
|
|
120
|
+
stop() {
|
|
121
|
+
if (this.timer)
|
|
122
|
+
clearInterval(this.timer);
|
|
123
|
+
}
|
|
124
|
+
async tick() {
|
|
125
|
+
let items;
|
|
126
|
+
try {
|
|
127
|
+
items = await fetchPendingWork(this.cfg);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const max = this.maxConcurrent?.() ?? 0;
|
|
133
|
+
const running = [];
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
if (this.inFlight.has(item.id))
|
|
136
|
+
continue;
|
|
137
|
+
// Honor the node's concurrency cap: leave the rest in the queue for a later
|
|
138
|
+
// tick (or an idle node) to claim when a slot frees.
|
|
139
|
+
if (max > 0 && this.inFlight.size >= max)
|
|
140
|
+
break;
|
|
141
|
+
// Reserve the slot synchronously (no `await` since the last check) so a
|
|
142
|
+
// later item considered in this same loop sees an accurate
|
|
143
|
+
// `inFlight.size` — then kick it off without awaiting it here (only
|
|
144
|
+
// collecting the promise to await below). Awaiting an item to completion
|
|
145
|
+
// before starting the next one meant the cap was never really exercised
|
|
146
|
+
// within a single tick: items ran one at a time regardless of `max`, and
|
|
147
|
+
// only overlapping `setInterval` ticks happened to run more than one
|
|
148
|
+
// concurrently.
|
|
149
|
+
this.inFlight.add(item.id);
|
|
150
|
+
running.push(this.runOne(item));
|
|
151
|
+
}
|
|
152
|
+
await Promise.all(running);
|
|
153
|
+
}
|
|
154
|
+
async runOne(item) {
|
|
155
|
+
try {
|
|
156
|
+
// Claim first so only one node runs it; skip if another node won (no
|
|
157
|
+
// claim → not ours → don't run or complete it).
|
|
158
|
+
if (!(await claimWork(this.cfg, item.id)))
|
|
159
|
+
return;
|
|
160
|
+
const report = (patch) => reportEvidence(this.cfg, item.id, patch);
|
|
161
|
+
await transitionWork(this.cfg, item.id, "running");
|
|
162
|
+
console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
|
|
163
|
+
// routingReason is a coarse baseline — a manual "Run…" override picked
|
|
164
|
+
// this agent/model explicitly; otherwise it's whatever the queue label
|
|
165
|
+
// routed to. runWorkItem/runIssueTask may layer a more specific reason
|
|
166
|
+
// (e.g. a fallback after an error) on top via the same `report` hook.
|
|
167
|
+
await report({ routingReason: item.runtimeId || item.model ? "manual override" : "queue label" });
|
|
168
|
+
await this.runWithPolicy(item, report);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.inFlight.delete(item.id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Run one item under the run policy: on failure, classify → decide → retry /
|
|
176
|
+
* reroute (rewrite routing for the next attempt) / park (needs_attention) /
|
|
177
|
+
* give_up (fail). Reroute happens only at ATTEMPT BOUNDARIES — the failed
|
|
178
|
+
* attempt is fully unwound before the next one starts — so there's no partial-
|
|
179
|
+
* work/idempotency hazard. Every decision is recorded as a bounded, privacy-
|
|
180
|
+
* safe evidence event. With no policy injected this is the historical path:
|
|
181
|
+
* one attempt, any throw fails the run.
|
|
182
|
+
*/
|
|
183
|
+
async runWithPolicy(item, report) {
|
|
184
|
+
let current = item;
|
|
185
|
+
let attempt = 1;
|
|
186
|
+
let rerouteCount = 0;
|
|
187
|
+
for (;;) {
|
|
188
|
+
try {
|
|
189
|
+
await this.runItem(current, report);
|
|
190
|
+
await completeWork(this.cfg, item.id);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const decision = this.policy?.decide({
|
|
195
|
+
routing: { runtimeId: current.runtimeId, model: current.model },
|
|
196
|
+
error,
|
|
197
|
+
attempt,
|
|
198
|
+
rerouteCount,
|
|
199
|
+
}) ?? { action: "give_up", condition: "unknown" };
|
|
200
|
+
if (decision.action === "retry" || decision.action === "reroute") {
|
|
201
|
+
attempt += 1;
|
|
202
|
+
const kind = decision.action === "retry" ? "retry" : "fallback";
|
|
203
|
+
console.warn(`[control-plane-tasks] item ${item.id} ${kind} (${decision.condition}): ${decision.summary}`);
|
|
204
|
+
await report({
|
|
205
|
+
events: [
|
|
206
|
+
{
|
|
207
|
+
at: new Date().toISOString(),
|
|
208
|
+
kind,
|
|
209
|
+
summary: decision.summary,
|
|
210
|
+
attempt,
|
|
211
|
+
...(decision.action === "reroute" ? { ref: decision.ref } : {}),
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
if (decision.action === "reroute") {
|
|
216
|
+
current = { ...current, runtimeId: decision.routing.runtimeId, model: decision.routing.model };
|
|
217
|
+
rerouteCount = decision.rerouteCount;
|
|
218
|
+
await report({ routingReason: `fallback: ${decision.ref}` });
|
|
219
|
+
}
|
|
220
|
+
if (decision.delayMs > 0)
|
|
221
|
+
await this.sleep(decision.delayMs);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (decision.action === "park") {
|
|
225
|
+
console.warn(`[control-plane-tasks] item ${item.id} needs attention (${decision.condition}): ${decision.summary}`);
|
|
226
|
+
await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary: decision.summary }] });
|
|
227
|
+
await needsAttentionWork(this.cfg, item.id);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
console.warn(`[control-plane-tasks] item ${item.id} failed:`, error);
|
|
231
|
+
await failWork(this.cfg, item.id);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
package/dist/data-dir.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Single source of truth for the Bivy data directory.
|
|
4
|
+
//
|
|
5
|
+
// Several modules independently derived this default and DISAGREED: the daemon
|
|
6
|
+
// (src/server.ts) used <install>/.bivy, the git credential helper and the secret
|
|
7
|
+
// vault fell back to ~/.bivy, and the agent service to <cwd>/.bivy. A process
|
|
8
|
+
// that never sets BIVY_DATA_DIR explicitly could therefore point its git
|
|
9
|
+
// credential helper and its secret vault at different roots — the drift behind
|
|
10
|
+
// issue #1's test-isolation failure. Everyone resolves the data dir HERE now.
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
// This module ships at src/ (dist/ in a release build), so its parent directory
|
|
14
|
+
// is the package root — the same <install>/.bivy the daemon uses. Because the
|
|
15
|
+
// path is resolved relative to THIS file, every importer agrees regardless of
|
|
16
|
+
// its own location (e.g. src/runtime/*) or the process's cwd.
|
|
17
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
/**
|
|
19
|
+
* The Bivy data directory: `BIVY_DATA_DIR` if set (resolved to an absolute path),
|
|
20
|
+
* otherwise `<install>/.bivy`. This is the one function that decides the default.
|
|
21
|
+
*/
|
|
22
|
+
export function defaultDataDir() {
|
|
23
|
+
const env = process.env.BIVY_DATA_DIR;
|
|
24
|
+
return env ? path.resolve(env) : path.join(packageRoot, ".bivy");
|
|
25
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { generatePairingKeypair, generateRoomKey, generatePairSecret, deriveWrapKey, verifyPairingProof, wrapRoomKey, } from "./pairing-crypto.js";
|
|
7
|
+
import { seal, open } from "./e2e.js";
|
|
8
|
+
const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
9
|
+
export class PairingStore {
|
|
10
|
+
filePath;
|
|
11
|
+
data;
|
|
12
|
+
pendingSecrets = new Map(); // secret -> expiry (ms)
|
|
13
|
+
constructor(filePath, data) {
|
|
14
|
+
this.filePath = filePath;
|
|
15
|
+
this.data = data;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Load (or create) the pairing state. A fresh state gets a randomly generated
|
|
19
|
+
* room key; devices receive it (and every later rotation) over the X25519
|
|
20
|
+
* pairing handshake, so there is no static seed to carry forward.
|
|
21
|
+
*/
|
|
22
|
+
static load(appDir) {
|
|
23
|
+
const filePath = path.join(appDir, "pairing.json");
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Only a missing file means "never paired yet". Any other read failure
|
|
30
|
+
// (permission denied, I/O error, etc.) must not mint a fresh identity.
|
|
31
|
+
if (error?.code !== "ENOENT")
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
if (raw !== undefined) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// The file exists but is corrupt/truncated. Minting a fresh keypair and
|
|
41
|
+
// room key here would silently invalidate every paired device, so fail
|
|
42
|
+
// loudly instead and let the operator decide how to recover.
|
|
43
|
+
throw new Error(`Pairing state file at ${filePath} is corrupt and could not be parsed. Refusing to generate a ` +
|
|
44
|
+
`new keypair/room key (this would invalidate every paired device). Restore it from a backup, ` +
|
|
45
|
+
`or remove the file manually if you intend to start fresh.`);
|
|
46
|
+
}
|
|
47
|
+
if (!parsed.nodeKeypair?.publicKeyB64 || !parsed.roomKeyB64) {
|
|
48
|
+
throw new Error(`Pairing state file at ${filePath} is missing required fields (nodeKeypair/roomKeyB64). Refusing ` +
|
|
49
|
+
`to generate a new keypair/room key (this would invalidate every paired device). Restore it ` +
|
|
50
|
+
`from a backup, or remove the file manually if you intend to start fresh.`);
|
|
51
|
+
}
|
|
52
|
+
return new PairingStore(filePath, {
|
|
53
|
+
nodeKeypair: parsed.nodeKeypair,
|
|
54
|
+
roomKeyB64: parsed.roomKeyB64,
|
|
55
|
+
devices: Array.isArray(parsed.devices) ? parsed.devices : [],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const data = {
|
|
59
|
+
nodeKeypair: generatePairingKeypair(),
|
|
60
|
+
roomKeyB64: generateRoomKey().toString("base64"),
|
|
61
|
+
devices: [],
|
|
62
|
+
};
|
|
63
|
+
const store = new PairingStore(filePath, data);
|
|
64
|
+
store.persist();
|
|
65
|
+
return store;
|
|
66
|
+
}
|
|
67
|
+
persist() {
|
|
68
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
69
|
+
// Atomic write: write to a tmp file in the same directory, then rename over
|
|
70
|
+
// the target, so readers never observe a partially-written/truncated file.
|
|
71
|
+
const tmp = `${this.filePath}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
try {
|
|
74
|
+
fs.chmodSync(tmp, 0o600);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// best effort on platforms without chmod
|
|
78
|
+
}
|
|
79
|
+
fs.renameSync(tmp, this.filePath);
|
|
80
|
+
try {
|
|
81
|
+
fs.chmodSync(this.filePath, 0o600);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// best effort on platforms without chmod
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
nodePublicKeyB64() {
|
|
88
|
+
return this.data.nodeKeypair.publicKeyB64;
|
|
89
|
+
}
|
|
90
|
+
wrapForNodePublicKey(nodePublicKeyB64, plaintextB64, purpose = "model-auth-vault") {
|
|
91
|
+
const wrapKey = deriveWrapKey(this.data.nodeKeypair.privateKeyB64, nodePublicKeyB64, purpose);
|
|
92
|
+
return seal(wrapKey, plaintextB64);
|
|
93
|
+
}
|
|
94
|
+
unwrapFromNodePublicKey(nodePublicKeyB64, wrapped, purpose = "model-auth-vault") {
|
|
95
|
+
const wrapKey = deriveWrapKey(this.data.nodeKeypair.privateKeyB64, nodePublicKeyB64, purpose);
|
|
96
|
+
return open(wrapKey, wrapped);
|
|
97
|
+
}
|
|
98
|
+
roomKey() {
|
|
99
|
+
return Buffer.from(this.data.roomKeyB64, "base64");
|
|
100
|
+
}
|
|
101
|
+
/** Issue a single-use, expiring pairing secret to embed in a linking QR. */
|
|
102
|
+
issuePairSecret(ttlMs = DEFAULT_PAIR_TTL_MS) {
|
|
103
|
+
this.sweepSecrets();
|
|
104
|
+
const secret = generatePairSecret();
|
|
105
|
+
this.pendingSecrets.set(secret, Date.now() + ttlMs);
|
|
106
|
+
return secret;
|
|
107
|
+
}
|
|
108
|
+
welcomeForTrustedDevice(input) {
|
|
109
|
+
const { devicePublicKeyB64 } = input;
|
|
110
|
+
if (!devicePublicKeyB64)
|
|
111
|
+
return null;
|
|
112
|
+
const existing = this.data.devices.find((d) => d.publicKeyB64 === devicePublicKeyB64);
|
|
113
|
+
const device = existing ?? {
|
|
114
|
+
id: `lnkdev_${randomUUID()}`,
|
|
115
|
+
publicKeyB64: devicePublicKeyB64,
|
|
116
|
+
label: (input.label ?? "Device").toString().slice(0, 60) || "Device",
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
lastSeenAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
if (existing)
|
|
121
|
+
existing.lastSeenAt = new Date().toISOString();
|
|
122
|
+
else
|
|
123
|
+
this.data.devices.push(device);
|
|
124
|
+
this.persist();
|
|
125
|
+
const wrapKey = deriveWrapKey(this.data.nodeKeypair.privateKeyB64, devicePublicKeyB64, "pair");
|
|
126
|
+
return {
|
|
127
|
+
deviceId: device.id,
|
|
128
|
+
nodePublicKeyB64: this.data.nodeKeypair.publicKeyB64,
|
|
129
|
+
wrapped: wrapRoomKey(wrapKey, this.roomKey()),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Handle a device's `pair.hello`. The device sends its public key and a proof
|
|
134
|
+
* (HMAC over its public key keyed by the QR's pairing secret) — never the
|
|
135
|
+
* secret itself. We match the proof against outstanding secrets; a relay that
|
|
136
|
+
* never saw the QR cannot produce a valid proof. On success the device is
|
|
137
|
+
* registered and the room key is wrapped for it. Returns null on failure.
|
|
138
|
+
*/
|
|
139
|
+
handleHello(input) {
|
|
140
|
+
this.sweepSecrets();
|
|
141
|
+
const { devicePublicKeyB64, proofB64 } = input;
|
|
142
|
+
if (!devicePublicKeyB64 || !proofB64)
|
|
143
|
+
return null;
|
|
144
|
+
let matched = null;
|
|
145
|
+
for (const secret of this.pendingSecrets.keys()) {
|
|
146
|
+
if (verifyPairingProof(secret, devicePublicKeyB64, proofB64)) {
|
|
147
|
+
matched = secret;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!matched)
|
|
152
|
+
return null;
|
|
153
|
+
this.pendingSecrets.delete(matched); // single use
|
|
154
|
+
return this.welcomeForTrustedDevice(input);
|
|
155
|
+
}
|
|
156
|
+
/** Trust a device that was authorized by the control plane account session. */
|
|
157
|
+
trustDevice(input) {
|
|
158
|
+
return this.welcomeForTrustedDevice(input);
|
|
159
|
+
}
|
|
160
|
+
listDevices() {
|
|
161
|
+
return this.data.devices.map((d) => ({ ...d }));
|
|
162
|
+
}
|
|
163
|
+
touchDevice(deviceId) {
|
|
164
|
+
const device = this.data.devices.find((d) => d.id === deviceId);
|
|
165
|
+
if (device) {
|
|
166
|
+
device.lastSeenAt = new Date().toISOString();
|
|
167
|
+
this.persist();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Revoke a device: drop it, rotate the room key, and re-wrap the NEW room key
|
|
172
|
+
* for every remaining device (using the "rotate" context). The revoked device
|
|
173
|
+
* never receives the new key and its old key is now dead. Returns the per-device
|
|
174
|
+
* wrapped deliveries for the caller to push over the relay, or null if the
|
|
175
|
+
* device was not found.
|
|
176
|
+
*/
|
|
177
|
+
revokeDevice(deviceId) {
|
|
178
|
+
const before = this.data.devices.length;
|
|
179
|
+
this.data.devices = this.data.devices.filter((d) => d.id !== deviceId);
|
|
180
|
+
if (this.data.devices.length === before)
|
|
181
|
+
return null;
|
|
182
|
+
return this.rotateRoomKey();
|
|
183
|
+
}
|
|
184
|
+
/** Rotate the room key and re-wrap it for all current devices. */
|
|
185
|
+
rotateRoomKey() {
|
|
186
|
+
this.data.roomKeyB64 = generateRoomKey().toString("base64");
|
|
187
|
+
this.persist();
|
|
188
|
+
const roomKey = this.roomKey();
|
|
189
|
+
return this.data.devices.map((device) => ({
|
|
190
|
+
deviceId: device.id,
|
|
191
|
+
wrapped: wrapRoomKey(deriveWrapKey(this.data.nodeKeypair.privateKeyB64, device.publicKeyB64, "rotate"), roomKey),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
sweepSecrets() {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
for (const [secret, expiry] of this.pendingSecrets) {
|
|
197
|
+
if (expiry < now)
|
|
198
|
+
this.pendingSecrets.delete(secret);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
package/dist/e2e.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
|
4
|
+
import { IV_BYTES, SEALED_HEADER_BYTES, FRAME_VERSION, FRAME_NONCE_BYTES, REPLAY_WINDOW_MS, MAX_SEEN_NONCES, } from "./wire-format.js";
|
|
5
|
+
/**
|
|
6
|
+
* End-to-end encryption envelope (AES-256-GCM).
|
|
7
|
+
*
|
|
8
|
+
* The 32-byte key is established during device pairing and shared only between
|
|
9
|
+
* the node and its paired clients. The relay never has it, so it can route but
|
|
10
|
+
* not read frames.
|
|
11
|
+
*
|
|
12
|
+
* Wire format (base64): [ 12-byte IV | 16-byte GCM tag | ciphertext ].
|
|
13
|
+
*/
|
|
14
|
+
export function seal(key, plaintext) {
|
|
15
|
+
const iv = randomBytes(IV_BYTES);
|
|
16
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
17
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
18
|
+
const tag = cipher.getAuthTag();
|
|
19
|
+
return Buffer.concat([iv, tag, ct]).toString("base64");
|
|
20
|
+
}
|
|
21
|
+
export function open(key, payload) {
|
|
22
|
+
const buf = Buffer.from(payload, "base64");
|
|
23
|
+
const iv = buf.subarray(0, IV_BYTES);
|
|
24
|
+
const tag = buf.subarray(IV_BYTES, SEALED_HEADER_BYTES);
|
|
25
|
+
const ct = buf.subarray(SEALED_HEADER_BYTES);
|
|
26
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
27
|
+
decipher.setAuthTag(tag);
|
|
28
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
29
|
+
}
|
|
30
|
+
export function sealFrame(key, data) {
|
|
31
|
+
const frame = { v: FRAME_VERSION, ts: Date.now(), nonce: randomBytes(FRAME_NONCE_BYTES).toString("base64"), data };
|
|
32
|
+
return seal(key, JSON.stringify(frame));
|
|
33
|
+
}
|
|
34
|
+
export function openFrame(key, payload) {
|
|
35
|
+
const env = JSON.parse(open(key, payload));
|
|
36
|
+
if (!env || typeof env !== "object" || typeof env.ts !== "number" || typeof env.nonce !== "string") {
|
|
37
|
+
throw new Error("Malformed frame");
|
|
38
|
+
}
|
|
39
|
+
return { ts: env.ts, nonce: env.nonce, data: env.data };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Rejects stale (outside the freshness window) or duplicate (nonce already
|
|
43
|
+
* seen) frames. A bounded seen-nonce cache handles multiple senders sharing the
|
|
44
|
+
* key without per-sender sequence state.
|
|
45
|
+
*/
|
|
46
|
+
export class ReplayGuard {
|
|
47
|
+
windowMs;
|
|
48
|
+
seen = new Map(); // nonce -> expiry (ms)
|
|
49
|
+
constructor(windowMs = REPLAY_WINDOW_MS) {
|
|
50
|
+
this.windowMs = windowMs;
|
|
51
|
+
}
|
|
52
|
+
/** Returns true if the frame is fresh and previously unseen. */
|
|
53
|
+
accept(frame) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
if (Math.abs(now - frame.ts) > this.windowMs)
|
|
56
|
+
return false;
|
|
57
|
+
if (this.seen.has(frame.nonce))
|
|
58
|
+
return false;
|
|
59
|
+
this.seen.set(frame.nonce, now + this.windowMs);
|
|
60
|
+
if (this.seen.size > MAX_SEEN_NONCES)
|
|
61
|
+
this.prune(now);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
prune(now) {
|
|
65
|
+
for (const [nonce, expiry] of this.seen) {
|
|
66
|
+
if (expiry <= now)
|
|
67
|
+
this.seen.delete(nonce);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Ephemeral provisioning proxy (node-broker path).
|
|
4
|
+
//
|
|
5
|
+
// A remote device that holds the user's cloud token asks this node to make ONE
|
|
6
|
+
// allowlisted HTTPS request to a provider (Fly/Hetzner/AWS/...) on its behalf —
|
|
7
|
+
// the browser can't call those APIs directly (no CORS). The token/credentials
|
|
8
|
+
// ride in the request headers and are used transiently; nothing is persisted
|
|
9
|
+
// here, so the provisioning stays end-to-end (the control plane never sees
|
|
10
|
+
// it). The host allowlist is the SSRF guard and must be kept in lock-step with
|
|
11
|
+
// the two other copies: `ALLOWED_HOSTS` in packages/core/src/ephemeral.ts
|
|
12
|
+
// (the browser-side adapter) and the control-plane's cold-start relay in
|
|
13
|
+
// services/control-plane/src/index.ts.
|
|
14
|
+
export const EPHEMERAL_ALLOWED_HOSTS = new Set([
|
|
15
|
+
"api.hetzner.cloud",
|
|
16
|
+
"api.machines.dev",
|
|
17
|
+
"api.fly.io",
|
|
18
|
+
"api.sprites.dev",
|
|
19
|
+
"ec2.us-east-1.amazonaws.com",
|
|
20
|
+
"ec2.us-west-2.amazonaws.com",
|
|
21
|
+
"ec2.eu-west-1.amazonaws.com",
|
|
22
|
+
"ec2.eu-central-1.amazonaws.com",
|
|
23
|
+
"ec2.ap-southeast-1.amazonaws.com",
|
|
24
|
+
"ec2.ap-northeast-1.amazonaws.com",
|
|
25
|
+
"ssm.us-east-1.amazonaws.com",
|
|
26
|
+
"ssm.us-west-2.amazonaws.com",
|
|
27
|
+
"ssm.eu-west-1.amazonaws.com",
|
|
28
|
+
"ssm.eu-central-1.amazonaws.com",
|
|
29
|
+
"ssm.ap-southeast-1.amazonaws.com",
|
|
30
|
+
"ssm.ap-northeast-1.amazonaws.com",
|
|
31
|
+
]);
|
|
32
|
+
/** Hard cap on redirect hops, so a provider (or a MITM) can't wedge us in a loop. */
|
|
33
|
+
const MAX_REDIRECTS = 5;
|
|
34
|
+
function checkAllowedHost(url) {
|
|
35
|
+
let host;
|
|
36
|
+
try {
|
|
37
|
+
host = new URL(url).host;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`Bad provider URL: ${url}`);
|
|
41
|
+
}
|
|
42
|
+
if (!EPHEMERAL_ALLOWED_HOSTS.has(host))
|
|
43
|
+
throw new Error(`Refusing to proxy to non-provider host: ${host}`);
|
|
44
|
+
return host;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* How a redirect changes the follow-up request, mirroring the WHATWG fetch spec's
|
|
48
|
+
* own `redirect: "follow"` behavior (which we're replicating manually below so we
|
|
49
|
+
* can re-validate the target host on every hop): 303 always downgrades to a
|
|
50
|
+
* bodyless GET; 301/302 do the same but only for a non-GET/HEAD method; 307/308
|
|
51
|
+
* preserve the original method and body.
|
|
52
|
+
*/
|
|
53
|
+
function nextHopRequest(status, method, payload) {
|
|
54
|
+
if (status === 303 || ((status === 301 || status === 302) && method !== "GET" && method !== "HEAD")) {
|
|
55
|
+
return { method: "GET", payload: undefined };
|
|
56
|
+
}
|
|
57
|
+
return { method, payload };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Perform one allowlisted provider request for a remote device. Rejects any
|
|
61
|
+
* non-provider host — including a redirect target: the allowlist check ran only
|
|
62
|
+
* on the *initial* URL, but a plain `fetch` follows redirects transparently, so a
|
|
63
|
+
* provider response (or anyone able to influence it) could 302 us — Authorization
|
|
64
|
+
* header and all — to an arbitrary host. `redirect: "manual"` disables that
|
|
65
|
+
* auto-follow so every hop's Location is re-checked against the same allowlist
|
|
66
|
+
* before it's requested.
|
|
67
|
+
*/
|
|
68
|
+
export async function execEphemeralRequest(request, fetchImpl = fetch) {
|
|
69
|
+
let url = String(request?.url ?? "");
|
|
70
|
+
let method = String(request?.method ?? "GET").toUpperCase();
|
|
71
|
+
const headers = { ...(request?.headers ?? {}) };
|
|
72
|
+
let payload;
|
|
73
|
+
if (request?.body !== undefined && request?.body !== null && method !== "GET" && method !== "HEAD") {
|
|
74
|
+
payload = typeof request.body === "string" ? request.body : JSON.stringify(request.body);
|
|
75
|
+
if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type"))
|
|
76
|
+
headers["content-type"] = "application/json";
|
|
77
|
+
}
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
80
|
+
try {
|
|
81
|
+
for (let hop = 0;; hop++) {
|
|
82
|
+
const host = checkAllowedHost(url);
|
|
83
|
+
const res = await fetchImpl(url, { method, headers, body: payload, signal: controller.signal, redirect: "manual" });
|
|
84
|
+
if (res.status >= 300 && res.status < 400 && res.status !== 304) {
|
|
85
|
+
if (hop >= MAX_REDIRECTS)
|
|
86
|
+
throw new Error(`Too many redirects proxying to ${host}`);
|
|
87
|
+
const location = res.headers.get("location");
|
|
88
|
+
if (!location)
|
|
89
|
+
throw new Error(`Redirect from ${host} (${res.status}) had no Location header`);
|
|
90
|
+
// Resolve relative to the current hop; the loop's next iteration
|
|
91
|
+
// re-validates this new host before it's ever requested — the whole
|
|
92
|
+
// point of this loop.
|
|
93
|
+
url = new URL(location, url).toString();
|
|
94
|
+
({ method, payload } = nextHopRequest(res.status, method, payload));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
let body = text;
|
|
99
|
+
try {
|
|
100
|
+
body = text ? JSON.parse(text) : null;
|
|
101
|
+
}
|
|
102
|
+
catch { /* non-JSON provider response */ }
|
|
103
|
+
return { status: res.status, body };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
}
|
|
109
|
+
}
|