@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,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Bivy's app-owned ruleset registry — the source of truth for user-authored
|
|
4
|
+
// run-orchestration policy (docs/rulesets.md).
|
|
5
|
+
//
|
|
6
|
+
// A ruleset decides what happens when a run fails: retry, reroute through a
|
|
7
|
+
// fallback chain, or park for a human. The schema + pure matcher live in
|
|
8
|
+
// src/policy/ruleset.ts; this module is only the STORAGE seam. It mirrors
|
|
9
|
+
// local-model-store.ts: a small JSON file under the app dir, non-secret config
|
|
10
|
+
// only, validated with the policy's own `validateRuleset` before it can steer a
|
|
11
|
+
// live run. It is deliberately node-local (policy is per-machine), not synced
|
|
12
|
+
// through the credential envelope.
|
|
13
|
+
//
|
|
14
|
+
// One ruleset may be marked ACTIVE — that's the one the work-queue effector
|
|
15
|
+
// consults (see server.ts activeQueueRuleset). When none is active, the queue
|
|
16
|
+
// falls back to the built-in DEFAULT_RULESET.
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { validateRuleset } from "../policy/ruleset.js";
|
|
20
|
+
const FILE_NAME = "rulesets.json";
|
|
21
|
+
function configPath(dir) {
|
|
22
|
+
return path.join(dir, FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
export function loadRulesets(dir) {
|
|
25
|
+
const file = configPath(dir);
|
|
26
|
+
if (!fs.existsSync(file))
|
|
27
|
+
return { activeName: null, rulesets: {} };
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
30
|
+
if (!parsed || typeof parsed !== "object")
|
|
31
|
+
return { activeName: null, rulesets: {} };
|
|
32
|
+
const rawRulesets = parsed.rulesets;
|
|
33
|
+
const rulesets = {};
|
|
34
|
+
if (rawRulesets && typeof rawRulesets === "object") {
|
|
35
|
+
for (const spec of Object.values(rawRulesets)) {
|
|
36
|
+
// Re-validate on read: a hand-edited or partially-migrated file never
|
|
37
|
+
// yields a shape the matcher can't trust. Invalid entries are dropped.
|
|
38
|
+
const result = validateRuleset(spec);
|
|
39
|
+
if (result.ok && result.ruleset)
|
|
40
|
+
rulesets[result.ruleset.name] = result.ruleset;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const activeRaw = parsed.activeName;
|
|
44
|
+
const activeName = typeof activeRaw === "string" && rulesets[activeRaw] ? activeRaw : null;
|
|
45
|
+
return { activeName, rulesets };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { activeName: null, rulesets: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function saveRulesets(dir, file) {
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const safe = {
|
|
54
|
+
activeName: file.activeName && file.rulesets[file.activeName] ? file.activeName : null,
|
|
55
|
+
rulesets: file.rulesets || {},
|
|
56
|
+
};
|
|
57
|
+
const target = configPath(dir);
|
|
58
|
+
fs.writeFileSync(target, `${JSON.stringify(safe, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
try {
|
|
60
|
+
fs.chmodSync(target, 0o600);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* best effort */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Full rulesets + active flag, for the UI. Sorted by name (active first). */
|
|
67
|
+
export function listRulesetInfos(dir) {
|
|
68
|
+
const { activeName, rulesets } = loadRulesets(dir);
|
|
69
|
+
return Object.values(rulesets)
|
|
70
|
+
.map((r) => ({ ...r, active: r.name === activeName }))
|
|
71
|
+
.sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate and store a ruleset (insert or update, keyed by name). Pass
|
|
75
|
+
* `active: true` to make it the queue's active ruleset, `false` to clear the
|
|
76
|
+
* active flag if this ruleset currently holds it, or omit to leave it unchanged.
|
|
77
|
+
* Throws with a human-readable message when the input fails validation.
|
|
78
|
+
*/
|
|
79
|
+
export function upsertRuleset(dir, input, active) {
|
|
80
|
+
const result = validateRuleset(input);
|
|
81
|
+
if (!result.ok || !result.ruleset) {
|
|
82
|
+
throw new Error(`Invalid ruleset: ${result.errors.join("; ") || "did not match the expected shape"}`);
|
|
83
|
+
}
|
|
84
|
+
const ruleset = result.ruleset;
|
|
85
|
+
const file = loadRulesets(dir);
|
|
86
|
+
file.rulesets[ruleset.name] = ruleset;
|
|
87
|
+
if (active === true)
|
|
88
|
+
file.activeName = ruleset.name;
|
|
89
|
+
else if (active === false && file.activeName === ruleset.name)
|
|
90
|
+
file.activeName = null;
|
|
91
|
+
saveRulesets(dir, file);
|
|
92
|
+
return { name: ruleset.name };
|
|
93
|
+
}
|
|
94
|
+
export function removeRuleset(dir, name) {
|
|
95
|
+
const file = loadRulesets(dir);
|
|
96
|
+
const key = String(name ?? "").trim();
|
|
97
|
+
if (!key)
|
|
98
|
+
throw new Error("ruleset name required");
|
|
99
|
+
delete file.rulesets[key];
|
|
100
|
+
if (file.activeName === key)
|
|
101
|
+
file.activeName = null;
|
|
102
|
+
saveRulesets(dir, file);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The active ruleset, but only if it `appliesTo` the given context — the
|
|
106
|
+
* work-queue effector calls this so an authored session-only ruleset never
|
|
107
|
+
* silently steers unattended queue runs. Undefined means "use the default".
|
|
108
|
+
*/
|
|
109
|
+
export function activeRulesetFor(dir, context) {
|
|
110
|
+
const { activeName, rulesets } = loadRulesets(dir);
|
|
111
|
+
if (!activeName)
|
|
112
|
+
return undefined;
|
|
113
|
+
const ruleset = rulesets[activeName];
|
|
114
|
+
if (!ruleset || !ruleset.appliesTo.includes(context))
|
|
115
|
+
return undefined;
|
|
116
|
+
return ruleset;
|
|
117
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Session-location registry — the Stage 2 seam (docs/agent-node-decoupling.md).
|
|
5
|
+
*
|
|
6
|
+
* Stage 1 moved the agent's process off the daemon; Stage 2 makes the daemon
|
|
7
|
+
* stateless by letting ANY daemon instance serve ANY session. The missing piece
|
|
8
|
+
* is a shared map from a session to the agent service that hosts it — a
|
|
9
|
+
* generalization of the control plane's existing `sessionId → nodeId` index
|
|
10
|
+
* (services/control-plane/src/store.ts) with the agent-service address added.
|
|
11
|
+
*
|
|
12
|
+
* This module defines the seam and a node-local default. Today the daemon holds
|
|
13
|
+
* the live handle in `openSessions` (src/server.ts) and resumes from local disk
|
|
14
|
+
* on a miss; with a shared implementation of this interface, a miss instead
|
|
15
|
+
* resolves the session's agent-service address and re-attaches to the still-live
|
|
16
|
+
* session via `RemoteRuntime.attachSession` (the Stage 1 `attach` op) — no local
|
|
17
|
+
* handle, no local disk. The interface is async so a control-plane-backed
|
|
18
|
+
* implementation (an HTTP round-trip) drops in behind the same flag without
|
|
19
|
+
* touching call sites.
|
|
20
|
+
*
|
|
21
|
+
* Kept dependency-free and unit-testable, in the style of
|
|
22
|
+
* services/relay/src/backpressure.ts.
|
|
23
|
+
*/
|
|
24
|
+
import { InMemoryLocationRegistry } from "./location-registry.js";
|
|
25
|
+
/**
|
|
26
|
+
* Node-local registry: each daemon knows only its own sessions. This is exactly
|
|
27
|
+
* today's behavior (no cross-daemon routing) and the safe default while the flag
|
|
28
|
+
* is off. A shared, control-plane-backed implementation replaces it to unlock
|
|
29
|
+
* true statelessness — same interface, so no call site changes.
|
|
30
|
+
*/
|
|
31
|
+
export class InMemorySessionLocationRegistry {
|
|
32
|
+
// Built on the generic keyed registry (location-registry.ts); this class just
|
|
33
|
+
// keys by sessionId and preserves the location-shaped API its callers use.
|
|
34
|
+
registry = new InMemoryLocationRegistry();
|
|
35
|
+
async record(location) {
|
|
36
|
+
if (!location.sessionId)
|
|
37
|
+
throw new Error("SessionLocation.sessionId is required");
|
|
38
|
+
await this.registry.record(location.sessionId, location);
|
|
39
|
+
}
|
|
40
|
+
async lookup(sessionId) {
|
|
41
|
+
return this.registry.lookup(sessionId);
|
|
42
|
+
}
|
|
43
|
+
async forget(sessionId) {
|
|
44
|
+
await this.registry.forget(sessionId);
|
|
45
|
+
}
|
|
46
|
+
/** Number of recorded locations (test/introspection aid). */
|
|
47
|
+
get size() {
|
|
48
|
+
return this.registry.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Runtime-agnostic agent interface.
|
|
4
|
+
//
|
|
5
|
+
// This is the seam that lets Bivy drive *any* agent runtime, not
|
|
6
|
+
// just Pi. Everything the daemon, guardian, relay and UI need from "the agent"
|
|
7
|
+
// is expressed here in terms that contain no Pi types. A concrete runtime
|
|
8
|
+
// (Pi today; Claude Agent SDK, a generic RPC agent, etc. later) lives behind an
|
|
9
|
+
// `AgentRuntime` implementation that maps these calls onto its own SDK.
|
|
10
|
+
//
|
|
11
|
+
// The shapes below are deliberately a *subset* of what Pi exposes — exactly the
|
|
12
|
+
// surface `src/server.ts` consumes — so the first adapter is a thin wrapper and
|
|
13
|
+
// the contract is small enough for other runtimes to satisfy.
|
|
14
|
+
//
|
|
15
|
+
// Nothing imports this yet; it is the target interface for the migration
|
|
16
|
+
// described in docs/pluggable-runtime.md.
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SecretVault } from "./secrets.js";
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
10
|
+
const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
|
|
11
|
+
const vault = new SecretVault(appDir);
|
|
12
|
+
async function askHidden(question) {
|
|
13
|
+
if (!input.isTTY) {
|
|
14
|
+
const rl = createInterface({ input, output });
|
|
15
|
+
try {
|
|
16
|
+
return await rl.question(question);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
rl.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let value = "";
|
|
24
|
+
const onData = (chunk) => {
|
|
25
|
+
for (const char of chunk.toString("utf8")) {
|
|
26
|
+
if (char === "\u0003") {
|
|
27
|
+
cleanup();
|
|
28
|
+
output.write("\n");
|
|
29
|
+
reject(new Error("Cancelled"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (char === "\r" || char === "\n") {
|
|
33
|
+
cleanup();
|
|
34
|
+
output.write("\n");
|
|
35
|
+
resolve(value);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (char === "\u007f" || char === "\b")
|
|
39
|
+
value = value.slice(0, -1);
|
|
40
|
+
else
|
|
41
|
+
value += char;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const cleanup = () => { input.off("data", onData); input.setRawMode(false); input.pause(); };
|
|
45
|
+
output.write(question);
|
|
46
|
+
input.setRawMode(true);
|
|
47
|
+
input.resume();
|
|
48
|
+
input.on("data", onData);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function usage() {
|
|
52
|
+
console.log(`Usage: bivy secrets <list|set|ref|delete|doctor|resolve>
|
|
53
|
+
|
|
54
|
+
Commands:
|
|
55
|
+
bivy secrets list
|
|
56
|
+
bivy secrets set <id> [value] Store an encrypted local secret (prompts when value is omitted)
|
|
57
|
+
bivy secrets ref <id> <op://...> Store a 1Password reference instead of the raw secret
|
|
58
|
+
bivy secrets ref <id> <env://VAR> Store an environment-variable reference
|
|
59
|
+
bivy secrets delete <id>
|
|
60
|
+
bivy secrets doctor
|
|
61
|
+
bivy secrets resolve <id> Verify a secret resolves without printing it
|
|
62
|
+
|
|
63
|
+
Common ids:
|
|
64
|
+
github.repo-token
|
|
65
|
+
model.anthropic
|
|
66
|
+
model.openai
|
|
67
|
+
integration.github
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
async function main() {
|
|
71
|
+
const [cmd, id, ...rest] = process.argv.slice(2);
|
|
72
|
+
if (!cmd || cmd === "help" || cmd === "--help") {
|
|
73
|
+
usage();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (cmd === "list") {
|
|
77
|
+
const records = vault.list();
|
|
78
|
+
if (records.length === 0) {
|
|
79
|
+
console.log("No Bivy secrets configured.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
const target = record.backend === "local" ? "encrypted local" : record.ref;
|
|
84
|
+
console.log(`${record.id}\t${record.backend}\t${target}\tupdated ${record.updatedAt}`);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (cmd === "set") {
|
|
89
|
+
if (!id)
|
|
90
|
+
throw new Error("Missing secret id.");
|
|
91
|
+
const value = rest.length ? rest.join(" ") : await askHidden(`Secret value for ${id}: `);
|
|
92
|
+
vault.setLocal(id, value);
|
|
93
|
+
console.log(`✓ Stored ${id} in the encrypted local Bivy vault (${vault.file}).`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (cmd === "ref") {
|
|
97
|
+
if (!id || rest.length === 0)
|
|
98
|
+
throw new Error("Usage: bivy secrets ref <id> <op://...|env://VAR>");
|
|
99
|
+
const ref = rest.join(" ").trim();
|
|
100
|
+
vault.setReference(id, ref);
|
|
101
|
+
console.log(`✓ Stored ${id} as ${ref}. Bivy will resolve it at runtime; the raw secret is not stored by Bivy.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (cmd === "delete" || cmd === "rm") {
|
|
105
|
+
if (!id)
|
|
106
|
+
throw new Error("Missing secret id.");
|
|
107
|
+
const removed = vault.delete(id);
|
|
108
|
+
console.log(removed ? `✓ Deleted ${id}.` : `No secret named ${id}.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (cmd === "resolve") {
|
|
112
|
+
if (!id)
|
|
113
|
+
throw new Error("Missing secret id.");
|
|
114
|
+
const value = await vault.resolve(id);
|
|
115
|
+
if (!value)
|
|
116
|
+
throw new Error(`${id} did not resolve to a value.`);
|
|
117
|
+
console.log(`✓ ${id} resolves (${value.length} bytes).`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (cmd === "doctor") {
|
|
121
|
+
const result = await vault.doctor();
|
|
122
|
+
for (const check of result.checks)
|
|
123
|
+
console.log(`${check.ok ? "✓" : "!"} ${check.name}: ${check.detail}`);
|
|
124
|
+
if (!result.ok)
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
usage();
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
main().catch((error) => {
|
|
132
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
function nowIso() { return new Date().toISOString(); }
|
|
11
|
+
function empty() { return { version: 1, records: {} }; }
|
|
12
|
+
// The vault's LAST-RESORT default (used only by `new SecretVault()` with no
|
|
13
|
+
// appDir — every daemon path passes an explicit one). Deliberately the home dir,
|
|
14
|
+
// NOT the daemon's <install>/.bivy: the vault refuses to mint a master key inside
|
|
15
|
+
// a git working tree, and <install>/.bivy is inside the repo in a dev checkout.
|
|
16
|
+
export function defaultSecretsDir(appDir) {
|
|
17
|
+
if (appDir)
|
|
18
|
+
return appDir;
|
|
19
|
+
return process.env.BIVY_DATA_DIR || path.join(os.homedir(), ".bivy");
|
|
20
|
+
}
|
|
21
|
+
/** True if `dir`, or any of its parents, is a git working tree (has a `.git` entry). */
|
|
22
|
+
function isInsideGitWorkTree(dir) {
|
|
23
|
+
let current = path.resolve(dir);
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (fs.existsSync(path.join(current, ".git")))
|
|
26
|
+
return true;
|
|
27
|
+
const parent = path.dirname(current);
|
|
28
|
+
if (parent === current)
|
|
29
|
+
return false;
|
|
30
|
+
current = parent;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function normalizeSecretId(id) {
|
|
34
|
+
const value = String(id || "").trim();
|
|
35
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,127}$/.test(value)) {
|
|
36
|
+
throw new Error("Secret id must be 1-128 chars: letters, numbers, dot, dash, underscore, colon, slash, or @.");
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function fileMode(file, mode) {
|
|
41
|
+
try {
|
|
42
|
+
fs.chmodSync(file, mode);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
function readJson(file) {
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = fs.readFileSync(file, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// Only a missing file means "nothing stored yet". Any other read failure
|
|
53
|
+
// (permission denied, I/O error, etc.) must not be treated as empty.
|
|
54
|
+
if (error?.code === "ENOENT")
|
|
55
|
+
return empty();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
return { version: 1, records: parsed.records && typeof parsed.records === "object" ? parsed.records : {} };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The file exists but is corrupt/truncated. Overwriting it with an empty
|
|
64
|
+
// record set would silently discard every stored secret reference, so
|
|
65
|
+
// fail loudly instead and let the caller decide how to recover.
|
|
66
|
+
throw new Error(`Secrets file at ${file} is corrupt and could not be parsed. Refusing to reset it to empty. ` +
|
|
67
|
+
`Restore it from a backup, or remove the file manually if you intend to start fresh.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function writeJson(file, data) {
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
72
|
+
const tmp = `${file}.tmp`;
|
|
73
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
74
|
+
fileMode(tmp, 0o600);
|
|
75
|
+
fs.renameSync(tmp, file);
|
|
76
|
+
fileMode(file, 0o600);
|
|
77
|
+
}
|
|
78
|
+
export class SecretVault {
|
|
79
|
+
dir;
|
|
80
|
+
file;
|
|
81
|
+
keyFile;
|
|
82
|
+
data;
|
|
83
|
+
constructor(appDir = defaultSecretsDir()) {
|
|
84
|
+
this.dir = appDir;
|
|
85
|
+
this.file = path.join(appDir, "secrets.json");
|
|
86
|
+
this.keyFile = path.join(appDir, "secrets.key");
|
|
87
|
+
this.data = readJson(this.file);
|
|
88
|
+
}
|
|
89
|
+
reload() { this.data = readJson(this.file); }
|
|
90
|
+
list() {
|
|
91
|
+
this.reload();
|
|
92
|
+
return Object.values(this.data.records)
|
|
93
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
94
|
+
.map(({ ciphertext: _ciphertext, tag: _tag, ...record }) => ({ ...record, configured: true }));
|
|
95
|
+
}
|
|
96
|
+
getRecord(id) {
|
|
97
|
+
this.reload();
|
|
98
|
+
return this.data.records[normalizeSecretId(id)];
|
|
99
|
+
}
|
|
100
|
+
async resolve(idOrRef) {
|
|
101
|
+
const value = String(idOrRef || "").trim();
|
|
102
|
+
if (!value)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (value.startsWith("secret://"))
|
|
105
|
+
return this.resolve(value.slice("secret://".length));
|
|
106
|
+
if (value.startsWith("env://"))
|
|
107
|
+
return process.env[value.slice("env://".length)] || undefined;
|
|
108
|
+
if (value.startsWith("op://"))
|
|
109
|
+
return readOnePassword(value);
|
|
110
|
+
const record = this.getRecord(value);
|
|
111
|
+
if (!record)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (record.backend === "env")
|
|
114
|
+
return record.ref ? process.env[record.ref.replace(/^env:\/\//, "")] || undefined : undefined;
|
|
115
|
+
if (record.backend === "1password")
|
|
116
|
+
return record.ref ? readOnePassword(record.ref) : undefined;
|
|
117
|
+
if (record.backend === "local")
|
|
118
|
+
return this.decryptRecord(record);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
setReference(id, ref, description) {
|
|
122
|
+
const normalized = normalizeSecretId(id);
|
|
123
|
+
const trimmed = String(ref || "").trim();
|
|
124
|
+
let backend;
|
|
125
|
+
if (trimmed.startsWith("op://"))
|
|
126
|
+
backend = "1password";
|
|
127
|
+
else if (trimmed.startsWith("env://"))
|
|
128
|
+
backend = "env";
|
|
129
|
+
else
|
|
130
|
+
throw new Error("Secret references must start with op:// or env://");
|
|
131
|
+
const prev = this.data.records[normalized];
|
|
132
|
+
const at = nowIso();
|
|
133
|
+
this.data.records[normalized] = {
|
|
134
|
+
id: normalized,
|
|
135
|
+
backend,
|
|
136
|
+
ref: trimmed,
|
|
137
|
+
description: description ?? prev?.description,
|
|
138
|
+
createdAt: prev?.createdAt ?? at,
|
|
139
|
+
updatedAt: at,
|
|
140
|
+
};
|
|
141
|
+
writeJson(this.file, this.data);
|
|
142
|
+
}
|
|
143
|
+
setLocal(id, plaintext, description) {
|
|
144
|
+
const normalized = normalizeSecretId(id);
|
|
145
|
+
const value = String(plaintext || "");
|
|
146
|
+
if (!value)
|
|
147
|
+
throw new Error("Secret value cannot be empty.");
|
|
148
|
+
const prev = this.data.records[normalized];
|
|
149
|
+
const at = nowIso();
|
|
150
|
+
const encrypted = this.encrypt(value);
|
|
151
|
+
this.data.records[normalized] = {
|
|
152
|
+
id: normalized,
|
|
153
|
+
backend: "local",
|
|
154
|
+
description: description ?? prev?.description,
|
|
155
|
+
createdAt: prev?.createdAt ?? at,
|
|
156
|
+
updatedAt: at,
|
|
157
|
+
...encrypted,
|
|
158
|
+
};
|
|
159
|
+
writeJson(this.file, this.data);
|
|
160
|
+
}
|
|
161
|
+
set(id, valueOrRef, description) {
|
|
162
|
+
const value = String(valueOrRef || "").trim();
|
|
163
|
+
if (value.startsWith("op://") || value.startsWith("env://"))
|
|
164
|
+
this.setReference(id, value, description);
|
|
165
|
+
else
|
|
166
|
+
this.setLocal(id, valueOrRef, description);
|
|
167
|
+
}
|
|
168
|
+
delete(id) {
|
|
169
|
+
const normalized = normalizeSecretId(id);
|
|
170
|
+
const had = Boolean(this.data.records[normalized]);
|
|
171
|
+
if (had) {
|
|
172
|
+
delete this.data.records[normalized];
|
|
173
|
+
writeJson(this.file, this.data);
|
|
174
|
+
}
|
|
175
|
+
return had;
|
|
176
|
+
}
|
|
177
|
+
async doctor() {
|
|
178
|
+
const checks = [];
|
|
179
|
+
const dirExists = fs.existsSync(this.dir);
|
|
180
|
+
checks.push({ name: "secrets directory", ok: dirExists, detail: this.dir });
|
|
181
|
+
if (fs.existsSync(this.file))
|
|
182
|
+
checks.push({ name: "secrets file permissions", ok: modeIsPrivate(this.file), detail: this.file });
|
|
183
|
+
if (fs.existsSync(this.keyFile))
|
|
184
|
+
checks.push({ name: "local key file permissions", ok: modeIsPrivate(this.keyFile), detail: this.keyFile });
|
|
185
|
+
checks.push({ name: "1Password CLI", ok: await commandExists("op"), detail: "required for op:// references" });
|
|
186
|
+
checks.push({ name: "GitHub CLI", ok: await commandExists("gh"), detail: "fallback for GitHub tokens when no Bivy secret is configured" });
|
|
187
|
+
return { ok: checks.every((c) => c.ok || c.name === "1Password CLI" || c.name === "GitHub CLI"), checks };
|
|
188
|
+
}
|
|
189
|
+
key() {
|
|
190
|
+
try {
|
|
191
|
+
const raw = fs.readFileSync(this.keyFile, "utf8").trim();
|
|
192
|
+
const key = Buffer.from(raw, "base64");
|
|
193
|
+
if (key.length === 32)
|
|
194
|
+
return key;
|
|
195
|
+
// File exists but is malformed/truncated — surface it rather than silently
|
|
196
|
+
// minting a new key that makes every stored secret undecryptable.
|
|
197
|
+
throw new Error(`Local secrets key at ${this.keyFile} is invalid (expected 32 bytes)`);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// Only mint a fresh key when the file genuinely does not exist. A transient
|
|
201
|
+
// read failure (EMFILE, permission blip) must NOT regenerate the key.
|
|
202
|
+
if (error?.code !== "ENOENT")
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
if (isInsideGitWorkTree(this.dir)) {
|
|
206
|
+
throw new Error(`Refusing to create the local secrets master key inside a git working tree (${this.dir}). ` +
|
|
207
|
+
`Point BIVY_DATA_DIR (or the vault's appDir) somewhere outside of any git repository — ` +
|
|
208
|
+
`e.g. ${path.join(os.homedir(), ".bivy")}.`);
|
|
209
|
+
}
|
|
210
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
211
|
+
const key = randomBytes(32);
|
|
212
|
+
fs.writeFileSync(this.keyFile, `${key.toString("base64")}\n`, { mode: 0o600 });
|
|
213
|
+
fileMode(this.keyFile, 0o600);
|
|
214
|
+
return key;
|
|
215
|
+
}
|
|
216
|
+
encrypt(plaintext) {
|
|
217
|
+
const iv = randomBytes(12);
|
|
218
|
+
const cipher = createCipheriv("aes-256-gcm", this.key(), iv);
|
|
219
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
220
|
+
const tag = cipher.getAuthTag();
|
|
221
|
+
return { iv: iv.toString("base64"), tag: tag.toString("base64"), ciphertext: ciphertext.toString("base64") };
|
|
222
|
+
}
|
|
223
|
+
decryptRecord(record) {
|
|
224
|
+
if (!record.iv || !record.tag || !record.ciphertext)
|
|
225
|
+
return undefined;
|
|
226
|
+
const decipher = createDecipheriv("aes-256-gcm", this.key(), Buffer.from(record.iv, "base64"));
|
|
227
|
+
decipher.setAuthTag(Buffer.from(record.tag, "base64"));
|
|
228
|
+
return Buffer.concat([decipher.update(Buffer.from(record.ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function commandExists(command) {
|
|
232
|
+
const which = process.platform === "win32" ? "where" : "command";
|
|
233
|
+
const args = process.platform === "win32" ? [command] : ["-v", command];
|
|
234
|
+
try {
|
|
235
|
+
await exec(which, args, process.platform === "win32" ? {} : { shell: true });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function readOnePassword(ref) {
|
|
243
|
+
try {
|
|
244
|
+
const { stdout } = await exec("op", ["read", ref], { env: process.env });
|
|
245
|
+
return stdout.trim() || undefined;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
throw new Error(`Could not read ${ref} with 1Password CLI. Run 'op signin' and check the reference.`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function modeIsPrivate(file) {
|
|
252
|
+
if (os.platform() === "win32")
|
|
253
|
+
return true;
|
|
254
|
+
try {
|
|
255
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
256
|
+
return (mode & 0o077) === 0;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function resolveSecret(value, appDir) {
|
|
263
|
+
return new SecretVault(appDir).resolve(value);
|
|
264
|
+
}
|