genetics-mcp 2.24.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.
- package/NATIVE-CODEX-SESSION.md +182 -0
- package/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/codex-session-cli.mjs +82 -0
- package/codex-session.mjs +353 -0
- package/genetics.mjs +399 -61
- package/package.json +5 -2
- package/skills/gazette-report/scripts/tone.mjs +15 -6
- package/skills/gazette-report/scripts/tone.test.mjs +39 -0
- package/skills/genetics-report/SKILL.md +37 -22
- package/skills/genetics-report/assets/mcp-call.mjs +61 -10
- package/skills/genetics-report/assets/patch.py +237 -22
- package/skills/genetics-report/assets/render.py +49 -1
- package/skills/genetics-report/references/content-pipeline.md +3 -4
- package/skills/genetics-report/references/edition-review.workflow.js +55 -13
- package/skills/genetics-report/references/font-embedding.md +1 -1
- package/skills/genetics-report/references/lim-portal-format.md +34 -17
- package/skills/genetics-report/references/process.md +9 -7
- package/skills/genetics-report/references/reader-review.workflow.js +13 -5
- package/skills/genetics-report/references/visual-first.md +6 -6
- package/skills/gazette-report/scripts/package-lock.json +0 -24
|
@@ -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
|
|
Binary file
|
package/bin/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"aarch64-apple-darwin": "
|
|
3
|
-
"aarch64-unknown-linux-musl": "
|
|
4
|
-
"x86_64-apple-darwin": "
|
|
5
|
-
"x86_64-pc-windows-gnu": "
|
|
6
|
-
"x86_64-unknown-linux-musl": "
|
|
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
|
+
}
|