genetics-mcp 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ # Native Codex sessions
2
+
3
+ The native adapter reads `~/.codex/sessions/YYYY/MM/DD/rollout-*-UUID.jsonl`.
4
+ The filename UUID must match the first complete `session_meta.payload.id`; cwd must
5
+ be absolute. Symlinks are excluded. `CODEX_THREAD_ID` identifies the current child
6
+ before the older `CODEX_SESSION_ID` fallback. Tenant ownership still comes from
7
+ the API or an explicit human choice, never from the source directory or cwd.
8
+
9
+ `push_session` keeps the existing arguments (`tenant`, `uuid`, `title`, `dry_run`).
10
+ Native user messages, assistant commentary/final messages, named tool calls, and
11
+ masked tool-result placeholders are projected to the existing portal wire schema.
12
+ Developer/system instructions, assistant analysis, event duplicates, and compacted
13
+ internal context are excluded. Images have explicit omission placeholders; all
14
+ surrounding human text is retained. Arbitrary shell-write inference is outside this
15
+ adapter. Source metadata is preserved as `tools.client=codex/<cli_version>`,
16
+ `tools.source_format=codex-rollout`, and `tools.codec=codex-rollout-v1`.
17
+
18
+ The canonical Genetics scrubber runs before caching and again before upload.
19
+ The private cache (`codex-projections` beside the credential file) has mode 0700;
20
+ files have mode 0600. It is not a Claude transcript or a native restore archive.
21
+ Only cwd markers retain local raw paths so the existing private-folder checks see
22
+ every source cwd before upload. Appended complete records extend the same projection
23
+ inode; partial final records are deferred. Source replacement, shrinkage, prefix or
24
+ seam changes rebuild the projection. The typed projection has its own codec revision
25
+ and scrub revision. Genetics append/MAC coordinates refer to this projection, while
26
+ the local receipt separately tracks native source offsets and byte counts.
27
+
28
+ Bounds: source 8 GiB, retained record 4 MiB, projection 64 MiB, private cache 512 MiB
29
+ and 256 sessions, source inventory 10,000 files/4,096 directories, one cache writer.
30
+ Oversized image values are omitted lexically without decoding their bodies.
31
+ Oversized tool results/internal records are omitted with counters. Oversized retained
32
+ human text or tool input, malformed retained JSON/UTF-8, ambiguous UUIDs, or exhausted
33
+ bounds fail before upload. No arbitrary middle-file rewrite guarantee is made: native
34
+ rollouts are append streams, with explicit prefix/seam checks on reuse. A crashed
35
+ cache writer is reclaimed only when its recorded PID is dead on this host; a lock
36
+ without a valid owner requires local inspection. Cache-limit errors require removal
37
+ of unused projections (subsequent uploads safely rebuild them).
38
+
39
+ Automatic discovery excludes child/fork/internal sources. It observes the existing
40
+ effective confirmed-folder flag, consent notice, activation cutoff, private-folder
41
+ and server-policy gates. Native sources have no legacy automatic-upload fallback.
42
+ A manual upload does not enable global or folder automatic upload, and does not
43
+ authorize backfilling other sessions. If the server's global switch is off, future
44
+ native session-body uploads remain off. Session heartbeat/world observations are
45
+ separate from publishing a body or finishing a work task.
46
+
47
+ Use a fresh MCP process when the installed server is still warm on an older module:
48
+
49
+ ```sh
50
+ node mcp/codex-session-cli.mjs whoami
51
+ node mcp/codex-session-cli.mjs dry-run --tenant <verified-slug> --uuid <selected-uuid> --title <title>
52
+ node mcp/codex-session-cli.mjs publish --tenant <verified-slug> --uuid <selected-uuid> --title <title>
53
+ node mcp/codex-session-cli.mjs list --tenant <verified-slug> --page 1
54
+ node mcp/codex-session-cli.mjs watch --tenant <verified-slug> --uuid <selected-uuid> --seconds 60
55
+ ```
56
+
57
+ The runner initializes `clientInfo.name=codex`, then invokes only the selected tool
58
+ through stdio. Existing SSO credentials remain in the child process. Session-only
59
+ mode disables installs, timers, mail/world response side effects, and closing beats;
60
+ its only tools are `whoami`, `list_sessions`, and explicit `push_session`. The runner
61
+ does not toggle policy or infer the tenant. `resume_session` reads published context;
62
+ native `codex resume` reconstruction is not supported by this projection.
63
+
64
+ Watch runs a fresh normal heartbeat/auto-upload loop for only the selected UUID.
65
+ It drains protocol notifications, disables installation/mail/world/observe side
66
+ effects, compares initial/final selected-tenant lists, and terminates its own child.
67
+ It never overrides global/folder policy, local opt-out, activation cutoff, or the
68
+ existing 90-second upload interval. Start after that interval when validating an
69
+ automatic update immediately following a manual publication. The warm installed
70
+ MCP process is unaffected. Image counters count source image_url values, including
71
+ duplicates inside omitted tool/internal context, rather than unique attachments.
72
+
73
+ `node mcp/codex-session.test.mjs` exercises the real upload function and fresh stdio
74
+ runner against an isolated localhost API with synthetic person credentials. It is
75
+ transport/codec evidence, not production publication or human acceptance evidence.
76
+
77
+
78
+ ## Native Codex mail
79
+
80
+ The local stdio `mail` tool uses the strict native child UUID. It does not require
81
+ Claude's ListAgents identity, invent a sender name, or trust `clientInfo.name` or a
82
+ caller-provided sender. The existing server registration authenticates the person
83
+ and labels the UUID. Claude and Cursor keep their existing identity rules. This is
84
+ Genetics addressed agent mail (session/brain/team addresses), not an SMTP sender.
85
+ Implementing this support does not authorize sending a message to any real person.
86
+
87
+ Codex keeps the default hookless MCP poll every 60 seconds. Received text is appended
88
+ to the next MCP tool response; a UUID alone never disables that fallback. Optional
89
+ local hooks require an explicit `gcell install-hooks --codex` preview and `--apply`
90
+ installation, followed by Codex's own `/hooks` trust approval. Installed host hooks
91
+ and actual model delivery have not been verified by the synthetic tests here.
92
+ Hosted HTTP MCP connections do not install these local command hooks.
93
+
94
+ The opt-in PostToolUse hook is synchronous and emits only the official
95
+ `hookSpecificOutput: {hookEventName:"PostToolUse",additionalContext:...}` object.
96
+ It shares an eight-second check throttle. Stop checks mail first even without file
97
+ changes and may request a continuation using `decision:"block",reason:...`.
98
+ `stop_hook_active` reentry cannot pull or acknowledge another batch. Async completion
99
+ while idle cannot create a new Codex turn, so neither Claude PostToolBatch nor
100
+ asyncRewake is installed for Codex. The command timeout is five seconds; registry
101
+ registration, pull and ACK share a three-second monotonic deadline, including curl
102
+ startup, pipe reading and child supervision, with at most 250 ms cleanup grace.
103
+ Successful complete bodies over 256 KiB or configuration stdin over 4 KiB fail closed.
104
+ The native Rust mail hooks currently fail closed outside Unix. Windows keeps the
105
+ original MCP receive path; shared Unix spool guarantees below do not apply to that
106
+ legacy Windows path. No Windows hook acceptance is claimed.
107
+
108
+ A hook requires a strict UUID, agreement with an available CODEX_THREAD_ID, and a
109
+ native rollout whose first session_meta, filename UUID and repository cwd match.
110
+ An explicit transcript must be that same discovered owned regular file. Symlink
111
+ rollouts, foreign sessions, Claude/Cursor payloads and unconfirmed folder bindings
112
+ cannot select a Codex mailbox. The tenant is the server-confirmed folder binding.
113
+ MCP uses the actual server tenant_id from its index or successful mail receipt.
114
+ Missing IDs are not guessed. Before any pending ACK, the same server must approve
115
+ registration of that native UUID with an empty sender name and client `codex`; its
116
+ returned tenant_id and session must match the pinned key before any pull or ACK.
117
+
118
+ ### Shared journal protocol (version 1)
119
+
120
+ Both Node and Rust use the actual credential file's parent directory, then
121
+ `mail-receive/v1-<key>.json`. The mail-only Rust resolver mirrors Node: environment
122
+ overrides api.env, api.env base overrides credential.base, and GENETICS_ENV changes
123
+ the default credential directory. The endpoint must be an exact permitted Genetics
124
+ API hostname over HTTPS or exact localhost/127.0.0.1 loopback. The new Rust mail
125
+ resolver rejects userinfo, malformed authority and localhost suffix tricks; it does
126
+ not change the existing Claude/Cursor resolver. Credentials and base are never logged.
127
+
128
+ The key is hexadecimal SHA256 of this UTF-8 byte sequence, with literal NUL separators:
129
+
130
+ ```
131
+ base-with-all-trailing-slashes-removed\0tenant_id-decimal\0lowercase-UUID\0SHA256(token)-hex
132
+ ```
133
+
134
+ Including the credential digest prevents a later login in the same file from reading
135
+ an earlier person's pending batch. Tenant aliases map to the same server ID. Different
136
+ server bases, tenant IDs, native UUIDs or token values have separate journals.
137
+
138
+ The journal has exactly `{version:1,last_checked_ms:<epoch integer>,pending:null}`,
139
+ or `pending:{ids:[...],says:"..."}`. IDs are unique positive safe integers, at most 20;
140
+ says is nonempty UTF-8 server-rendered text at most 64 KiB. Unknown fields, incomplete
141
+ JSON, invalid UTF-8, future timestamps, duplicate IDs or invalid bounds fail closed.
142
+ Files are owned regular files with no symlink and mode 0600, inside an owned 0700
143
+ directory. Node reads at most max+1 bytes; Rust uses the same finite read bound.
144
+
145
+ The nonwaiting `<journal>.lock` is created exclusively with exactly
146
+ `{version:1,pid:<positive PID>,host:"...",nonce:"<nonempty string, max128 bytes>"}`.
147
+ Node uses a random hex nonce; Rust uses its nanosecond nonce. A matching token alone
148
+ can remove its lock. Age never steals a live writer. Only a valid recorded dead PID
149
+ on the same host can be reclaimed, under a separately exclusive `.lock.reap` file.
150
+ An invalid owner, foreign host, live process, or interrupted reaper is fail closed
151
+ and requires local inspection; it is not removed automatically.
152
+
153
+ A directory-wide `.inventory.lock` serializes admission and atomic state writes.
154
+ At most 128 journal files, 16 MiB total bytes including atomic-write overlap, and a
155
+ 510-entry admission scan are allowed. The scan reads at most 510 entries; busy
156
+ inventory is not waited on. Pending batches are never evicted. At capacity, inspect
157
+ and remove only obsolete journals with pending null while the clients are stopped;
158
+ retain undelivered pending data. Every update uses an exclusive private temporary
159
+ file, a complete bounded write (including partial-write checks), file fsync, rename
160
+ and directory fsync. Mail never rewrites the unlocked
161
+ file-author/wake State used by the existing hooks.
162
+
163
+ Pull saves the complete server-rendered batch before ACK. Failure, a lost ACK response
164
+ or a dead writer retains that pending batch for an idempotent retry by either Node
165
+ or Rust; no local cursor skips it. A successful authorized ACK must return matching
166
+ asked count and an integer stamped count in 0..asked. The API cannot distinguish
167
+ already delivered, expired or absent IDs when stamped is zero. This is an authorized
168
+ ACK attempt followed by one local emission, not proof of every ID's current server
169
+ delivery state. Clearing pending after successful ACK and before emission prevents
170
+ repeated continuation; a crash in that final gap can lose local delivery. The protocol
171
+ does not claim exactly-once model context or an idle wake guarantee.
172
+
173
+ ```sh
174
+ NODE_BIN=<absolute-real-node> GCELL_BIN=<owned-gcell> python3 cell/tests/native-codex-mail.py
175
+ ```
176
+
177
+ This exercises actual local hooks and three bounded
178
+ stdio processes against synthetic loopback mail. It covers single writer exclusion,
179
+ ACK-response loss and cross-language pending recovery, identities, private files,
180
+ aggregate limits, network deadlines and explicit hook configuration. The existing
181
+ `cell/tests/gate.sh` registers this check separately from the six legacy native checks. These tests do not send real mail,
182
+ install user hooks, test production authorization, or invoke a model.
Binary file
package/bin/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "aarch64-apple-darwin": "373bf4961f501e0423387e70a9c119d60ee92f09195b0f958d87be10e0a39429",
3
- "aarch64-unknown-linux-musl": "7c0284e65d5aae4543f8b7b84c8f08047985b0d15d429d0fc4745ba87e041215",
4
- "x86_64-apple-darwin": "b3f77b1ec64d2a68fa8f94976f1afabd2cb374b0bd93f8fe2298bc4c2c971c44",
5
- "x86_64-pc-windows-gnu": "fa35abad22a18a7cc0e8c52694550016a3b94dda2881074428d4c3eb7647ae0e",
6
- "x86_64-unknown-linux-musl": "eb9bcaa435884b2c0f598cf2a8a40f6bb559a3bf99ba1424e18afbfde2ca374a"
2
+ "aarch64-apple-darwin": "67c002cef4354855509ef329d0b7b2a4c4cf7c9f8d469205700824668aaad23f",
3
+ "aarch64-unknown-linux-musl": "5c96ab2cd1a8971462f9d7505fa5d64b59723c30a1b6fc92b57f06bfd36b102f",
4
+ "x86_64-apple-darwin": "fe636058430bdf32ddf10bfa901cac27db94c38664b572ef78963b5c79d4e843",
5
+ "x86_64-pc-windows-gnu": "0a9bb25cf6e617789ffd96dd2f87cdaff3d49a9b7c7f5dd99811a3ab075d546d",
6
+ "x86_64-unknown-linux-musl": "a0eb644986d0291db0fa91f76504d0d79607e2767e704d4ca419d9220a6be4a2"
7
7
  }
Binary file
Binary file
Binary file
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /** One explicit native-session operation through a fresh MCP stdio child. Credentials stay in the child. */
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const argv = process.argv.slice(2), operation = argv.shift();
7
+ if (!['dry-run', 'publish', 'whoami', 'list', 'watch'].includes(operation)) {
8
+ process.stderr.write('Usage: node codex-session-cli.mjs dry-run|publish --tenant <slug> --uuid <UUID> [--title <title>]\n'
9
+ + ' node codex-session-cli.mjs whoami | list --tenant <slug> [--page <n>]\n'
10
+ + ' node codex-session-cli.mjs watch --tenant <slug> --uuid <UUID> [--seconds 60]\n');
11
+ process.exit(2);
12
+ }
13
+ const options = {};
14
+ for (let i = 0; i < argv.length; i += 2) {
15
+ if (!['--tenant', '--uuid', '--title', '--page', '--seconds'].includes(argv[i]) || argv[i + 1] === undefined) throw new Error('Unknown or missing CLI option');
16
+ options[argv[i].slice(2)] = argv[i + 1];
17
+ }
18
+ if (operation !== 'whoami' && !options.tenant) throw new Error('Explicit tenant is required; it is never inferred from cwd');
19
+ if (['dry-run', 'publish', 'watch'].includes(operation) && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(options.uuid || '')) throw new Error('Explicit valid session UUID required');
20
+ const watching = operation === 'watch', seconds = Number(options.seconds || 60);
21
+ if (watching && (!Number.isInteger(seconds) || seconds < 1 || seconds > 60)) throw new Error('Watch seconds must be 1..60');
22
+ const watchDeadline = watching ? Date.now() + seconds * 1000 : 0;
23
+ const tool = operation === 'whoami' ? 'whoami' : operation === 'list' || watching ? 'list_sessions' : 'push_session';
24
+ const args = operation === 'whoami' ? {} : operation === 'list' || watching ? { tenant: options.tenant, limit: 100, page: Number(options.page || 1) }
25
+ : { tenant: options.tenant, uuid: options.uuid, ...(options.title ? { title: options.title } : {}), dry_run: operation === 'dry-run' };
26
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./genetics.mjs', import.meta.url))], {
27
+ stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, GENETICS_SESSION_ONLY: watching ? '' : '1',
28
+ GENETICS_SESSION_WATCH_UUID: watching ? options.uuid : '', ...(watching ? { CODEX_THREAD_ID: options.uuid } : {}),
29
+ GENETICS_NO_BEAT: watching ? '' : '1', GENETICS_NO_CELL: '1', GENETICS_NO_SKILL: '1',
30
+ ...(watching ? {} : { GENETICS_NO_AUTO_PUSH: '1' }) } });
31
+ let buffer = '', stderrBytes = 0, sequence = 0, notifications = 0;
32
+ const pending = new Map();
33
+ child.stdout.setEncoding('utf8');
34
+ child.stdout.on('data', chunk => {
35
+ buffer += chunk;
36
+ if (Buffer.byteLength(buffer) > 1024 ** 2) { child.kill(); return; }
37
+ for (let at; (at = buffer.indexOf('\n')) >= 0;) {
38
+ const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
39
+ try { const reply = JSON.parse(line); const task = pending.get(reply.id); if (task) { pending.delete(reply.id); task.resolve(reply); } else if (reply.method) notifications++; }
40
+ catch { child.kill(); }
41
+ }
42
+ });
43
+ child.stderr.on('data', chunk => { stderrBytes += chunk.length; if (stderrBytes > 1024 ** 2) child.kill();
44
+ // The server only emits operational messages here; never print inherited credentials/environment.
45
+ else process.stderr.write(chunk); });
46
+ child.on('exit', () => { for (const task of pending.values()) task.reject(new Error('MCP child exited before its reply')); pending.clear(); });
47
+ function rpc(method, params) {
48
+ return new Promise((resolve, reject) => {
49
+ const id = ++sequence, timeout = watching ? Math.max(1, watchDeadline - Date.now()) : 300_000;
50
+ const timer = setTimeout(() => { pending.delete(id); child.kill(); reject(new Error('MCP operation deadline exceeded')); }, timeout);
51
+ pending.set(id, { resolve: reply => { clearTimeout(timer); resolve(reply); }, reject: error => { clearTimeout(timer); reject(error); } });
52
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
53
+ });
54
+ }
55
+ try {
56
+ const initialized = await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'codex', version: 'native-session-cli-v1' } });
57
+ if (initialized.error) throw new Error(initialized.error.message);
58
+ const initial = await rpc('tools/call', { name: tool, arguments: args });
59
+ let reply = initial;
60
+ if (watching) {
61
+ if (initial.error || initial.result?.isError) throw new Error('Initial selected-tenant session list failed');
62
+ const notices = response => (response.result?.content || []).filter(item => item.type === 'text' && /^genetics:/.test(item.text || ''));
63
+ let shown = notices(initial);
64
+ // Resolve can finish after the first tool response. Emit its real notice before
65
+ // waiting for the automatic update; do not synthesize a consent flag.
66
+ for (let attempt = 0; !shown.length && attempt < 5 && watchDeadline - Date.now() > 600; attempt++) {
67
+ await new Promise(resolve => setTimeout(resolve, 250));
68
+ shown = notices(await rpc('tools/call', { name: 'whoami', arguments: {} }));
69
+ }
70
+ for (const notice of shown) process.stderr.write(notice.text + '\n');
71
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, watchDeadline - Date.now() - Math.min(5000, seconds * 250))));
72
+ reply = await rpc('tools/call', { name: tool, arguments: args });
73
+ }
74
+ process.stdout.write(JSON.stringify({ operation, tenant: options.tenant || null, uuid: options.uuid || null,
75
+ ...(watching ? { initial: initial.result, notifications_drained: notifications, window_seconds: seconds } : {}), result: reply.result || reply.error }) + '\n');
76
+ if (reply.error || reply.result?.isError) process.exitCode = 1;
77
+ } catch (e) { process.stderr.write(`${e.message}\n`); process.exitCode = 1; }
78
+ finally {
79
+ child.stdin.end();
80
+ if (child.exitCode === null) { const closed = new Promise(resolve => child.once('exit', resolve));
81
+ const kill = setTimeout(() => child.kill('SIGKILL'), 3000); await closed; clearTimeout(kill); }
82
+ }
@@ -0,0 +1,353 @@
1
+ /** Native Codex rollout → portal wire records. No Claude transcript is created.
2
+ * Raw offsets belong to the rollout; Genetics append offsets belong to this versioned projection.
3
+ * First scan is sequential and bounded; later scans read only complete appended records.
4
+ */
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+
10
+ export const CODEX_CODEC = 'codex-rollout-v1';
11
+ export const CODEX_LIMITS = Object.freeze({ input_bytes: 8 * 1024 ** 3, record_bytes: 4 * 1024 ** 2,
12
+ projection_bytes: 64 * 1024 ** 2, cache_bytes: 512 * 1024 ** 2, cache_entries: 256,
13
+ records: 1_000_000, files: 10_000, directories: 4096, chunk_bytes: 1024 ** 2 });
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const MASK = '[가림: 서버에 올리며 지움]';
16
+
17
+ export function codexSessionId(env = process.env) {
18
+ // A child has its own THREAD_ID; SESSION_ID can still name its parent.
19
+ const id = env.CODEX_THREAD_ID || env.CODEX_SESSION_ID || '';
20
+ return UUID.test(id) ? id.toLowerCase() : '';
21
+ }
22
+
23
+ function plainFile(file) {
24
+ try { const st = fs.lstatSync(file); return st.isFile() && !st.isSymbolicLink() ? st : null; } catch { return null; }
25
+ }
26
+
27
+ /** Fixed date depth, no symlinks, and an explicit inventory budget. */
28
+ export function codexRollouts(home = os.homedir()) {
29
+ const root = path.join(home, '.codex', 'sessions');
30
+ try { if (fs.lstatSync(path.join(home, '.codex')).isSymbolicLink()) return []; } catch { return []; }
31
+ const found = [];
32
+ let directories = 0, files = 0;
33
+ const visit = (dir, depth) => {
34
+ let entries;
35
+ try { if (!fs.lstatSync(dir).isDirectory() || fs.lstatSync(dir).isSymbolicLink()) return;
36
+ entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
37
+ if (++directories > CODEX_LIMITS.directories) throw new Error('Codex session directory inventory limit exceeded');
38
+ entries.sort((a, b) => b.name.localeCompare(a.name));
39
+ for (const entry of entries) {
40
+ if (entry.isSymbolicLink()) continue;
41
+ const file = path.join(dir, entry.name);
42
+ if (depth < 3 && entry.isDirectory() && (depth === 0 ? /^\d{4}$/ : /^(0[1-9]|[12]\d|3[01])$/).test(entry.name)) visit(file, depth + 1);
43
+ if (depth !== 3 || !entry.isFile()) continue;
44
+ if (++files > CODEX_LIMITS.files) throw new Error('Codex session file inventory limit exceeded');
45
+ const m = /^rollout-.*-([0-9a-f-]{36})\.jsonl$/i.exec(entry.name);
46
+ if (m && UUID.test(m[1])) found.push({ uuid: m[1].toLowerCase(), file });
47
+ }
48
+ };
49
+ visit(root, 0);
50
+ return found;
51
+ }
52
+
53
+ export function codexMetadata(file, uuid) {
54
+ const st = plainFile(file);
55
+ if (!st || st.size > CODEX_LIMITS.input_bytes || (process.getuid && st.uid !== process.getuid())) return null;
56
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
57
+ try {
58
+ const opened = fs.fstatSync(fd);
59
+ if (opened.ino !== st.ino || opened.dev !== st.dev || opened.uid !== st.uid) return null;
60
+ const bytes = Buffer.alloc(Math.min(st.size, 64 * 1024));
61
+ fs.readSync(fd, bytes, 0, bytes.length, 0);
62
+ const end = bytes.indexOf(10);
63
+ if (end < 0) return null; // active partial metadata is never a source
64
+ const r = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, end)));
65
+ const p = r.payload;
66
+ if (r.type !== 'session_meta' || !UUID.test(p?.id || '') || p.id.toLowerCase() !== uuid.toLowerCase()
67
+ || typeof p.cwd !== 'string' || !path.isAbsolute(p.cwd)) return null;
68
+ const version = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(p.cli_version || '') ? p.cli_version : 'unknown';
69
+ return { uuid: p.id.toLowerCase(), cwd: p.cwd, cli_version: version, source_ino: String(st.ino), source_dev: String(st.dev), source_uid: st.uid,
70
+ parent_thread_id: p.parent_thread_id || null, forked_from_id: p.forked_from_id || null,
71
+ thread_source: p.thread_source || null, source: p.source || null, file, size: st.size, mtime: st.mtimeMs };
72
+ } catch { return null; } finally { fs.closeSync(fd); }
73
+ }
74
+
75
+ export function findCodexRollout(uuid, home = os.homedir()) {
76
+ if (!UUID.test(uuid || '')) return null;
77
+ const named = codexRollouts(home).filter(r => r.uuid === uuid.toLowerCase());
78
+ const matches = named.map(r => codexMetadata(r.file, uuid)).filter(Boolean);
79
+ if (matches.length > 1) throw new Error(`Ambiguous native Codex session UUID: ${uuid}`);
80
+ if (named.length && !matches.length) throw new Error(`Native Codex source metadata/ownership/size cannot be verified: ${uuid}`);
81
+ return matches[0] || null;
82
+ }
83
+
84
+ export function codexHumanRoot(meta) {
85
+ return !meta.parent_thread_id && !meta.forked_from_id && (!meta.source || typeof meta.source !== 'object')
86
+ && (!meta.thread_source || meta.thread_source === 'user');
87
+ }
88
+
89
+ function textContent(p) {
90
+ if (!Array.isArray(p.content)) return [];
91
+ const blocks = [];
92
+ for (const b of p.content) {
93
+ if ((b?.type === 'input_text' || b?.type === 'output_text') && typeof b.text === 'string') {
94
+ if (p.role === 'user' && (/^\s*# AGENTS\.md instructions/.test(b.text) || /^\s*<INSTRUCTIONS>/.test(b.text))) continue;
95
+ const text = b.text.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, '').trim();
96
+ if (text) blocks.push({ type: 'text', text });
97
+ } else if (b?.type === 'input_image' || b?.type === 'image') blocks.push({ type: 'text', text: '[첨부 이미지 생략]' });
98
+ }
99
+ return blocks;
100
+ }
101
+
102
+ export function codexWireRecord(r, metadata, offset) {
103
+ const p = r?.payload;
104
+ if (!p || typeof p !== 'object') return null;
105
+ if ((r.type === 'session_meta' || r.type === 'turn_context') && typeof p.cwd === 'string')
106
+ return { type: 'system', cwd: p.cwd }; // scrub counts every source cwd before dropping this marker
107
+ if (r.type !== 'response_item') return null; // event_msg duplicates, reasoning, token accounting
108
+ const base = { uuid: crypto.createHash('sha256').update(`${metadata.uuid}:${offset}`).digest('hex').slice(0, 32),
109
+ timestamp: r.timestamp, cwd: metadata.cwd, source_client: 'codex' };
110
+ if (p.type === 'message') {
111
+ if (!['user', 'assistant'].includes(p.role) || (p.role === 'assistant' && p.channel != null && !['final', 'commentary', 'default'].includes(p.channel))) return null;
112
+ const content = textContent(p);
113
+ return content.length ? { ...base, type: p.role, message: { role: p.role, content } } : null;
114
+ }
115
+ if (p.type === 'custom_tool_call' || p.type === 'function_call') {
116
+ if (typeof p.name !== 'string' || typeof p.call_id !== 'string') throw new Error('Codex tool call lacks name/call_id');
117
+ let input = { source: String(p.input ?? p.arguments ?? '') };
118
+ if (p.type === 'function_call') { try { input = JSON.parse(p.arguments); } catch { /* preserve text, never execute */ } }
119
+ return { ...base, type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id, name: p.name, input }] } };
120
+ }
121
+ if (p.type === 'custom_tool_call_output' || p.type === 'function_call_output') {
122
+ if (typeof p.call_id !== 'string') throw new Error('Codex tool result lacks call_id');
123
+ return { ...base, type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: p.call_id, content: MASK }] } };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** Oversized output can be masked without decoding its body. Anything potentially publishable fails closed. */
129
+ function oversizedRecord(prefix) {
130
+ const s = prefix.toString('utf8');
131
+ if (/"type"\s*:\s*"(event_msg|reasoning|compacted)"/.test(s.slice(0, 256))) return null;
132
+ if (!/^\s*\{[^\n]*?"type"\s*:\s*"response_item"/.test(s.slice(0, 256))) throw new Error('Oversized Codex record cannot be safely classified');
133
+ const output = /"payload"\s*:\s*\{\s*"type"\s*:\s*"(custom_tool_call_output|function_call_output)"/.exec(s);
134
+ const call = /"call_id"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(s);
135
+ const timestamp = /"timestamp"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(s);
136
+ if (!output || !call) throw new Error('Codex publishable record exceeds record_bytes limit');
137
+ return { type: 'response_item', timestamp: timestamp ? JSON.parse(timestamp[1]) : null,
138
+ payload: { type: output[1], call_id: JSON.parse(call[1]) } };
139
+ }
140
+
141
+ /** JSON lexical filtering: only an actual image_url string value is omitted, never quoted user text.
142
+ * A screenshot may be tens of MiB, while its surrounding human text stays within record_bytes.
143
+ */
144
+ class ImageOmitter {
145
+ constructor() { this.string = false; this.omit = false; this.token = ''; this.between = ''; this.slashes = 0; this.images = 0; }
146
+ filter(bytes) {
147
+ const out = [];
148
+ for (let at = 0; at < bytes.length;) {
149
+ if (!this.string) {
150
+ const q = bytes.indexOf(34, at), end = q < 0 ? bytes.length : q;
151
+ const between = bytes.subarray(at, end);
152
+ out.push(between);
153
+ this.between = (this.between + between.toString('ascii')).slice(-32);
154
+ if (q < 0) break;
155
+ this.omit = this.token === 'image_url' && /^\s*:\s*$/.test(this.between);
156
+ this.token = ''; this.between = ''; this.slashes = 0; this.string = true;
157
+ out.push(bytes.subarray(q, q + 1));
158
+ if (this.omit) { out.push(Buffer.from('[첨부 이미지 생략]')); this.images++; }
159
+ at = q + 1;
160
+ } else {
161
+ const q = bytes.indexOf(34, at), end = q < 0 ? bytes.length : q;
162
+ const value = bytes.subarray(at, end);
163
+ if (!this.omit) { out.push(value); if (this.token.length <= 256) this.token += value.toString('utf8'); }
164
+ let slashes = 0;
165
+ for (let i = end - 1; i >= at && bytes[i] === 92; i--) slashes++;
166
+ if (slashes === value.length) slashes += this.slashes;
167
+ this.slashes = slashes;
168
+ if (q < 0) break;
169
+ if (!this.omit) out.push(bytes.subarray(q, q + 1));
170
+ if (slashes % 2) { if (this.token.length <= 256) this.token += '"'; this.slashes = 0; at = q + 1; continue; }
171
+ if (this.omit) out.push(bytes.subarray(q, q + 1));
172
+ this.string = false; this.omit = false; this.between = ''; this.slashes = 0;
173
+ at = q + 1;
174
+ }
175
+ }
176
+ return Buffer.concat(out);
177
+ }
178
+ }
179
+
180
+ function hashWindow(fd, start, length) {
181
+ const bytes = Buffer.alloc(length);
182
+ if (fs.readSync(fd, bytes, 0, length, start) !== length) throw new Error('Codex source changed while reading');
183
+ return crypto.createHash('sha256').update(bytes).digest('hex');
184
+ }
185
+
186
+ function readState(file) {
187
+ try { const st = plainFile(file); if (!st || st.size > 64 * 1024) return null;
188
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
189
+ try { return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); }
190
+ } catch { return null; }
191
+ }
192
+
193
+ /** mkdir ownership prevents a live-lock rename window. Reap only a dead PID on this host, never by age. */
194
+ function projectionLock(dir) {
195
+ for (let attempt = 0; attempt < 2; attempt++) {
196
+ let made = false;
197
+ try {
198
+ fs.mkdirSync(dir, { mode: 0o700 }); made = true;
199
+ const ino = fs.lstatSync(dir).ino;
200
+ fs.writeFileSync(path.join(dir, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname() }), { mode: 0o600 });
201
+ return () => { try { if (fs.lstatSync(dir).ino === ino) fs.rmSync(dir, { recursive: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; } };
202
+ } catch (e) { if (made) fs.rmSync(dir, { recursive: true, force: true }); if (e.code !== 'EEXIST') throw e; }
203
+ const lockStat = fs.lstatSync(dir);
204
+ if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) break;
205
+ const owner = readState(path.join(dir, 'owner.json'));
206
+ let dead = false;
207
+ if (owner?.host === os.hostname() && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
208
+ try { process.kill(owner.pid, 0); } catch (e) { dead = e.code === 'ESRCH'; }
209
+ }
210
+ if (!dead) break;
211
+ let reap;
212
+ try { reap = fs.openSync(path.join(dir, 'reaping'), 'wx', 0o600); } catch { break; }
213
+ try {
214
+ const still = readState(path.join(dir, 'owner.json'));
215
+ if (still?.pid !== owner.pid || still?.host !== owner.host) break;
216
+ fs.rmSync(dir, { recursive: true });
217
+ } finally { fs.closeSync(reap); }
218
+ }
219
+ throw new Error('Native Codex projection is already being refreshed; retry after it completes');
220
+ }
221
+
222
+ function checkCacheBudget(root, output, nextBytes) {
223
+ let bytes = nextBytes, entries = 1;
224
+ const names = fs.readdirSync(root);
225
+ if (names.length > CODEX_LIMITS.cache_entries * 3 + 8) throw new Error('Codex cache inventory limit exceeded');
226
+ for (const name of names) {
227
+ if (!/^[0-9a-f-]{36}\.jsonl$/.test(name)) continue;
228
+ const file = path.join(root, name);
229
+ if (file === output) continue;
230
+ const st = plainFile(file);
231
+ if (st) { bytes += st.size; entries++; }
232
+ }
233
+ if (bytes > CODEX_LIMITS.cache_bytes || entries > CODEX_LIMITS.cache_entries)
234
+ throw new Error('Codex private projection cache limit exceeded; remove unused native projections before retrying');
235
+ }
236
+
237
+ function reusable(state, fd, st, output, source) {
238
+ if (!state || state.codec !== CODEX_CODEC || state.source !== source || state.ino !== String(st.ino) || state.dev !== String(st.dev)
239
+ || state.raw_off > st.size || plainFile(output)?.size !== state.projection_bytes) return false;
240
+ if (![state.raw_off, state.projection_bytes, state.records, state.kept, state.prefix_bytes, state.seam_bytes].every(n => Number.isSafeInteger(n) && n >= 0)
241
+ || state.prefix_bytes > 4096 || state.seam_bytes > 4096 || state.raw_off < state.seam_bytes
242
+ || state.projection_bytes > CODEX_LIMITS.projection_bytes || state.records > CODEX_LIMITS.records) return false;
243
+ return state.prefix_sha === hashWindow(fd, 0, state.prefix_bytes)
244
+ && state.seam_sha === hashWindow(fd, Math.max(0, state.raw_off - state.seam_bytes), state.seam_bytes);
245
+ }
246
+
247
+ /** O(chunk + record + projection cap), never O(rollout size). A partial trailing record is deferred. */
248
+ async function project(fd, snapshotBytes, start, metadata, scrub, delta, old) {
249
+ const out = fs.openSync(delta, 'wx', 0o600);
250
+ const chunk = Buffer.alloc(CODEX_LIMITS.chunk_bytes);
251
+ let parts = [], recordBytes = 0, sourceRecordBytes = 0, prefix = Buffer.alloc(0), oversized = false;
252
+ let imageFilter = new ImageOmitter(), omittedImages = old?.omitted_images || 0, omittedLarge = old?.omitted_large || 0;
253
+ let rawOffset = start, recordStart = start, records = old?.records || 0, kept = old?.kept || 0;
254
+ let projectionBytes = old?.projection_bytes || 0, cwd = old?.cwd || metadata.cwd;
255
+ try {
256
+ for (let pos = start; pos < snapshotBytes;) {
257
+ const length = Math.min(chunk.length, snapshotBytes - pos);
258
+ const n = fs.readSync(fd, chunk, 0, length, pos);
259
+ if (n !== length) throw new Error('Codex source shrank while projecting');
260
+ for (let at = 0; at < n;) {
261
+ const newline = chunk.indexOf(10, at);
262
+ const end = newline < 0 || newline >= n ? n : newline;
263
+ const piece = chunk.subarray(at, end);
264
+ sourceRecordBytes += piece.length;
265
+ if (prefix.length < 64 * 1024) prefix = Buffer.concat([prefix, piece.subarray(0, 64 * 1024 - prefix.length)]);
266
+ const retained = oversized ? Buffer.alloc(0) : imageFilter.filter(piece);
267
+ recordBytes += oversized ? piece.length : retained.length;
268
+ if (recordBytes <= CODEX_LIMITS.record_bytes) parts.push(retained);
269
+ else { oversized = true; parts = []; }
270
+ if (end < n) {
271
+ if (++records > CODEX_LIMITS.records) throw new Error('Codex record count limit exceeded');
272
+ const r = oversized ? oversizedRecord(prefix) : JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts)));
273
+ omittedImages += imageFilter.images;
274
+ if (sourceRecordBytes > CODEX_LIMITS.record_bytes && r?.payload?.type !== 'message') omittedLarge++;
275
+ if (r && (r.type === 'turn_context' || r.type === 'session_meta') && typeof r.payload?.cwd === 'string') cwd = r.payload.cwd;
276
+ const row = r ? codexWireRecord(r, { ...metadata, cwd }, recordStart) : null;
277
+ let wire = '';
278
+ if (row?.type === 'system') wire = JSON.stringify(row) + '\n';
279
+ else if (row) {
280
+ wire = scrub(JSON.stringify(row) + '\n', { delta: true }).jsonl;
281
+ if (wire) { const masked = JSON.parse(wire); masked.cwd = cwd; wire = JSON.stringify(masked) + '\n'; kept++; }
282
+ }
283
+ projectionBytes += Buffer.byteLength(wire);
284
+ if (projectionBytes > CODEX_LIMITS.projection_bytes) throw new Error('Codex projection_bytes limit exceeded; no upload performed');
285
+ if (wire) fs.writeSync(out, wire);
286
+ rawOffset = pos + end + 1; recordStart = rawOffset;
287
+ parts = []; recordBytes = 0; sourceRecordBytes = 0; prefix = Buffer.alloc(0); oversized = false; imageFilter = new ImageOmitter();
288
+ }
289
+ at = end < n ? end + 1 : n;
290
+ }
291
+ pos += n;
292
+ await new Promise(resolve => setImmediate(resolve)); // cancel/stdio/timers can progress during multi-GiB scans
293
+ }
294
+ fs.fsyncSync(out);
295
+ return { raw_off: rawOffset, records, kept, projection_bytes: projectionBytes, cwd, deferred_bytes: snapshotBytes - rawOffset,
296
+ omitted_images: omittedImages, omitted_large: omittedLarge };
297
+ } finally { fs.closeSync(out); }
298
+ }
299
+
300
+ export async function prepareCodexProjection(metadata, { cacheRoot, scrub, scrubRevision = '' } = {}) {
301
+ if (typeof scrub !== 'function' || !cacheRoot) throw new Error('Codex projection requires the canonical Genetics scrubber and private cacheRoot');
302
+ fs.mkdirSync(cacheRoot, { recursive: true, mode: 0o700 });
303
+ if (fs.lstatSync(cacheRoot).isSymbolicLink()) throw new Error('Codex projection directory must not be a symlink');
304
+ fs.chmodSync(cacheRoot, 0o700);
305
+ const output = path.join(cacheRoot, `${metadata.uuid}.jsonl`), stateFile = path.join(cacheRoot, `${metadata.uuid}.json`);
306
+ // One cache writer bounds aggregate temporary bytes as well as duplicate multi-GiB scans.
307
+ const unlock = projectionLock(path.join(cacheRoot, '.refresh.lock'));
308
+ const delta = output + `.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
309
+ let fd;
310
+ try {
311
+ fd = fs.openSync(metadata.file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
312
+ const st = fs.fstatSync(fd);
313
+ if (st.size > CODEX_LIMITS.input_bytes) throw new Error('Codex input_bytes limit exceeded');
314
+ if (String(st.ino) !== metadata.source_ino || String(st.dev) !== metadata.source_dev
315
+ || st.uid !== metadata.source_uid || (process.getuid && st.uid !== process.getuid()))
316
+ throw new Error('Codex opened source inode/ownership differs from discovery');
317
+ const head = Buffer.alloc(Math.min(st.size, 64 * 1024)); fs.readSync(fd, head, 0, head.length, 0);
318
+ const headEnd = head.indexOf(10);
319
+ const actual = headEnd >= 0 ? JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(head.subarray(0, headEnd))) : null;
320
+ if (actual?.type !== 'session_meta' || actual.payload?.id?.toLowerCase() !== metadata.uuid || actual.payload?.cwd !== metadata.cwd)
321
+ throw new Error('Codex opened source metadata does not match the selected session');
322
+ const state = readState(stateFile);
323
+ const old = state?.scrub_revision === scrubRevision && reusable(state, fd, st, output, metadata.file) ? state : null;
324
+ const initialPrefixBytes = Math.min(st.size, 4096), initialPrefix = hashWindow(fd, 0, initialPrefixBytes);
325
+ const next = await project(fd, st.size, old?.raw_off || 0, metadata, scrub, delta, old);
326
+ if (!next.kept) throw new Error('Native Codex session has no publishable messages');
327
+ if (hashWindow(fd, 0, initialPrefixBytes) !== initialPrefix || fs.fstatSync(fd).size < st.size)
328
+ throw new Error('Codex source changed while projecting; retry from the verified source');
329
+ checkCacheBudget(cacheRoot, output, next.projection_bytes);
330
+ if (old) {
331
+ const dest = fs.openSync(output, fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0));
332
+ try { for await (const bytes of fs.createReadStream(delta)) fs.writeSync(dest, bytes); fs.fsyncSync(dest); } finally { fs.closeSync(dest); }
333
+ } else fs.renameSync(delta, output);
334
+ fs.chmodSync(output, 0o600);
335
+ const prefixBytes = Math.min(next.raw_off, 4096), seamBytes = Math.min(next.raw_off, 4096);
336
+ const receipt = { ...next, codec: CODEX_CODEC, scrub_revision: scrubRevision, source: metadata.file, ino: String(st.ino), dev: String(st.dev),
337
+ prefix_bytes: prefixBytes, prefix_sha: hashWindow(fd, 0, prefixBytes), seam_bytes: seamBytes,
338
+ seam_sha: hashWindow(fd, next.raw_off - seamBytes, seamBytes), metadata };
339
+ fs.writeFileSync(delta + '.state', JSON.stringify(receipt), { mode: 0o600 });
340
+ fs.renameSync(delta + '.state', stateFile);
341
+ return { ...metadata, projection: output, source_bytes: st.size, ...next, codec: CODEX_CODEC };
342
+ } finally {
343
+ if (fd !== undefined) fs.closeSync(fd);
344
+ fs.rmSync(delta, { force: true }); fs.rmSync(delta + '.state', { force: true });
345
+ unlock();
346
+ }
347
+ }
348
+
349
+ export function cachedCodexProjection(metadata, cacheRoot) {
350
+ const file = path.join(cacheRoot, `${metadata.uuid}.jsonl`);
351
+ const state = readState(path.join(cacheRoot, `${metadata.uuid}.json`));
352
+ return state?.codec === CODEX_CODEC && state.source === metadata.file && plainFile(file)?.size === state.projection_bytes ? file : null;
353
+ }