@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
package/dist/question.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
// Bivy owns the AskUserQuestion → interactive-question-card feature at the
|
|
5
|
+
// governance layer, NOT inside any single runtime adapter. A `user_question`
|
|
6
|
+
// is a blocking, multiple-choice clarifying question an agent raises mid-turn
|
|
7
|
+
// (Claude Code's AskUserQuestion tool, and any future agent that emits the same
|
|
8
|
+
// tool). It is intercepted in the guardian tool-interceptor — the one seam every
|
|
9
|
+
// runtime with capabilities.toolInterception already implements — so pi and the
|
|
10
|
+
// Claude Agent SDK both light up with zero per-runtime question code. This is
|
|
11
|
+
// the deliberate sibling of ApprovalManager: same block-and-wait shape, but it
|
|
12
|
+
// returns the user's structured answers instead of a yes/no.
|
|
13
|
+
/** How long a pending question waits for an answer before auto-cancelling.
|
|
14
|
+
* Without this a client that never renders/answers the card (a stale client,
|
|
15
|
+
* a UI bug, nobody looking) would park the agent's tool call — and the turn —
|
|
16
|
+
* indefinitely. Mirrors the old runtime-side QUESTION_TIMEOUT_MS. */
|
|
17
|
+
const QUESTION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
18
|
+
export class QuestionManager {
|
|
19
|
+
pending = new Map();
|
|
20
|
+
requestListeners = new Set();
|
|
21
|
+
resolvedListeners = new Set();
|
|
22
|
+
/** Notified when a new question is raised — the server broadcasts
|
|
23
|
+
* `session.question` from here (mirrors approvals.onRequest). */
|
|
24
|
+
onRequest(listener) {
|
|
25
|
+
this.requestListeners.add(listener);
|
|
26
|
+
return () => this.requestListeners.delete(listener);
|
|
27
|
+
}
|
|
28
|
+
/** Notified every time a question settles — answered, cancelled, timed out,
|
|
29
|
+
* or aborted — exactly once. The server broadcasts `session.question.resolved`
|
|
30
|
+
* from here, so it fires precisely when the card should close. */
|
|
31
|
+
onResolved(listener) {
|
|
32
|
+
this.resolvedListeners.add(listener);
|
|
33
|
+
return () => this.resolvedListeners.delete(listener);
|
|
34
|
+
}
|
|
35
|
+
list() {
|
|
36
|
+
return [...this.pending.values()].map((p) => p.request);
|
|
37
|
+
}
|
|
38
|
+
hasPendingForSession(sessionId) {
|
|
39
|
+
for (const p of this.pending.values())
|
|
40
|
+
if (p.request.sessionId === sessionId)
|
|
41
|
+
return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Raise a blocking question and resolve once the user answers/skips (or it
|
|
46
|
+
* times out / the turn aborts). Called from the guardian interceptor; the
|
|
47
|
+
* returned answer is formatted back to the agent as the tool result.
|
|
48
|
+
*/
|
|
49
|
+
request(input) {
|
|
50
|
+
const request = {
|
|
51
|
+
id: randomUUID(),
|
|
52
|
+
sessionId: input.sessionId,
|
|
53
|
+
questions: input.questions,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
status: "pending",
|
|
56
|
+
};
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
// Already-aborted turn: settle immediately, never register.
|
|
59
|
+
if (input.signal?.aborted) {
|
|
60
|
+
request.status = "cancelled";
|
|
61
|
+
resolve({ behavior: "cancelled" });
|
|
62
|
+
for (const l of this.resolvedListeners)
|
|
63
|
+
l(request);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const timeout = setTimeout(() => this.settle(request.id, { behavior: "cancelled" }, "expired"), input.timeoutMs ?? QUESTION_TIMEOUT_MS);
|
|
67
|
+
const onAbort = () => this.settle(request.id, { behavior: "cancelled" }, "cancelled");
|
|
68
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
this.pending.set(request.id, { request, resolve, timeout, onAbort, signal: input.signal });
|
|
70
|
+
for (const l of this.requestListeners)
|
|
71
|
+
l(request);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Deliver the user's answer (or a skip/cancel) to a pending question. A
|
|
75
|
+
* stale/unknown id is a silent no-op — same tolerance as ApprovalManager. */
|
|
76
|
+
resolve(id, answer) {
|
|
77
|
+
return this.settle(id, answer, answer.behavior === "completed" ? "answered" : "cancelled");
|
|
78
|
+
}
|
|
79
|
+
/** Cancel every question outstanding for a session — called when the session
|
|
80
|
+
* is disposed/killed so cards close and the awaiting guardian promise (and
|
|
81
|
+
* the turn behind it) doesn't hang until timeout. */
|
|
82
|
+
cancelForSession(sessionId) {
|
|
83
|
+
for (const id of [...this.pending.keys()]) {
|
|
84
|
+
if (this.pending.get(id)?.request.sessionId === sessionId)
|
|
85
|
+
this.settle(id, { behavior: "cancelled" }, "cancelled");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
settle(id, answer, status) {
|
|
89
|
+
const pending = this.pending.get(id);
|
|
90
|
+
if (!pending)
|
|
91
|
+
return false;
|
|
92
|
+
clearTimeout(pending.timeout);
|
|
93
|
+
if (pending.onAbort)
|
|
94
|
+
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
95
|
+
pending.request.status = status;
|
|
96
|
+
this.pending.delete(id);
|
|
97
|
+
pending.resolve(answer);
|
|
98
|
+
for (const l of this.resolvedListeners)
|
|
99
|
+
l(pending.request);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Defensive shape-check for an AskUserQuestion tool input's `questions` array.
|
|
105
|
+
* Returns null (rather than throwing or forwarding a partial item) on anything
|
|
106
|
+
* that doesn't look like a real UserQuestionItem[], so a malformed call degrades
|
|
107
|
+
* to "let the tool run un-intercepted" instead of reaching QuestionCard (which
|
|
108
|
+
* has no ErrorBoundary above it) with e.g. a missing `options` array.
|
|
109
|
+
*/
|
|
110
|
+
export function validQuestions(value) {
|
|
111
|
+
if (!Array.isArray(value) || !value.length)
|
|
112
|
+
return null;
|
|
113
|
+
for (const q of value) {
|
|
114
|
+
if (typeof q?.question !== "string" || typeof q?.header !== "string")
|
|
115
|
+
return null;
|
|
116
|
+
if (!Array.isArray(q.options) || q.options.length < 2)
|
|
117
|
+
return null;
|
|
118
|
+
for (const opt of q.options) {
|
|
119
|
+
if (typeof opt?.label !== "string")
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
/** Case-insensitive match for the AskUserQuestion tool across runtimes. Pi's
|
|
126
|
+
* "stealth" naming mirrors Claude Code exactly ("AskUserQuestion"); other
|
|
127
|
+
* agents may vary case, so normalize. */
|
|
128
|
+
export function isAskUserQuestionTool(toolName) {
|
|
129
|
+
return toolName.toLowerCase() === "askuserquestion";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Format the user's answer into the tool result text handed back to the agent.
|
|
133
|
+
* The interceptor delivers this through each runtime's block/deny channel (the
|
|
134
|
+
* only host-supplied-result mechanism both pi and the Claude SDK expose), so it
|
|
135
|
+
* reads as a plain statement of what the user chose rather than an error.
|
|
136
|
+
*/
|
|
137
|
+
export function formatQuestionResult(questions, answer) {
|
|
138
|
+
if (answer.behavior === "cancelled") {
|
|
139
|
+
return "The user dismissed the question(s) without answering. Proceed using your best judgment, or ask again if you truly cannot continue.";
|
|
140
|
+
}
|
|
141
|
+
const lines = questions.map((q) => {
|
|
142
|
+
const a = answer.answers[q.question];
|
|
143
|
+
return `- ${q.header}: ${a && a.trim() ? a : "(no answer)"}`;
|
|
144
|
+
});
|
|
145
|
+
return `The user answered your question(s):\n${lines.join("\n")}`;
|
|
146
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Redact credentials from text before it is persisted to disk. Bivy stores
|
|
4
|
+
// session transcripts and tool-activity sidecars as JSON under `.bivy/`, and
|
|
5
|
+
// those files sync to the web/PWA — so any secret an agent happens to print
|
|
6
|
+
// (e.g. a tool running `git remote -v`, which surfaces the token baked into a
|
|
7
|
+
// clone's origin URL) would otherwise land in cleartext in a synced log.
|
|
8
|
+
//
|
|
9
|
+
// Applied at the single persistence choke point (EventLog.flush), so every
|
|
10
|
+
// base-transcript / tool-activity / intermediate-message write is scrubbed. Pattern-
|
|
11
|
+
// based and structure-preserving: it only ever shortens string *values*, so the
|
|
12
|
+
// surrounding JSON stays valid.
|
|
13
|
+
const REDACTED = "***REDACTED***";
|
|
14
|
+
// GitHub tokens in all current shapes:
|
|
15
|
+
// ghp_ (classic PAT), gho_ (OAuth), ghu_/ghs_ (GitHub App user/installation),
|
|
16
|
+
// ghr_ (refresh), and github_pat_ (fine-grained PAT).
|
|
17
|
+
const GH_TOKEN = /\bgh[posur]_[A-Za-z0-9]{16,255}\b/g;
|
|
18
|
+
const GH_FINE_PAT = /\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g;
|
|
19
|
+
// The password half of any URL userinfo — `scheme://user:SECRET@host`. Covers
|
|
20
|
+
// the `https://x-access-token:<token>@github.com/...` form Bivy writes into a
|
|
21
|
+
// clone's remote, plus any other basic-auth URL. Keeps the user, masks the rest.
|
|
22
|
+
const URL_CREDENTIAL = /([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+):[^\s/@]+@/gi;
|
|
23
|
+
// Model/provider and common SaaS API keys with distinctive, high-entropy
|
|
24
|
+
// prefixes — the shapes an agent most often prints via `env`/`cat .env`. These
|
|
25
|
+
// prefixes are specific enough that the minimum-length suffix guards keep false
|
|
26
|
+
// positives (redacting innocuous transcript text) negligible.
|
|
27
|
+
// sk-… / sk-ant-… / sk-proj-… OpenAI + Anthropic
|
|
28
|
+
// sk_live_… / sk_test_… / rk_live_… / rk_test_… Stripe secret + restricted
|
|
29
|
+
// gsk_… Groq
|
|
30
|
+
// xai-… xAI
|
|
31
|
+
// AIza… Google API keys
|
|
32
|
+
// AKIA… AWS access key id
|
|
33
|
+
// xox[baprs]-… Slack tokens
|
|
34
|
+
const PROVIDER_KEYS = [
|
|
35
|
+
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
36
|
+
/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
|
|
37
|
+
/\bgsk_[A-Za-z0-9]{20,}\b/g,
|
|
38
|
+
/\bxai-[A-Za-z0-9]{20,}\b/g,
|
|
39
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
40
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
41
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
42
|
+
];
|
|
43
|
+
// Bivy's own bearer tokens — all minted as `<prefix>_` + `randomBytes(...).
|
|
44
|
+
// toString("base64url")` (see identity.ts's `mesh_` device tokens and the
|
|
45
|
+
// control-plane's `postgres-store.ts`):
|
|
46
|
+
// mesh_… device access token (identity.ts, `.bivy/node.json` creation flow)
|
|
47
|
+
// enr_… node enrollment token (relay-setup.ts, written to `.bivy/relay.json`)
|
|
48
|
+
// sess_… account session token (the bearer used while enrolling/pairing)
|
|
49
|
+
// mlt_… magic-link sign-in token
|
|
50
|
+
// lnk_… remote-device link grant
|
|
51
|
+
// tkt_… single-use relay ticket
|
|
52
|
+
const BIVY_TOKEN = /\b(?:mesh|enr|sess|mlt|lnk|tkt)_[A-Za-z0-9_-]{20,}\b/g;
|
|
53
|
+
// JWTs (three dot-separated base64url segments, header always decodes to `{"`
|
|
54
|
+
// so it always starts `eyJ`). Covers GitHub App JWTs (server.ts's
|
|
55
|
+
// `createAppJwt`) and any other JWT an agent might print.
|
|
56
|
+
const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
|
57
|
+
// Generic `Authorization: Bearer <token>` — catches bearer credentials that
|
|
58
|
+
// don't match one of the specific shapes above (e.g. a third-party bearer an
|
|
59
|
+
// agent echoes while debugging a request). Case-insensitive on the scheme
|
|
60
|
+
// name; keeps "Bearer"/"bearer" as written and only masks the credential.
|
|
61
|
+
const BEARER_HEADER = /\b(bearer\s+)[A-Za-z0-9._-]{16,}\b/gi;
|
|
62
|
+
// Pairing secrets / room keys / private keys: high-entropy string *values*
|
|
63
|
+
// serialized right next to one of Bivy's own known secret-bearing field
|
|
64
|
+
// names (device-registry.ts, pairing-crypto.ts, server.ts's GitHub App vault).
|
|
65
|
+
// These are raw random bytes with no distinctive prefix of their own, so we
|
|
66
|
+
// key off the surrounding JSON field name instead — e.g.
|
|
67
|
+
// `"roomKeyB64":"<...>"` in a synced device-registry / vault write, or the
|
|
68
|
+
// account session forwarded as `"sessionToken":"<...>"` during pairing.
|
|
69
|
+
const KNOWN_SECRET_FIELDS = [
|
|
70
|
+
"roomKeyB64",
|
|
71
|
+
"privateKeyB64",
|
|
72
|
+
"pairSecretB64",
|
|
73
|
+
"pairSecret",
|
|
74
|
+
"vaultKeyB64",
|
|
75
|
+
"deviceSecret",
|
|
76
|
+
"webhookSecret",
|
|
77
|
+
"sessionToken",
|
|
78
|
+
"enrollmentToken",
|
|
79
|
+
"privateKeyPem",
|
|
80
|
+
];
|
|
81
|
+
const KNOWN_SECRET_FIELD_VALUE = new RegExp(`("(?:${KNOWN_SECRET_FIELDS.join("|")})"\\s*:\\s*")[^"\\\\]*(?:\\\\.[^"\\\\]*)*(")`, "g");
|
|
82
|
+
/** Mask credentials in `text`. Safe on non-string / empty input. */
|
|
83
|
+
export function redactSecrets(text) {
|
|
84
|
+
if (!text)
|
|
85
|
+
return text;
|
|
86
|
+
let out = text
|
|
87
|
+
.replace(URL_CREDENTIAL, `$1:${REDACTED}@`)
|
|
88
|
+
.replace(GH_TOKEN, REDACTED)
|
|
89
|
+
.replace(GH_FINE_PAT, REDACTED)
|
|
90
|
+
.replace(BIVY_TOKEN, REDACTED)
|
|
91
|
+
.replace(JWT, REDACTED)
|
|
92
|
+
.replace(BEARER_HEADER, `$1${REDACTED}`)
|
|
93
|
+
.replace(KNOWN_SECRET_FIELD_VALUE, `$1${REDACTED}$2`);
|
|
94
|
+
for (const re of PROVIDER_KEYS)
|
|
95
|
+
out = out.replace(re, REDACTED);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Relay-tunnelled attach bridge for `bivy run --node <account-node>`.
|
|
5
|
+
//
|
|
6
|
+
// Bivy has no node↔node link; a node reaches a sibling it co-owns exactly the
|
|
7
|
+
// way a phone reaches a node — as a CLIENT in the sibling's relay room. This
|
|
8
|
+
// process is that client. It:
|
|
9
|
+
//
|
|
10
|
+
// 1. Mints a client-scoped grant for the target node from THIS node's
|
|
11
|
+
// enrollment token (POST /node/sibling-link-grant),
|
|
12
|
+
// 2. Exchanges it for a single-use relay ticket
|
|
13
|
+
// (POST /client/relay-ticket),
|
|
14
|
+
// 3. Opens the relay `/client` socket in the target's room, and
|
|
15
|
+
// 4. Pairs with `pair.account` to recover the target's rotating room key.
|
|
16
|
+
//
|
|
17
|
+
// After pairing it stands up a LOOPBACK WebSocket server that speaks the node's
|
|
18
|
+
// plain `/ws` protocol, and spawns the ordinary `attach` client against it. The
|
|
19
|
+
// two ends share ONE identical `terminal.*` message vocabulary — the only
|
|
20
|
+
// difference between a local `/ws` and the relay is the encrypted frame
|
|
21
|
+
// envelope — so the bridge is a pure translation layer and `attach` needs no
|
|
22
|
+
// relay awareness:
|
|
23
|
+
//
|
|
24
|
+
// attach → local /ws (bare terminal.* command JSON)
|
|
25
|
+
// → seal + chunk → relay frames → target node
|
|
26
|
+
// target node → relay frames → open + reassemble
|
|
27
|
+
// → bare terminal.* event JSON → local /ws → attach
|
|
28
|
+
//
|
|
29
|
+
// The crypto + framing are the already-unit-tested node core
|
|
30
|
+
// (relay-cli-crypto.ts + relay-chunk.ts); the credential handshake mirrors
|
|
31
|
+
// SiblingClient (src/session/sibling-client.ts).
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { spawn } from "node:child_process";
|
|
35
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
36
|
+
import { newDeviceKeypair, acceptWelcome, RoomCipher } from "./relay-cli-crypto.js";
|
|
37
|
+
import { frameMessages, FrameReassembler } from "./relay-chunk.js";
|
|
38
|
+
const c = {
|
|
39
|
+
dim: (s) => `\x1b[2m${s}\x1b[22m`,
|
|
40
|
+
red: (s) => `\x1b[31m${s}\x1b[39m`,
|
|
41
|
+
cyan: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
42
|
+
};
|
|
43
|
+
// All progress goes to STDERR: once `attach` binds the PTY it owns stdout in raw
|
|
44
|
+
// mode, so anything we print there would corrupt the terminal.
|
|
45
|
+
const note = (s) => process.stderr.write(s + "\n");
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const get = (flag) => {
|
|
48
|
+
const i = argv.indexOf(flag);
|
|
49
|
+
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined;
|
|
50
|
+
};
|
|
51
|
+
const nodeId = get("--node-id");
|
|
52
|
+
const relayConfigPath = get("--relay-config");
|
|
53
|
+
const attachCmdRaw = get("--attach-cmd");
|
|
54
|
+
if (!nodeId || !relayConfigPath || !attachCmdRaw) {
|
|
55
|
+
note("relay-attach: missing --node-id / --relay-config / --attach-cmd");
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
let attachCmd;
|
|
59
|
+
try {
|
|
60
|
+
attachCmd = JSON.parse(attachCmdRaw);
|
|
61
|
+
if (!Array.isArray(attachCmd))
|
|
62
|
+
throw new Error("not an array");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
note("relay-attach: --attach-cmd must be a JSON array");
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
nodeId: nodeId,
|
|
70
|
+
nodeName: get("--node-name") || nodeId,
|
|
71
|
+
relayConfigPath: relayConfigPath,
|
|
72
|
+
attachCmd,
|
|
73
|
+
run: get("--run"),
|
|
74
|
+
attachTermId: get("--attach"),
|
|
75
|
+
label: get("--label") || "Bivy CLI (run --node)",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function loadRelay(relayConfigPath) {
|
|
79
|
+
let raw = {};
|
|
80
|
+
try {
|
|
81
|
+
raw = JSON.parse(fs.readFileSync(relayConfigPath, "utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* handled below */
|
|
85
|
+
}
|
|
86
|
+
const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL || raw.controlPlaneUrl;
|
|
87
|
+
const enrollmentToken = process.env.BIVY_RELAY_TOKEN || raw.enrollmentToken;
|
|
88
|
+
if (!controlPlaneUrl || !enrollmentToken) {
|
|
89
|
+
note(c.red("relay-attach: relay is not configured (missing controlPlaneUrl/enrollmentToken). Run 'bivy relay:setup'."));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
return { controlPlaneUrl, enrollmentToken, url: process.env.BIVY_RELAY_URL || raw.url };
|
|
93
|
+
}
|
|
94
|
+
// Reuse ONE client device identity across runs so we don't spam the target
|
|
95
|
+
// node's device list with a fresh pairing on every `bivy run --node`.
|
|
96
|
+
function loadOrCreateDeviceKeypair(relayConfigPath) {
|
|
97
|
+
const file = path.join(path.dirname(relayConfigPath), "relay-cli-device.json");
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
100
|
+
if (data?.publicKeyB64 && data?.privateKeyB64)
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* create below */
|
|
105
|
+
}
|
|
106
|
+
const kp = newDeviceKeypair();
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(file, JSON.stringify(kp), { mode: 0o600 });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* non-fatal: fall back to an ephemeral identity */
|
|
112
|
+
}
|
|
113
|
+
return kp;
|
|
114
|
+
}
|
|
115
|
+
async function postJson(url, bearer, body) {
|
|
116
|
+
const res = await fetch(url, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
signal: AbortSignal.timeout(15_000),
|
|
121
|
+
});
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
data = { raw: text };
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok)
|
|
131
|
+
throw new Error(`${url} → ${res.status} ${String(data.error || text).slice(0, 160)}`);
|
|
132
|
+
return data;
|
|
133
|
+
}
|
|
134
|
+
async function main() {
|
|
135
|
+
const args = parseArgs(process.argv.slice(2));
|
|
136
|
+
const relay = loadRelay(args.relayConfigPath);
|
|
137
|
+
const cp = relay.controlPlaneUrl.replace(/\/$/, "");
|
|
138
|
+
const keypair = loadOrCreateDeviceKeypair(args.relayConfigPath);
|
|
139
|
+
// 1. grant (enrollment-scoped) → 2. single-use relay ticket.
|
|
140
|
+
note(c.dim(`Linking to ${c.cyan(args.nodeName)} over the relay…`));
|
|
141
|
+
const grantRes = await postJson(`${cp}/node/sibling-link-grant`, relay.enrollmentToken, { nodeId: args.nodeId });
|
|
142
|
+
const grant = String(grantRes.grant ?? "");
|
|
143
|
+
if (!grant)
|
|
144
|
+
throw new Error("sibling-link-grant returned no grant");
|
|
145
|
+
const ticketRes = await postJson(`${cp}/client/relay-ticket`, grant, { nodeId: args.nodeId });
|
|
146
|
+
const ticket = String(ticketRes.ticket ?? "");
|
|
147
|
+
if (!ticket)
|
|
148
|
+
throw new Error("relay-ticket returned no ticket");
|
|
149
|
+
const relayBase = String((typeof ticketRes.relayUrl === "string" && ticketRes.relayUrl) ||
|
|
150
|
+
(typeof grantRes.relayUrl === "string" && grantRes.relayUrl) ||
|
|
151
|
+
relay.url ||
|
|
152
|
+
"").replace(/\/$/, "");
|
|
153
|
+
if (!relayBase)
|
|
154
|
+
throw new Error("no relay URL available for the target node");
|
|
155
|
+
// 3. open the relay /client socket in the target's room and pair.
|
|
156
|
+
const rly = new WebSocket(`${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(args.nodeId)}`);
|
|
157
|
+
const reassembler = new FrameReassembler();
|
|
158
|
+
let cipher = null;
|
|
159
|
+
let local = null;
|
|
160
|
+
let paired = false;
|
|
161
|
+
let pairSent = false;
|
|
162
|
+
const fail = (message, code = 1) => {
|
|
163
|
+
note(c.red(`\n${message}`));
|
|
164
|
+
try {
|
|
165
|
+
rly.close();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* ignore */
|
|
169
|
+
}
|
|
170
|
+
process.exit(code);
|
|
171
|
+
};
|
|
172
|
+
const pairTimer = setTimeout(() => {
|
|
173
|
+
if (!paired)
|
|
174
|
+
fail(`Node "${args.nodeName}" did not respond to pairing — it may be offline.`, 1);
|
|
175
|
+
}, 20_000);
|
|
176
|
+
pairTimer.unref?.();
|
|
177
|
+
const sendPair = () => {
|
|
178
|
+
if (pairSent)
|
|
179
|
+
return;
|
|
180
|
+
pairSent = true;
|
|
181
|
+
rly.send(JSON.stringify({
|
|
182
|
+
t: "pair",
|
|
183
|
+
// `ephemeral`: this is a transient CLI bridge, not a user device — the
|
|
184
|
+
// control plane authorizes it but keeps it out of the account's
|
|
185
|
+
// "Signed-in devices" list.
|
|
186
|
+
p: JSON.stringify({ k: "pair.account", sessionToken: grant, devicePublicKeyB64: keypair.publicKeyB64, label: args.label, ephemeral: true }),
|
|
187
|
+
}));
|
|
188
|
+
};
|
|
189
|
+
rly.on("open", () => note(c.dim("Relay connected; pairing…")));
|
|
190
|
+
rly.on("error", (err) => fail(`Relay connection error: ${err.message}`, 1));
|
|
191
|
+
rly.on("close", () => {
|
|
192
|
+
if (!paired)
|
|
193
|
+
fail("Relay closed before the session was established.", 1);
|
|
194
|
+
// After pairing, a relay drop ends the remote session bridge; let the child exit.
|
|
195
|
+
try {
|
|
196
|
+
local?.close();
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
rly.on("message", (data) => {
|
|
203
|
+
let msg;
|
|
204
|
+
try {
|
|
205
|
+
msg = JSON.parse(String(data));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (msg.t === "ready" || msg.t === "peer.online") {
|
|
211
|
+
sendPair();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (msg.t === "peer.offline") {
|
|
215
|
+
if (!paired)
|
|
216
|
+
fail(`Node "${args.nodeName}" is offline.`, 1);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (msg.t === "error") {
|
|
220
|
+
fail(`Relay error: ${String(msg.message || msg.p || "unknown")}`, 1);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (msg.t === "pair") {
|
|
224
|
+
let p;
|
|
225
|
+
try {
|
|
226
|
+
p = JSON.parse(String(msg.p));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (p.k === "pair.welcome") {
|
|
232
|
+
try {
|
|
233
|
+
const roomKey = acceptWelcome(keypair, { nodePublicKeyB64: String(p.nodePublicKeyB64), wrapped: String(p.wrapped) });
|
|
234
|
+
cipher = new RoomCipher(roomKey);
|
|
235
|
+
paired = true;
|
|
236
|
+
clearTimeout(pairTimer);
|
|
237
|
+
startBridge();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
fail(`Pairing failed: ${err.message}`, 1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else if (p.k === "pair.error") {
|
|
244
|
+
fail(`Pairing rejected by ${args.nodeName}: ${p.error || "unknown reason"}`, 1);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (msg.t === "frame" && typeof msg.p === "string" && cipher) {
|
|
249
|
+
const full = reassembler.accept(msg);
|
|
250
|
+
if (!full)
|
|
251
|
+
return;
|
|
252
|
+
let event;
|
|
253
|
+
try {
|
|
254
|
+
event = cipher.open(full).data;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return; // undecryptable frame — ignore
|
|
258
|
+
}
|
|
259
|
+
// Forward the bare terminal.* event to the local attach socket verbatim.
|
|
260
|
+
if (local && local.readyState === WebSocket.OPEN)
|
|
261
|
+
local.send(JSON.stringify(event));
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
// Stand up the loopback /ws server + spawn attach, only once paired.
|
|
265
|
+
function startBridge() {
|
|
266
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0, path: "/ws" });
|
|
267
|
+
wss.on("connection", (sock) => {
|
|
268
|
+
// A single attach client owns the bridge. Reject extras.
|
|
269
|
+
if (local) {
|
|
270
|
+
try {
|
|
271
|
+
sock.close();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
local = sock;
|
|
279
|
+
sock.on("message", (data) => {
|
|
280
|
+
if (!cipher)
|
|
281
|
+
return;
|
|
282
|
+
let command;
|
|
283
|
+
try {
|
|
284
|
+
command = JSON.parse(String(data));
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
for (const frame of frameMessages(cipher.seal(command))) {
|
|
291
|
+
if (rly.readyState === WebSocket.OPEN)
|
|
292
|
+
rly.send(frame);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* seal/send failure — the relay close handler will surface it */
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
sock.on("close", () => {
|
|
300
|
+
// attach detached or the remote session ended; tear down.
|
|
301
|
+
try {
|
|
302
|
+
rly.close();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* ignore */
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
wss.on("listening", () => {
|
|
310
|
+
const addr = wss.address();
|
|
311
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
312
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
313
|
+
const childArgs = [
|
|
314
|
+
...args.attachCmd,
|
|
315
|
+
"--url",
|
|
316
|
+
localUrl,
|
|
317
|
+
...(args.run ? ["--run", args.run] : []),
|
|
318
|
+
...(args.attachTermId ? ["--attach", args.attachTermId] : []),
|
|
319
|
+
];
|
|
320
|
+
note(c.dim(`Paired ✓ starting session on ${c.cyan(args.nodeName)}\n`));
|
|
321
|
+
const child = spawn(process.execPath, childArgs, { stdio: "inherit", env: process.env });
|
|
322
|
+
child.on("exit", (code, signal) => {
|
|
323
|
+
try {
|
|
324
|
+
rly.close();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* ignore */
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
wss.close();
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* ignore */
|
|
334
|
+
}
|
|
335
|
+
process.exit(signal ? 1 : code ?? 0);
|
|
336
|
+
});
|
|
337
|
+
child.on("error", (err) => fail(`Could not start the attach client: ${err.message}`, 1));
|
|
338
|
+
});
|
|
339
|
+
wss.on("error", (err) => fail(`Local bridge error: ${err.message}`, 1));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
main().catch((err) => {
|
|
343
|
+
note(c.red(`relay-attach: ${err?.message || String(err)}`));
|
|
344
|
+
process.exit(1);
|
|
345
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { FRAME_CHUNK_BYTES, MAX_REASSEMBLY_BYTES, MAX_FRAME_CHUNKS, MAX_REASSEMBLY_GROUPS, } from "./wire-format.js";
|
|
5
|
+
// Re-exported so existing importers keep using `./relay-chunk.js`; the value
|
|
6
|
+
// itself is owned by the shared wire-format spec.
|
|
7
|
+
export { FRAME_CHUNK_BYTES };
|
|
8
|
+
/**
|
|
9
|
+
* Turn a sealed payload string into one or more wire messages (JSON strings).
|
|
10
|
+
* Returns a single `{ t: "frame", p }` for small payloads, or an ordered list of
|
|
11
|
+
* chunk messages for large ones.
|
|
12
|
+
*/
|
|
13
|
+
export function frameMessages(payload) {
|
|
14
|
+
if (payload.length <= FRAME_CHUNK_BYTES) {
|
|
15
|
+
return [JSON.stringify({ t: "frame", p: payload })];
|
|
16
|
+
}
|
|
17
|
+
const id = randomBytes(8).toString("hex");
|
|
18
|
+
const total = Math.ceil(payload.length / FRAME_CHUNK_BYTES);
|
|
19
|
+
const out = [];
|
|
20
|
+
for (let i = 0; i < total; i++) {
|
|
21
|
+
const slice = payload.slice(i * FRAME_CHUNK_BYTES, (i + 1) * FRAME_CHUNK_BYTES);
|
|
22
|
+
out.push(JSON.stringify({ t: "frame", p: slice, fc: id, fi: i, fn: total }));
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Buffers inbound frame chunks and yields the full payload once a group is
|
|
28
|
+
* complete. A non-chunked frame passes straight through.
|
|
29
|
+
*/
|
|
30
|
+
export class FrameReassembler {
|
|
31
|
+
groups = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Feed one inbound frame envelope. Returns the reassembled payload string when
|
|
34
|
+
* complete (or the payload itself when the frame was not chunked), or null
|
|
35
|
+
* while more chunks are still outstanding / the frame was invalid.
|
|
36
|
+
*/
|
|
37
|
+
accept(env) {
|
|
38
|
+
if (typeof env.p !== "string")
|
|
39
|
+
return null;
|
|
40
|
+
if (env.fc === undefined)
|
|
41
|
+
return env.p; // not chunked
|
|
42
|
+
const id = String(env.fc);
|
|
43
|
+
const index = Number(env.fi);
|
|
44
|
+
const total = Number(env.fn);
|
|
45
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || total <= 0 || total > MAX_FRAME_CHUNKS || index < 0 || index >= total) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let group = this.groups.get(id);
|
|
49
|
+
if (!group) {
|
|
50
|
+
// Bound concurrent groups; evict the oldest (insertion order) if needed.
|
|
51
|
+
if (this.groups.size >= MAX_REASSEMBLY_GROUPS) {
|
|
52
|
+
const oldest = this.groups.keys().next().value;
|
|
53
|
+
if (oldest !== undefined)
|
|
54
|
+
this.groups.delete(oldest);
|
|
55
|
+
}
|
|
56
|
+
group = { total, parts: new Array(total), have: 0, bytes: 0 };
|
|
57
|
+
this.groups.set(id, group);
|
|
58
|
+
}
|
|
59
|
+
if (group.parts[index] !== undefined)
|
|
60
|
+
return null; // duplicate chunk
|
|
61
|
+
group.parts[index] = env.p;
|
|
62
|
+
group.have += 1;
|
|
63
|
+
group.bytes += env.p.length;
|
|
64
|
+
if (group.bytes > MAX_REASSEMBLY_BYTES) {
|
|
65
|
+
this.groups.delete(id);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (group.have < group.total)
|
|
69
|
+
return null;
|
|
70
|
+
this.groups.delete(id);
|
|
71
|
+
return group.parts.join("");
|
|
72
|
+
}
|
|
73
|
+
}
|