@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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex credential materialization.
4
+ //
5
+ // A ChatGPT/Codex subscription a user connects *inside Bivy* lands in the shared
6
+ // node vault as an `openai-codex` OAuth record. The Codex CLI can't
7
+ // read that vault — it authenticates from `OPENAI_API_KEY` or its own
8
+ // `$CODEX_HOME/auth.json` (the file `codex login` writes). This module bridges the
9
+ // gap: it mints the file Codex expects from the vault record, so a subscription
10
+ // connected in Bivy "just works" for Codex with no separate `codex login`.
11
+ //
12
+ // Why it's sound: Pi's `openai-codex` OAuth app IS the Codex CLI's own OAuth app
13
+ // (identical client_id, token endpoint, and scopes — verified against the codex
14
+ // binary), so tokens minted through Bivy are accepted by Codex's backend. The
15
+ // vault stores {access, refresh, expires, accountId} but NOT the `id_token`
16
+ // Codex's auth.json requires; we recover it with a `refresh_token` grant. The
17
+ // original grant used `scope=openid`, so the refresh response carries a fresh
18
+ // `id_token` — the same call Codex itself makes to refresh.
19
+ //
20
+ // Rotation note: OpenAI rotates the refresh token on every grant, so we persist
21
+ // the rotated token back to the vault. We mint only when no auth.json exists yet
22
+ // (Codex then owns and self-refreshes it), which keeps churn to a single grant.
23
+ // The residual edge case — Codex self-refreshing later invalidates the vault's
24
+ // copy for *other* `openai-codex` consumers — is documented; for the common case
25
+ // (the subscription was connected for Codex) there are no other consumers.
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { createCredentialVault } from "./credential-store.js";
30
+ // Codex's own OAuth app — verified identical to the app Pi's `openai-codex` login
31
+ // uses, so a refresh grant minted here is honored by Codex's backend.
32
+ const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
33
+ const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
34
+ /** Resolve Codex's home dir exactly as the CLI (and our rollout reader) does. */
35
+ export function resolveCodexHome() {
36
+ return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
37
+ }
38
+ /**
39
+ * Pre-trust a workspace in Codex's `config.toml` so a `bivy run codex` launched
40
+ * there doesn't stall on the first-run "Do you trust the contents of this
41
+ * directory?" gate — which, unanswered, blocks Codex before it writes a rollout
42
+ * (and so blocks "continue as chat" takeover, which resumes by that rollout id).
43
+ *
44
+ * We only ADD a missing `[projects."<path>"]` trust entry; we never rewrite or
45
+ * downgrade an existing one (a user may have deliberately set a different level).
46
+ * Best-effort and idempotent: any failure just leaves the interactive prompt in
47
+ * place, exactly as before. Mirrors what choosing "Yes, continue" would persist.
48
+ */
49
+ export function ensureCodexTrusted(workspace) {
50
+ const dir = workspace?.trim();
51
+ if (!dir)
52
+ return;
53
+ try {
54
+ const codexHome = resolveCodexHome();
55
+ const configFile = path.join(codexHome, "config.toml");
56
+ let existing = "";
57
+ try {
58
+ existing = fs.readFileSync(configFile, "utf8");
59
+ }
60
+ catch { /* no config yet — we'll create one */ }
61
+ // TOML basic-string key: escape backslashes and double quotes.
62
+ const escaped = dir.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
63
+ const header = `[projects."${escaped}"]`;
64
+ // Already has a table header for this exact path → leave its trust_level be.
65
+ if (existing.split(/\r?\n/).some((line) => line.trim() === header))
66
+ return;
67
+ const prefix = existing.length && !existing.endsWith("\n") ? "\n" : "";
68
+ const block = `${prefix}\n${header}\ntrust_level = "trusted"\n`;
69
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
70
+ fs.appendFileSync(configFile, block);
71
+ }
72
+ catch { /* best effort — an untrusted prompt is a soft failure, not a hard one */ }
73
+ }
74
+ /** Exchange a refresh token for a fresh token set (incl. the `id_token`). */
75
+ async function refreshCodexTokens(refreshToken) {
76
+ let res;
77
+ try {
78
+ res = await fetch(CODEX_TOKEN_URL, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
81
+ body: new URLSearchParams({
82
+ grant_type: "refresh_token",
83
+ refresh_token: refreshToken,
84
+ client_id: CODEX_OAUTH_CLIENT_ID,
85
+ }),
86
+ });
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ if (!res.ok)
92
+ return undefined;
93
+ const j = (await res.json().catch(() => ({})));
94
+ const idToken = typeof j.id_token === "string" ? j.id_token : "";
95
+ const accessToken = typeof j.access_token === "string" ? j.access_token : "";
96
+ const refreshTok = typeof j.refresh_token === "string" ? j.refresh_token : "";
97
+ const expiresIn = typeof j.expires_in === "number" ? j.expires_in : 0;
98
+ if (!idToken || !accessToken || !refreshTok)
99
+ return undefined;
100
+ return { idToken, accessToken, refreshToken: refreshTok, expiresIn };
101
+ }
102
+ /**
103
+ * Ensure the Codex CLI has a usable credential file, minting one from Bivy's
104
+ * vault when needed. Returns the resolved `CODEX_HOME` (so the caller can pin it
105
+ * on the subprocess env) or `undefined` when there's nothing to do / no vault
106
+ * credential — in which case the caller's preflight surfaces the actionable
107
+ * "no credential" error unchanged.
108
+ *
109
+ * Idempotent and low-churn: if an `auth.json` already exists (a native
110
+ * `codex login` or a prior materialization) it is left untouched — Codex owns and
111
+ * self-refreshes it — so we mint at most once. We write to the *default* Codex
112
+ * home (never a throwaway dir) so Codex's rollouts stay where the session reader
113
+ * looks.
114
+ */
115
+ export async function ensureCodexAuth(credsDir) {
116
+ const codexHome = resolveCodexHome();
117
+ const authFile = path.join(codexHome, "auth.json");
118
+ // Never clobber an existing login (native or previously materialized); Codex
119
+ // owns and refreshes it. An OPENAI_API_KEY, if present, is handled by preflight.
120
+ if (fs.existsSync(authFile))
121
+ return codexHome;
122
+ if (process.env.OPENAI_API_KEY?.trim())
123
+ return undefined;
124
+ const store = createCredentialVault(credsDir);
125
+ const cred = await store.read("openai-codex").catch(() => undefined);
126
+ if (!cred || cred.type !== "oauth" || typeof cred.refresh !== "string" || !cred.refresh)
127
+ return undefined;
128
+ const refreshed = await refreshCodexTokens(cred.refresh);
129
+ if (!refreshed)
130
+ return undefined;
131
+ // Persist the rotated refresh token back to the vault FIRST — OpenAI rotates it
132
+ // on every grant, so the previous one is now dead. If we can't persist it, bail
133
+ // rather than strand the vault on a token we've just invalidated.
134
+ try {
135
+ await store.modify("openai-codex", async (current) => ({
136
+ ...(current ?? cred),
137
+ type: "oauth",
138
+ access: refreshed.accessToken,
139
+ refresh: refreshed.refreshToken,
140
+ expires: Date.now() + refreshed.expiresIn * 1000,
141
+ }));
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ const accountId = typeof cred.accountId === "string" ? cred.accountId : undefined;
147
+ const authJson = {
148
+ auth_mode: "chatgpt",
149
+ OPENAI_API_KEY: null,
150
+ tokens: {
151
+ id_token: refreshed.idToken,
152
+ access_token: refreshed.accessToken,
153
+ refresh_token: refreshed.refreshToken,
154
+ account_id: accountId,
155
+ },
156
+ last_refresh: new Date().toISOString(),
157
+ };
158
+ try {
159
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
160
+ const tmp = `${authFile}.tmp`;
161
+ fs.writeFileSync(tmp, `${JSON.stringify(authJson, null, 2)}\n`, { mode: 0o600 });
162
+ fs.renameSync(tmp, authFile);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ return codexHome;
168
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex credential preflight.
4
+ //
5
+ // The Codex CLI authenticates one of two ways: an OpenAI API key read from the
6
+ // `OPENAI_API_KEY` environment variable, or tokens in `CODEX_HOME/auth.json`
7
+ // (default `~/.codex/auth.json`). Bivy's shared credential vault forwards *API
8
+ // keys* to every agent (see credentials.ts), and `codex-auth.ts` mints the
9
+ // auth.json from a connected ChatGPT/Codex *subscription* — so both sign-in kinds
10
+ // now reach Codex.
11
+ //
12
+ // This preflight is the backstop: if neither an API key nor an auth.json is
13
+ // present after that handoff (e.g. no OpenAI sign-in at all, or a saved
14
+ // subscription that could not be refreshed), `codex exec` would fail with the
15
+ // opaque upstream error `unexpected status 401 Unauthorized: Missing bearer or
16
+ // basic authentication in header`. We detect that up front and return a clear,
17
+ // actionable message instead of letting the subprocess spawn and die on a 401.
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ /** Path to the Codex CLI's own auth file (`CODEX_HOME/auth.json`, default `~/.codex`). */
22
+ export function codexAuthFile(deps = {}) {
23
+ const codexHome = (deps.codexHome ?? process.env.CODEX_HOME)?.trim();
24
+ const base = codexHome ? codexHome : path.join(deps.home ?? os.homedir(), ".codex");
25
+ return path.join(base, "auth.json");
26
+ }
27
+ /** The user-facing message shown when Codex has no OpenAI credential. */
28
+ export const CODEX_NO_CREDENTIAL_MESSAGE = [
29
+ "Codex has no OpenAI credential on this node, so it can't start a turn — it would fail with `401 Unauthorized: Missing bearer or basic authentication in header`.",
30
+ "",
31
+ "Fix it any of these ways:",
32
+ "• Connect ChatGPT Plus/Pro (Codex) under Keys & OAuth — Bivy mints Codex's auth file from it automatically, or",
33
+ "• Add an OpenAI API key under Keys & OAuth — Bivy passes it to Codex as OPENAI_API_KEY, or",
34
+ "• Run `codex login` on this node to sign in directly.",
35
+ "",
36
+ "If you already connected the ChatGPT/Codex subscription in Bivy and still see this, the saved login could not be refreshed — reconnect it under Keys & OAuth.",
37
+ ].join("\n");
38
+ /**
39
+ * Returns a human-readable error when the Codex CLI has no usable OpenAI
40
+ * credential (no `OPENAI_API_KEY` in the resolved environment and no
41
+ * `codex login` auth file), or undefined when a credential is present so the
42
+ * turn should proceed. `env` is the environment the Codex subprocess will run
43
+ * with (ambient process env + Bivy's vault handoff), so this catches both an
44
+ * OpenAI API key stored in Bivy and one already present on the node.
45
+ */
46
+ export function codexCredentialPreflight(env, deps = {}) {
47
+ if (env.OPENAI_API_KEY?.trim())
48
+ return undefined;
49
+ const exists = deps.fileExists ?? ((p) => {
50
+ try {
51
+ return fs.existsSync(p);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ });
57
+ if (exists(codexAuthFile(deps)))
58
+ return undefined;
59
+ return CODEX_NO_CREDENTIAL_MESSAGE;
60
+ }
@@ -0,0 +1,229 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex on-disk session ("rollout") reader — the read side of adopting a Codex
4
+ // session that was started outside Bivy (e.g. a bare `codex` in a terminal).
5
+ //
6
+ // The Codex CLI ("@openai/codex") persists each session as a JSONL rollout under
7
+ // $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
8
+ // (CODEX_HOME defaults to ~/.codex). The first line carries session metadata
9
+ // (id, cwd, timestamp); subsequent lines are response/event items.
10
+ //
11
+ // IMPORTANT — best-effort, and NOT yet verified against a live Codex here:
12
+ // Codex's rollout record shape has changed across versions, so every read is
13
+ // defensive (unknown/malformed lines are skipped, never thrown), and both a
14
+ // wrapped (`{type,payload}`) and a flat record layout are tolerated. This module
15
+ // only *reconstructs history* and *locates* sessions; a governed live resume of a
16
+ // Codex session additionally needs the Codex runtime to gain resume +
17
+ // toolInterception (today it is a non-governing process runtime — see
18
+ // docs/agent-runtimes.md and src/runtime/index.ts `cliAgentInfo`). Unlike Claude,
19
+ // Codex has no stable launch-time session-id flag to pin, so adoption relies on
20
+ // locating the rollout a run produced (see discoverCodexSessionForCwd).
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { hasLiveProcessForCwd } from "./native-process-scan.js";
25
+ /** Binary names a live Codex process could be running under (see
26
+ * native-process-scan.ts's best-effort cwd match). */
27
+ const CODEX_BIN_NAMES = ["codex"];
28
+ export function codexHome() {
29
+ return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
30
+ }
31
+ export function codexSessionsDir() {
32
+ return path.join(codexHome(), "sessions");
33
+ }
34
+ /** Unwrap a `{ type, payload }` envelope, else return the record as-is. */
35
+ function inner(rec) {
36
+ const r = (rec ?? {});
37
+ return r.payload && typeof r.payload === "object" ? r.payload : r;
38
+ }
39
+ function toEpoch(value) {
40
+ if (value == null)
41
+ return undefined;
42
+ const ms = new Date(value).getTime();
43
+ return Number.isFinite(ms) ? ms : undefined;
44
+ }
45
+ /** Recursively collect `rollout-*.jsonl` files under a Codex sessions dir. */
46
+ function findRolloutFiles(root) {
47
+ const out = [];
48
+ const walk = (dir) => {
49
+ let entries;
50
+ try {
51
+ entries = fs.readdirSync(dir, { withFileTypes: true });
52
+ }
53
+ catch {
54
+ return; // missing/unreadable — best-effort
55
+ }
56
+ for (const entry of entries) {
57
+ const full = path.join(dir, entry.name);
58
+ if (entry.isDirectory())
59
+ walk(full);
60
+ else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name))
61
+ out.push(full);
62
+ }
63
+ };
64
+ walk(root);
65
+ return out;
66
+ }
67
+ /** Session metadata from a rollout's first line (id / cwd / start time). */
68
+ function metaFromFirstLine(line) {
69
+ try {
70
+ const rec = JSON.parse(line);
71
+ const p = inner(rec);
72
+ const id = typeof p.id === "string" ? p.id : typeof p.session_id === "string" ? p.session_id : undefined;
73
+ const cwd = typeof p.cwd === "string" ? p.cwd : undefined;
74
+ const createdAt = toEpoch(p.timestamp ?? rec.timestamp);
75
+ return { id, cwd, createdAt };
76
+ }
77
+ catch {
78
+ return {};
79
+ }
80
+ }
81
+ /** Extract plain text from a Codex content field (string or content-block array). */
82
+ function textOf(content) {
83
+ if (typeof content === "string")
84
+ return content;
85
+ if (!Array.isArray(content))
86
+ return "";
87
+ return content
88
+ .map((block) => {
89
+ const b = (block ?? {});
90
+ if (typeof b.text === "string")
91
+ return b.text;
92
+ return "";
93
+ })
94
+ .join("")
95
+ .trim();
96
+ }
97
+ /**
98
+ * Reconstruct a session's conversation from its rollout file as normalized
99
+ * RuntimeMessages (role + content + timestamp) — the same shape loadClaudeTranscript
100
+ * produces, so a reopened Codex session paints prior history the same way. Skips
101
+ * meta/system/tooling records; keeps user and assistant turns. Best-effort.
102
+ */
103
+ export function loadCodexTranscriptFile(file) {
104
+ const messages = [];
105
+ let raw;
106
+ try {
107
+ raw = fs.readFileSync(file, "utf8");
108
+ }
109
+ catch {
110
+ return messages;
111
+ }
112
+ for (const line of raw.split(/\r?\n/)) {
113
+ if (!line.trim())
114
+ continue;
115
+ let rec;
116
+ try {
117
+ rec = JSON.parse(line);
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const p = inner(rec);
123
+ // A message item: either {role, content} directly or an item with type "message".
124
+ const role = p.role ?? (p.type === "message" ? p.role : undefined);
125
+ if (role !== "user" && role !== "assistant")
126
+ continue;
127
+ const content = p.content ?? p.text;
128
+ const text = textOf(content);
129
+ if (!text)
130
+ continue;
131
+ messages.push({ role, content: text, timestamp: toEpoch(p.timestamp ?? rec.timestamp) ?? Date.now() });
132
+ }
133
+ return messages;
134
+ }
135
+ /** Enumerate Codex sessions on disk, newest first. Best-effort. */
136
+ export function listCodexSessions() {
137
+ const sessions = [];
138
+ for (const file of findRolloutFiles(codexSessionsDir())) {
139
+ let firstLine = "";
140
+ let mtime;
141
+ try {
142
+ mtime = fs.statSync(file).mtimeMs;
143
+ const fd = fs.readFileSync(file, "utf8");
144
+ firstLine = fd.split(/\r?\n/, 1)[0] ?? "";
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ const meta = metaFromFirstLine(firstLine);
150
+ const transcript = loadCodexTranscriptFile(file);
151
+ // Codex prepends synthetic context turns as the first "user" messages —
152
+ // `<environment_context>`, `<recommended_plugins>`, `<user_instructions>`,
153
+ // etc. — which make useless list labels. Prefer the first turn that isn't one
154
+ // of these XML-ish blocks (i.e. the user's real prompt), falling back to the
155
+ // first turn only if that's genuinely all there is.
156
+ const userTurns = transcript.filter((m) => m.role === "user");
157
+ const contentOf = (m) => String(m?.content ?? "");
158
+ const isInjectedBlock = (text) => /^<[a-z][\w-]*>/i.test(text.trimStart());
159
+ const firstUser = userTurns.find((m) => !isInjectedBlock(contentOf(m))) ?? userTurns[0];
160
+ const firstMessage = firstUser ? contentOf(firstUser).slice(0, 200) : undefined;
161
+ sessions.push({
162
+ id: meta.id,
163
+ file,
164
+ cwd: meta.cwd,
165
+ createdAt: meta.createdAt ?? mtime,
166
+ firstMessage,
167
+ });
168
+ }
169
+ return sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
170
+ }
171
+ /** Load a Codex session's transcript by its session id (searches the store). */
172
+ export function loadCodexTranscript(sessionId) {
173
+ const match = listCodexSessions().find((s) => s.id === sessionId);
174
+ return match ? loadCodexTranscriptFile(match.file) : [];
175
+ }
176
+ /**
177
+ * Delete a Codex session's rollout file by id so a user-initiated delete in the
178
+ * app actually removes it from Codex's store (`$CODEX_HOME/sessions/**`) — the
179
+ * read-side counterpart to loadCodexTranscript. Returns true if a rollout was
180
+ * unlinked. Best-effort: a missing store / already-gone file is not an error.
181
+ */
182
+ export function deleteCodexSession(sessionId) {
183
+ const match = listCodexSessions().find((s) => s.id === sessionId);
184
+ if (!match)
185
+ return false;
186
+ try {
187
+ fs.unlinkSync(match.file);
188
+ return true;
189
+ }
190
+ catch (error) {
191
+ if (error?.code === "ENOENT")
192
+ return false;
193
+ throw error;
194
+ }
195
+ }
196
+ /**
197
+ * Locate the Codex session a run produced, for adoption when there is no pinned
198
+ * id: the newest rollout whose recorded cwd matches `cwd` and that started at/after
199
+ * `since` (the run-terminal's creation time, minus a small skew). Returns the
200
+ * session summary or undefined. Best-effort — Codex assigns the id itself, so this
201
+ * is how a `bivy run codex` session is mapped back to its on-disk rollout.
202
+ */
203
+ export function discoverCodexSessionForCwd(cwd, since = 0) {
204
+ const target = path.resolve(cwd);
205
+ const skewMs = 5_000;
206
+ return listCodexSessions().find((s) => s.cwd != null && path.resolve(s.cwd) === target && (s.createdAt ?? 0) >= since - skewMs);
207
+ }
208
+ /**
209
+ * Enumerate Codex's on-disk sessions as bounded discovery metadata (issue
210
+ * #156's runtime-agnostic discovery/adoption flow — see
211
+ * native-session-discovery.ts). Every Codex session Codex itself assigned an
212
+ * id to is resumable via `codex exec resume <id>`, so `resumable` is true
213
+ * whenever an id was recorded; `active` is a best-effort live-process check
214
+ * scoped to this session's own cwd, never its transcript content.
215
+ */
216
+ export function discoverNativeCodexSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, CODEX_BIN_NAMES)) {
217
+ return listCodexSessions()
218
+ .filter((s) => Boolean(s.id))
219
+ .map((s) => ({
220
+ runtimeId: "codex-approvals",
221
+ ref: s.id,
222
+ file: s.file,
223
+ cwd: s.cwd,
224
+ updatedAt: s.createdAt,
225
+ title: s.firstMessage,
226
+ active: Boolean(s.cwd) && hasLiveProcess(s.cwd),
227
+ resumable: true,
228
+ }));
229
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ export class ControlPlaneSessionLocationRegistry {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ /** Map a durable row to a routable location, or undefined if not adoptable. */
9
+ toLocation(row) {
10
+ if (!row.sessionId || !row.agentServiceAddress)
11
+ return undefined;
12
+ const runtimeId = this.options.resolveRuntimeId(row.sessionId);
13
+ if (!runtimeId)
14
+ return undefined; // can't route an attach without the runtime id
15
+ return { sessionId: row.sessionId, agentServiceAddress: row.agentServiceAddress, runtimeId, nodeId: this.options.nodeId };
16
+ }
17
+ async lookup(sessionId) {
18
+ if (!sessionId)
19
+ return undefined;
20
+ const rows = await this.options.fetchNodeSessions().catch(() => []);
21
+ const row = rows.find((r) => r.sessionId === sessionId);
22
+ return row ? this.toLocation(row) : undefined;
23
+ }
24
+ /**
25
+ * Every adoptable session this node currently owns in the control plane (has a
26
+ * host address AND a resolvable runtime id). Startup adoption iterates this and
27
+ * re-attaches to each. Never throws — a fetch failure yields `[]`.
28
+ */
29
+ async listNode() {
30
+ const rows = await this.options.fetchNodeSessions().catch(() => []);
31
+ const out = [];
32
+ for (const row of rows) {
33
+ const location = this.toLocation(row);
34
+ if (location)
35
+ out.push(location);
36
+ }
37
+ return out;
38
+ }
39
+ // Writes are owned by advertiseSessions (the replace-all POST to /node/sessions
40
+ // is the single writer of session_index); this is a read-through view.
41
+ async record(_location) {
42
+ /* no-op: advertise owns control-plane writes */
43
+ }
44
+ async forget(_sessionId) {
45
+ /* no-op: advertise owns control-plane writes */
46
+ }
47
+ }
48
+ /**
49
+ * Layers two registries so a lookup prefers the fast, authoritative in-memory
50
+ * entry (this daemon's own live sessions) and falls through to a shared,
51
+ * durable one (the control plane) only on a miss — exactly the "in-memory first,
52
+ * then control-plane" ordering Stage 3 needs so nothing regresses when the
53
+ * process already knows a session. Writes (`record`/`forget`) go to the primary
54
+ * (in-memory) layer; the control-plane layer's writes are no-ops by design.
55
+ */
56
+ export class LayeredSessionLocationRegistry {
57
+ primary;
58
+ fallback;
59
+ constructor(primary, fallback) {
60
+ this.primary = primary;
61
+ this.fallback = fallback;
62
+ }
63
+ async record(location) {
64
+ await this.primary.record(location);
65
+ await this.fallback.record(location);
66
+ }
67
+ async lookup(sessionId) {
68
+ return (await this.primary.lookup(sessionId)) ?? (await this.fallback.lookup(sessionId));
69
+ }
70
+ async forget(sessionId) {
71
+ await this.primary.forget(sessionId);
72
+ await this.fallback.forget(sessionId);
73
+ }
74
+ }
@@ -0,0 +1,122 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Ingest credentials FROM an agent's own store INTO Bivy's vault.
4
+ //
5
+ // The reverse of provisioning: when a user logs in inside an agent's native CLI
6
+ // (e.g. `codex login`, or a login done in Pi's TUI), Bivy folds that credential
7
+ // back into its vault so the one login is unified everywhere. Merge is
8
+ // rotation-safe (freshest `expires` wins), so it's safe even when both Bivy and
9
+ // the agent have touched the same credential.
10
+ //
11
+ // Triggered on `bivy run <agent>` terminal exit (server.ts) and by the auth.json
12
+ // watcher; each agent contributes a small format adapter here.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { createCredentialVault } from "./credential-store.js";
16
+ import { resolveCodexHome } from "./codex-auth.js";
17
+ import { claudeCredentialFiles } from "./anthropic-preflight.js";
18
+ /** Read the `exp` (seconds) claim from a JWT access token → absolute epoch ms. */
19
+ function jwtExpiryMs(token) {
20
+ const parts = token.split(".");
21
+ if (parts.length < 2 || !parts[1])
22
+ return undefined;
23
+ try {
24
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
25
+ const exp = Number(payload.exp);
26
+ return Number.isFinite(exp) && exp > 0 ? exp * 1000 : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ /** Map Codex's `~/.codex/auth.json` into a Bivy credential (provider id → credential). */
33
+ export function codexAuthToCredential(raw) {
34
+ if (!raw || typeof raw !== "object")
35
+ return undefined;
36
+ const record = raw;
37
+ // API-key mode: `codex login --api-key` writes OPENAI_API_KEY.
38
+ if (typeof record.OPENAI_API_KEY === "string" && record.OPENAI_API_KEY.trim()) {
39
+ return { providerId: "openai", credential: { type: "api_key", key: record.OPENAI_API_KEY.trim() } };
40
+ }
41
+ // ChatGPT subscription: the OAuth token set the CLI persists.
42
+ const tokens = record.tokens;
43
+ const access = typeof tokens?.access_token === "string" ? tokens.access_token : "";
44
+ const refresh = typeof tokens?.refresh_token === "string" ? tokens.refresh_token : "";
45
+ if (!access || !refresh)
46
+ return undefined;
47
+ const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
48
+ const expires = jwtExpiryMs(access) ?? Date.now() + 60 * 60 * 1000;
49
+ return {
50
+ providerId: "openai-codex",
51
+ credential: { type: "oauth", access, refresh, expires, ...(accountId ? { accountId } : {}) },
52
+ };
53
+ }
54
+ /** Fold a login/refresh done in the Codex CLI back into Bivy's vault. Returns imported count. */
55
+ export async function ingestCodexCredentials(credsDir) {
56
+ let raw;
57
+ try {
58
+ raw = JSON.parse(fs.readFileSync(path.join(resolveCodexHome(), "auth.json"), "utf8"));
59
+ }
60
+ catch {
61
+ return 0; // no Codex login on disk
62
+ }
63
+ const mapped = codexAuthToCredential(raw);
64
+ if (!mapped)
65
+ return 0;
66
+ return createCredentialVault(credsDir).importAll({ [mapped.providerId]: mapped.credential });
67
+ }
68
+ /** Map the Claude CLI's `.credentials.json` (`claudeAiOauth`) into a Bivy credential. */
69
+ export function claudeAuthToCredential(raw) {
70
+ if (!raw || typeof raw !== "object")
71
+ return undefined;
72
+ const oauth = raw.claudeAiOauth;
73
+ const access = typeof oauth?.accessToken === "string" ? oauth.accessToken : "";
74
+ const refresh = typeof oauth?.refreshToken === "string" ? oauth.refreshToken : "";
75
+ if (!access || !refresh)
76
+ return undefined;
77
+ // Claude stores `expiresAt` as an absolute epoch-ms timestamp already.
78
+ const expires = Number(oauth?.expiresAt) || Date.now() + 60 * 60 * 1000;
79
+ return { providerId: "anthropic", credential: { type: "oauth", access, refresh, expires } };
80
+ }
81
+ /** Fold a login done in the Claude CLI (`claude /login`) back into Bivy's vault. */
82
+ export async function ingestClaudeCredentials(credsDir) {
83
+ for (const file of claudeCredentialFiles()) {
84
+ let raw;
85
+ try {
86
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
87
+ }
88
+ catch {
89
+ continue; // not this path (or macOS Keychain — no file to read)
90
+ }
91
+ const mapped = claudeAuthToCredential(raw);
92
+ if (mapped)
93
+ return createCredentialVault(credsDir).importAll({ [mapped.providerId]: mapped.credential });
94
+ }
95
+ return 0;
96
+ }
97
+ /**
98
+ * Ingest an agent's native credential store into Bivy's vault. Called when a
99
+ * `bivy run <agent>` terminal exits, so a login done inside that agent's CLI is
100
+ * captured centrally. Best-effort and per-agent; unknown agents are a no-op.
101
+ *
102
+ * Gemini is intentionally absent: its `~/.gemini` login is a Google account
103
+ * (Code Assist) token, a different auth model from an api key / subscription
104
+ * OAuth Bivy can provision back — folding it in would be misleading.
105
+ */
106
+ export async function ingestAgentCredentials(agentId, credsDir, piDir) {
107
+ switch ((agentId ?? "").toLowerCase()) {
108
+ case "codex":
109
+ case "codex-approvals":
110
+ return ingestCodexCredentials(credsDir);
111
+ case "claude":
112
+ case "claude-code":
113
+ case "claude-code-sdk":
114
+ return ingestClaudeCredentials(credsDir);
115
+ case "pi":
116
+ // Pi's TUI writes a plaintext auth.json in its own dir; fold it back into
117
+ // the shared vault.
118
+ return createCredentialVault(credsDir, piDir).ingestPlaintext();
119
+ default:
120
+ return 0;
121
+ }
122
+ }