@principles/codex-adapter 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codec/input-decoder.d.ts +1 -0
- package/dist/codec/input-decoder.js +23 -4
- package/dist/host-adapter.js +5 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/ingestion/codex-home.d.ts +19 -0
- package/dist/ingestion/codex-home.js +43 -0
- package/dist/ingestion/codex-version.d.ts +30 -0
- package/dist/ingestion/codex-version.js +53 -0
- package/dist/ingestion/ingestion.d.ts +46 -0
- package/dist/ingestion/ingestion.js +234 -0
- package/dist/ingestion/transcript-decoder.d.ts +72 -0
- package/dist/ingestion/transcript-decoder.js +485 -0
- package/dist/ingestion/transcript-path.d.ts +23 -0
- package/dist/ingestion/transcript-path.js +87 -0
- package/dist/pd-hook.js +45 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ export declare const CODEX_EVENT_PRE_TOOL_USE = "PreToolUse";
|
|
|
3
3
|
export declare const CODEX_EVENT_POST_TOOL_USE = "PostToolUse";
|
|
4
4
|
export declare const CODEX_EVENT_USER_PROMPT_SUBMIT = "UserPromptSubmit";
|
|
5
5
|
export declare const CODEX_EVENT_SESSION_START = "SessionStart";
|
|
6
|
+
export declare const CODEX_EVENT_STOP = "Stop";
|
|
6
7
|
export declare const CODEX_EVENT_SESSION_END = "SessionEnd";
|
|
7
8
|
export declare class CodexDecoderError extends Error {
|
|
8
9
|
readonly reason: string;
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
import { CODEX_INGESTION_MIN_VERSION } from '../ingestion/codex-version.js';
|
|
2
|
+
// Two contract baselines coexist deliberately (G1 probe report §9): the
|
|
3
|
+
// four pre-ingestion events pin their payload contract to codex-cli 0.147.0,
|
|
4
|
+
// while the Stop / ingestion-side fields follow the ingestion baseline
|
|
5
|
+
// (CODEX_INGESTION_MIN_VERSION = 0.148.0). Each message names the baseline
|
|
6
|
+
// that defines it — never mix them.
|
|
7
|
+
const FOUR_EVENT_CONTRACT_VERSION = '0.147.0';
|
|
1
8
|
export const CODEX_EVENT_PRE_TOOL_USE = 'PreToolUse';
|
|
2
9
|
export const CODEX_EVENT_POST_TOOL_USE = 'PostToolUse';
|
|
3
10
|
export const CODEX_EVENT_USER_PROMPT_SUBMIT = 'UserPromptSubmit';
|
|
4
11
|
export const CODEX_EVENT_SESSION_START = 'SessionStart';
|
|
12
|
+
export const CODEX_EVENT_STOP = 'Stop';
|
|
5
13
|
export const CODEX_EVENT_SESSION_END = 'SessionEnd';
|
|
6
14
|
const EVENT_KINDS = new Map([
|
|
7
15
|
[CODEX_EVENT_PRE_TOOL_USE, 'before_tool_call'],
|
|
8
16
|
[CODEX_EVENT_POST_TOOL_USE, 'after_tool_call'],
|
|
9
17
|
[CODEX_EVENT_USER_PROMPT_SUBMIT, 'before_prompt_build'],
|
|
10
18
|
[CODEX_EVENT_SESSION_START, 'session_start'],
|
|
19
|
+
[CODEX_EVENT_STOP, 'turn_complete'],
|
|
11
20
|
]);
|
|
12
21
|
export class CodexDecoderError extends Error {
|
|
13
22
|
reason;
|
|
@@ -28,13 +37,13 @@ function own(value, key) {
|
|
|
28
37
|
function requiredString(value, key) {
|
|
29
38
|
const candidate = own(value, key);
|
|
30
39
|
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
31
|
-
throw new CodexDecoderError(`missing or malformed required field "${key}"`, `Use the exact codex-cli
|
|
40
|
+
throw new CodexDecoderError(`missing or malformed required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
|
|
32
41
|
}
|
|
33
42
|
return candidate;
|
|
34
43
|
}
|
|
35
44
|
function requiredNullableString(value, key) {
|
|
36
45
|
if (!Object.hasOwn(value, key))
|
|
37
|
-
throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli
|
|
46
|
+
throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
|
|
38
47
|
const candidate = own(value, key);
|
|
39
48
|
if (candidate !== null && typeof candidate !== 'string') {
|
|
40
49
|
throw new CodexDecoderError(`malformed required field "${key}"`, `${key} must be a string or null.`);
|
|
@@ -43,7 +52,7 @@ function requiredNullableString(value, key) {
|
|
|
43
52
|
}
|
|
44
53
|
function requiredUnknown(value, key) {
|
|
45
54
|
if (!Object.hasOwn(value, key))
|
|
46
|
-
throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli
|
|
55
|
+
throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
|
|
47
56
|
return own(value, key);
|
|
48
57
|
}
|
|
49
58
|
function validateCommon(raw, needsTurn) {
|
|
@@ -61,7 +70,7 @@ export function decodeCodexInput(raw) {
|
|
|
61
70
|
const eventName = requiredString(raw, 'hook_event_name');
|
|
62
71
|
const kind = EVENT_KINDS.get(eventName);
|
|
63
72
|
if (!kind)
|
|
64
|
-
throw new CodexDecoderError(`unknown hook_event_name "${eventName}"`, 'Configure only PreToolUse, PostToolUse, UserPromptSubmit, or
|
|
73
|
+
throw new CodexDecoderError(`unknown hook_event_name "${eventName}"`, 'Configure only PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, or Stop.');
|
|
65
74
|
const common = validateCommon(raw, kind !== 'session_start');
|
|
66
75
|
let context;
|
|
67
76
|
let rawPayload = raw;
|
|
@@ -76,6 +85,16 @@ export function decodeCodexInput(raw) {
|
|
|
76
85
|
else if (kind === 'before_prompt_build') {
|
|
77
86
|
context = { ...common, promptContent: requiredString(raw, 'prompt') };
|
|
78
87
|
}
|
|
88
|
+
else if (kind === 'turn_complete') {
|
|
89
|
+
// Stop is the G1-verified turn-complete event (probe report §2). Its
|
|
90
|
+
// payload carries stop_hook_active (required boolean); PD consumes the
|
|
91
|
+
// already-flushed transcript, never last_assistant_message directly.
|
|
92
|
+
const stopHookActive = own(raw, 'stop_hook_active');
|
|
93
|
+
if (typeof stopHookActive !== 'boolean') {
|
|
94
|
+
throw new CodexDecoderError('missing or malformed required field "stop_hook_active"', `Use the exact codex-cli ${CODEX_INGESTION_MIN_VERSION} stop_hook_active field.`);
|
|
95
|
+
}
|
|
96
|
+
context = { ...common };
|
|
97
|
+
}
|
|
79
98
|
else {
|
|
80
99
|
context = { ...common, source: requiredString(raw, 'source') };
|
|
81
100
|
}
|
package/dist/host-adapter.js
CHANGED
|
@@ -4,7 +4,11 @@ const SUBSCRIBED_EVENTS = [
|
|
|
4
4
|
'after_tool_call',
|
|
5
5
|
'before_prompt_build',
|
|
6
6
|
'session_start',
|
|
7
|
-
//
|
|
7
|
+
// Stop = turn_complete is the G1-verified turn-complete event (probe
|
|
8
|
+
// report §2); it drives bounded governance-observation ingestion, not a
|
|
9
|
+
// dispatch route. session_end stays deferred (SPEC §8: never register both
|
|
10
|
+
// Stop and SessionEnd for turn completion).
|
|
11
|
+
'turn_complete',
|
|
8
12
|
];
|
|
9
13
|
export class CodexHooksHostAdapter {
|
|
10
14
|
hostId = 'codex';
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,11 @@ export { CodexHooksHostAdapter } from './host-adapter.js';
|
|
|
9
9
|
export { processHookInvocation } from './pd-hook.js';
|
|
10
10
|
export type { PdHookResult } from './pd-hook.js';
|
|
11
11
|
export * from './codec/index.js';
|
|
12
|
+
export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
13
|
+
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
14
|
+
export type { TranscriptPathValidation, TranscriptFileIdentity } from './ingestion/transcript-path.js';
|
|
15
|
+
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
16
|
+
export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
|
|
17
|
+
export type { CodexIngestionOptions, CodexIngestionOutcome } from './ingestion/ingestion.js';
|
|
18
|
+
export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
|
|
19
|
+
export type { TranscriptPort, TranscriptExpectedIdentity, DecodedDelta, TranscriptDecodeStop } from './ingestion/transcript-decoder.js';
|
package/dist/index.js
CHANGED
|
@@ -8,3 +8,8 @@
|
|
|
8
8
|
export { CodexHooksHostAdapter } from './host-adapter.js';
|
|
9
9
|
export { processHookInvocation } from './pd-hook.js';
|
|
10
10
|
export * from './codec/index.js';
|
|
11
|
+
export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
12
|
+
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
13
|
+
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
14
|
+
export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
|
|
15
|
+
export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CodexHomeResolution = {
|
|
2
|
+
ok: true;
|
|
3
|
+
home: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: string;
|
|
7
|
+
nextAction: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Canonicalize a Windows path to its final long form. Node's JS realpath
|
|
11
|
+
* preserves 8.3 short-name segments (ADMINI~1), while hook payloads carry
|
|
12
|
+
* the long form — containment comparisons must use one canonical form, so
|
|
13
|
+
* prefer the native realpath (GetFinalPathNameByHandle) and fall back to
|
|
14
|
+
* the JS implementation where native is unavailable.
|
|
15
|
+
*/
|
|
16
|
+
export declare function canonicalizePath(target: string): string;
|
|
17
|
+
export declare function resolveCodexHome(env?: {
|
|
18
|
+
CODEX_HOME?: string | undefined;
|
|
19
|
+
}): CodexHomeResolution;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex home resolution (G1 probe report §7, source-pinned from
|
|
3
|
+
* codex-rs/utils/home-dir/src/lib.rs find_codex_home).
|
|
4
|
+
*
|
|
5
|
+
* Rules: CODEX_HOME, when set and non-empty, MUST already exist and be a
|
|
6
|
+
* directory and is canonicalized; a missing path or a file path is an error.
|
|
7
|
+
* When unset, the home comes from the OS home directory plus `.codex`, and
|
|
8
|
+
* existence is NOT verified. There is one OS-generic implementation.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* Canonicalize a Windows path to its final long form. Node's JS realpath
|
|
15
|
+
* preserves 8.3 short-name segments (ADMINI~1), while hook payloads carry
|
|
16
|
+
* the long form — containment comparisons must use one canonical form, so
|
|
17
|
+
* prefer the native realpath (GetFinalPathNameByHandle) and fall back to
|
|
18
|
+
* the JS implementation where native is unavailable.
|
|
19
|
+
*/
|
|
20
|
+
export function canonicalizePath(target) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.realpathSync.native(target);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return fs.realpathSync(target);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function resolveCodexHome(env = process.env) {
|
|
29
|
+
const configured = env.CODEX_HOME;
|
|
30
|
+
if (configured !== undefined && configured.trim() !== '') {
|
|
31
|
+
try {
|
|
32
|
+
const stats = fs.statSync(configured);
|
|
33
|
+
if (!stats.isDirectory()) {
|
|
34
|
+
return { ok: false, reason: 'codex_home_not_directory', nextAction: 'CODEX_HOME must point at an existing directory (Codex fatal-error contract).' };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, home: canonicalizePath(configured) };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { ok: false, reason: 'codex_home_unavailable', nextAction: 'CODEX_HOME is set but does not exist; unset it or create the directory.' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, home: path.join(os.homedir(), '.codex') };
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported Codex ingestion version contract (Codex Governance Closure
|
|
3
|
+
* Slice A; ADR-0020 §11.2 + G1 probe report §9).
|
|
4
|
+
*
|
|
5
|
+
* The G1 contract baseline is: minimum supported Codex 0.148.0, verified
|
|
6
|
+
* on-device at 0.150.1. The transcript itself carries `session_meta.
|
|
7
|
+
* payload.cli_version`, which is the version signal available inside a hook
|
|
8
|
+
* subprocess (hook payloads carry no version field — G1 fixture contract).
|
|
9
|
+
*
|
|
10
|
+
* Older-than-minimum and newer-than-verified versions both degrade
|
|
11
|
+
* explicitly; an unknown version must never silently guess record fields.
|
|
12
|
+
* Adopting a newer Codex version requires re-running the contract probe and
|
|
13
|
+
* refreshing the fixtures (G1 report §9 drift-detection boundary).
|
|
14
|
+
*/
|
|
15
|
+
export declare const CODEX_INGESTION_MIN_VERSION = "0.148.0";
|
|
16
|
+
export declare const CODEX_INGESTION_VERIFIED_VERSION = "0.150.1";
|
|
17
|
+
export type CodexVersionClassification = {
|
|
18
|
+
status: 'supported';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'unsupported_below';
|
|
21
|
+
reason: 'unsupported_codex_version';
|
|
22
|
+
} | {
|
|
23
|
+
status: 'unverified_above';
|
|
24
|
+
reason: 'codex_version_unverified';
|
|
25
|
+
} | {
|
|
26
|
+
status: 'unknown';
|
|
27
|
+
reason: 'codex_version_unverified';
|
|
28
|
+
};
|
|
29
|
+
export declare const CODEX_VERSION_NEXT_ACTION = "rerun the Codex contract probe / update supported fixtures (docs/architecture/CODEX_G1_CONTRACT_PROBE_REPORT.md \u00A79)";
|
|
30
|
+
export declare function classifyCodexVersion(cliVersion: string | null | undefined): CodexVersionClassification;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported Codex ingestion version contract (Codex Governance Closure
|
|
3
|
+
* Slice A; ADR-0020 §11.2 + G1 probe report §9).
|
|
4
|
+
*
|
|
5
|
+
* The G1 contract baseline is: minimum supported Codex 0.148.0, verified
|
|
6
|
+
* on-device at 0.150.1. The transcript itself carries `session_meta.
|
|
7
|
+
* payload.cli_version`, which is the version signal available inside a hook
|
|
8
|
+
* subprocess (hook payloads carry no version field — G1 fixture contract).
|
|
9
|
+
*
|
|
10
|
+
* Older-than-minimum and newer-than-verified versions both degrade
|
|
11
|
+
* explicitly; an unknown version must never silently guess record fields.
|
|
12
|
+
* Adopting a newer Codex version requires re-running the contract probe and
|
|
13
|
+
* refreshing the fixtures (G1 report §9 drift-detection boundary).
|
|
14
|
+
*/
|
|
15
|
+
export const CODEX_INGESTION_MIN_VERSION = '0.148.0';
|
|
16
|
+
export const CODEX_INGESTION_VERIFIED_VERSION = '0.150.1';
|
|
17
|
+
export const CODEX_VERSION_NEXT_ACTION = 'rerun the Codex contract probe / update supported fixtures (docs/architecture/CODEX_G1_CONTRACT_PROBE_REPORT.md §9)';
|
|
18
|
+
function isDigits(value) {
|
|
19
|
+
return value.length > 0 && /^[0-9]+$/.test(value);
|
|
20
|
+
}
|
|
21
|
+
function parseSemver(value) {
|
|
22
|
+
const parts = value.trim().split('.');
|
|
23
|
+
if (parts.length < 3)
|
|
24
|
+
return null;
|
|
25
|
+
const [major, minor, patch] = parts;
|
|
26
|
+
if (major === undefined || minor === undefined || patch === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
if (!isDigits(major) || !isDigits(minor) || !isDigits(patch))
|
|
29
|
+
return null;
|
|
30
|
+
return { major: Number(major), minor: Number(minor), patch: Number(patch) };
|
|
31
|
+
}
|
|
32
|
+
function compareSemver(a, b) {
|
|
33
|
+
if (a.major !== b.major)
|
|
34
|
+
return a.major - b.major;
|
|
35
|
+
if (a.minor !== b.minor)
|
|
36
|
+
return a.minor - b.minor;
|
|
37
|
+
return a.patch - b.patch;
|
|
38
|
+
}
|
|
39
|
+
export function classifyCodexVersion(cliVersion) {
|
|
40
|
+
if (typeof cliVersion !== 'string' || cliVersion.trim().length === 0) {
|
|
41
|
+
return { status: 'unknown', reason: 'codex_version_unverified' };
|
|
42
|
+
}
|
|
43
|
+
const version = parseSemver(cliVersion);
|
|
44
|
+
const min = parseSemver(CODEX_INGESTION_MIN_VERSION);
|
|
45
|
+
const verified = parseSemver(CODEX_INGESTION_VERIFIED_VERSION);
|
|
46
|
+
if (!version || !min || !verified)
|
|
47
|
+
return { status: 'unknown', reason: 'codex_version_unverified' };
|
|
48
|
+
if (compareSemver(version, min) < 0)
|
|
49
|
+
return { status: 'unsupported_below', reason: 'unsupported_codex_version' };
|
|
50
|
+
if (compareSemver(version, verified) > 0)
|
|
51
|
+
return { status: 'unverified_above', reason: 'codex_version_unverified' };
|
|
52
|
+
return { status: 'supported' };
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversation ingestion orchestrator (Codex Governance Closure
|
|
3
|
+
* Slice A; SPEC rev 2 §7/§9/§17).
|
|
4
|
+
*
|
|
5
|
+
* Called by pd-hook ONLY after both `host.codex` and
|
|
6
|
+
* `codex_conversation_ingestion` are enabled — the flag check happens
|
|
7
|
+
* before any transcript filesystem I/O, so flag-off means this module is
|
|
8
|
+
* never entered and the transcript boundary receives zero calls (SPEC §10
|
|
9
|
+
* hard privacy invariant, proven by a port spy test).
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities: extract validated ingestion fields from the raw hook
|
|
12
|
+
* payload (Codex protocol facts live in the adapter, not host-runtime),
|
|
13
|
+
* authorize the transcript path, run bounded incremental decoding from the
|
|
14
|
+
* durable checkpoint, apply the supported-version guard, and hand the
|
|
15
|
+
* projected observations to the host-neutral host-runtime seam.
|
|
16
|
+
*
|
|
17
|
+
* Stop (`turn_complete`) is the turn-complete ingestion trigger (G1 §2);
|
|
18
|
+
* live `UserPromptSubmit`/`PostToolUse` contribute live observations that
|
|
19
|
+
* the transcript replay later converges with (SPEC §10 source precedence).
|
|
20
|
+
*/
|
|
21
|
+
import type { HostEventKind } from '@principles/core/host';
|
|
22
|
+
import { type TranscriptPort } from './transcript-decoder.js';
|
|
23
|
+
export interface CodexIngestionOptions {
|
|
24
|
+
readonly workspaceDir: string;
|
|
25
|
+
readonly env?: {
|
|
26
|
+
CODEX_HOME?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
readonly now?: Date;
|
|
29
|
+
readonly port?: TranscriptPort;
|
|
30
|
+
}
|
|
31
|
+
export type CodexIngestionOutcome = {
|
|
32
|
+
status: 'ok';
|
|
33
|
+
inserted: number;
|
|
34
|
+
enriched: number;
|
|
35
|
+
duplicates: number;
|
|
36
|
+
warnings: readonly string[];
|
|
37
|
+
lagBytes: number;
|
|
38
|
+
} | {
|
|
39
|
+
status: 'degraded';
|
|
40
|
+
reason: string;
|
|
41
|
+
nextAction: string;
|
|
42
|
+
warnings: readonly string[];
|
|
43
|
+
};
|
|
44
|
+
/** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
|
|
45
|
+
export declare function setCodexTranscriptPortForTest(port: TranscriptPort | null): void;
|
|
46
|
+
export declare function ingestCodexConversation(rawPayload: unknown, kind: HostEventKind, options: CodexIngestionOptions): CodexIngestionOutcome;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { ingestGovernanceObservations, readGovernanceCheckpoint, } from '@principles/host-runtime';
|
|
2
|
+
import { resolveCodexHome } from './codex-home.js';
|
|
3
|
+
import { validateCodexTranscriptPath } from './transcript-path.js';
|
|
4
|
+
import { classifyCodexVersion, CODEX_VERSION_NEXT_ACTION } from './codex-version.js';
|
|
5
|
+
import { decodeTranscriptWindow, createNodeTranscriptPort, CODEX_INGESTION_MAX_BATCH_BYTES, TranscriptReplacedError } from './transcript-decoder.js';
|
|
6
|
+
let activePort = null;
|
|
7
|
+
/** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
|
|
8
|
+
export function setCodexTranscriptPortForTest(port) {
|
|
9
|
+
activePort = port;
|
|
10
|
+
}
|
|
11
|
+
function activeTranscriptPort(options) {
|
|
12
|
+
return options.port ?? activePort ?? createNodeTranscriptPort();
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function own(value, key) {
|
|
18
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function extractFields(raw) {
|
|
21
|
+
if (!isRecord(raw))
|
|
22
|
+
return null;
|
|
23
|
+
const transcriptPath = own(raw, 'transcript_path');
|
|
24
|
+
if (transcriptPath !== null && typeof transcriptPath !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const sessionId = own(raw, 'session_id');
|
|
27
|
+
const turnId = own(raw, 'turn_id');
|
|
28
|
+
const prompt = own(raw, 'prompt');
|
|
29
|
+
const toolUseId = own(raw, 'tool_use_id');
|
|
30
|
+
const toolName = own(raw, 'tool_name');
|
|
31
|
+
return {
|
|
32
|
+
transcriptPath: transcriptPath ?? null,
|
|
33
|
+
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
34
|
+
turnId: typeof turnId === 'string' ? turnId : null,
|
|
35
|
+
prompt: typeof prompt === 'string' ? prompt : null,
|
|
36
|
+
toolUseId: typeof toolUseId === 'string' ? toolUseId : null,
|
|
37
|
+
toolName: typeof toolName === 'string' ? toolName : null,
|
|
38
|
+
toolInput: own(raw, 'tool_input'),
|
|
39
|
+
toolResponse: own(raw, 'tool_response'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
|
|
43
|
+
const nowIso = now.toISOString();
|
|
44
|
+
let observation = null;
|
|
45
|
+
if (fields.turnId !== null && fields.prompt !== null) {
|
|
46
|
+
observation = {
|
|
47
|
+
hostKind: 'codex',
|
|
48
|
+
rolloutIdentity,
|
|
49
|
+
rootSessionId: fields.sessionId,
|
|
50
|
+
hostTurnId: fields.turnId,
|
|
51
|
+
kind: 'user_turn',
|
|
52
|
+
logicalObservationKey: `codex|${rolloutIdentity}|${fields.turnId}|user`,
|
|
53
|
+
visibleText: fields.prompt,
|
|
54
|
+
source: 'live_hook',
|
|
55
|
+
completeness: 'complete',
|
|
56
|
+
observedAt: nowIso,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else if (fields.turnId !== null && fields.toolUseId !== null) {
|
|
60
|
+
observation = {
|
|
61
|
+
hostKind: 'codex',
|
|
62
|
+
rolloutIdentity,
|
|
63
|
+
rootSessionId: fields.sessionId,
|
|
64
|
+
hostTurnId: fields.turnId,
|
|
65
|
+
kind: 'tool_call',
|
|
66
|
+
logicalObservationKey: `codex|${rolloutIdentity}|${fields.toolUseId}`,
|
|
67
|
+
toolUseId: fields.toolUseId,
|
|
68
|
+
toolFacts: {
|
|
69
|
+
toolName: fields.toolName,
|
|
70
|
+
params: fields.toolInput ?? null,
|
|
71
|
+
result: fields.toolResponse ?? null,
|
|
72
|
+
},
|
|
73
|
+
source: 'live_hook',
|
|
74
|
+
completeness: 'complete',
|
|
75
|
+
observedAt: nowIso,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (observation === null) {
|
|
79
|
+
return { status: 'degraded', reason: 'transcript_path_invalid', nextAction: 'the live event lacks turn/prompt/tool identity; no observation was written.', warnings: [] };
|
|
80
|
+
}
|
|
81
|
+
const result = ingestGovernanceObservations({
|
|
82
|
+
workspaceDir,
|
|
83
|
+
observations: [observation],
|
|
84
|
+
now,
|
|
85
|
+
});
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
return { status: 'degraded', reason: result.reason ?? 'governance_write_failed', nextAction: result.nextAction ?? 'inspect the workspace trajectory database.', warnings: result.warnings };
|
|
88
|
+
return { status: 'ok', inserted: result.inserted, enriched: result.enriched, duplicates: result.duplicates, warnings: result.warnings, lagBytes: 0 };
|
|
89
|
+
}
|
|
90
|
+
function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentity, workspaceDir, now, port }) {
|
|
91
|
+
const checkpoint = readGovernanceCheckpoint({ workspaceDir, hostKind: 'codex', rolloutIdentity });
|
|
92
|
+
if (checkpoint !== null && !('byteOffset' in checkpoint) && 'ok' in checkpoint && checkpoint.ok === false) {
|
|
93
|
+
return { status: 'degraded', reason: checkpoint.reason, nextAction: checkpoint.nextAction, warnings: [] };
|
|
94
|
+
}
|
|
95
|
+
const existing = checkpoint !== null && 'byteOffset' in checkpoint ? checkpoint : null;
|
|
96
|
+
const offset = existing !== null ? existing.byteOffset : 0;
|
|
97
|
+
let window;
|
|
98
|
+
try {
|
|
99
|
+
// Post-open revalidation (SPEC §9): the port must prove the opened
|
|
100
|
+
// object still carries the identity the validator approved.
|
|
101
|
+
window = port.read({ canonicalPath, offset, maxBytes: CODEX_INGESTION_MAX_BATCH_BYTES, expectedIdentity: identity });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof TranscriptReplacedError) {
|
|
105
|
+
return { status: 'degraded', reason: 'transcript_replaced', nextAction: 'the transcript changed identity after validation (replacement or symlink swap); refusing to read — the next Stop revalidates the current file.', warnings: [] };
|
|
106
|
+
}
|
|
107
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
108
|
+
return { status: 'degraded', reason: `transcript_read_failed:${detail}`, nextAction: 'verify the transcript file still exists and is a regular file inside the Codex sessions root.', warnings: [] };
|
|
109
|
+
}
|
|
110
|
+
if (offset > window.fileSize) {
|
|
111
|
+
// The file shrank below the committed cursor: replaced or truncated —
|
|
112
|
+
// never guess; hold the checkpoint and degrade explicitly.
|
|
113
|
+
return { status: 'degraded', reason: 'checkpoint_inconsistent', nextAction: 'the transcript is shorter than the committed checkpoint; run the audited recovery/quarantine path once available or re-ingest from a fresh rollout.', warnings: [] };
|
|
114
|
+
}
|
|
115
|
+
const byteBoundReached = offset + window.bytes.length < window.fileSize;
|
|
116
|
+
const decoded = decodeTranscriptWindow({
|
|
117
|
+
bytes: window.bytes,
|
|
118
|
+
fileOffset: offset,
|
|
119
|
+
byteBoundReached,
|
|
120
|
+
rolloutIdentity,
|
|
121
|
+
fallbackRootSessionId: existing !== null ? existing.rootSessionId : fields.sessionId,
|
|
122
|
+
nowIso: now.toISOString(),
|
|
123
|
+
});
|
|
124
|
+
// Supported-version guard (SPEC §9): the version signal lives in the
|
|
125
|
+
// transcript session_meta (or the committed checkpoint after the first
|
|
126
|
+
// batch). Anything below the verified floor or above the verified ceiling
|
|
127
|
+
// degrades explicitly — never guess record fields of an unknown contract.
|
|
128
|
+
const cliVersion = decoded.rolloutMeta.cliVersion ?? existing?.cliVersion ?? null;
|
|
129
|
+
const version = classifyCodexVersion(cliVersion);
|
|
130
|
+
if (version.status !== 'supported') {
|
|
131
|
+
return {
|
|
132
|
+
status: 'degraded',
|
|
133
|
+
reason: `${version.reason}:${cliVersion ?? 'unknown'}`,
|
|
134
|
+
nextAction: CODEX_VERSION_NEXT_ACTION,
|
|
135
|
+
warnings: decoded.warnings,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const degradations = [];
|
|
139
|
+
if (decoded.stop.kind === 'malformed') {
|
|
140
|
+
degradations.push({ reason: 'transcript_record_malformed', ...(decoded.stop.ordinal !== null ? { ordinal: decoded.stop.ordinal } : {}), nextAction: 'the record is stable-invalid; run the audited quarantine command once available (Slice D). Later records remain as lag.' });
|
|
141
|
+
}
|
|
142
|
+
else if (decoded.stop.kind === 'oversized_record') {
|
|
143
|
+
degradations.push({ reason: 'transcript_record_too_large', nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.' });
|
|
144
|
+
}
|
|
145
|
+
const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ?? fields.sessionId;
|
|
146
|
+
const result = ingestGovernanceObservations({
|
|
147
|
+
workspaceDir,
|
|
148
|
+
rollout: {
|
|
149
|
+
hostKind: 'codex',
|
|
150
|
+
rolloutIdentity,
|
|
151
|
+
rootSessionId,
|
|
152
|
+
...(decoded.rolloutMeta.parentRolloutIdentity !== null ? { parentRolloutIdentity: decoded.rolloutMeta.parentRolloutIdentity } : {}),
|
|
153
|
+
...(decoded.rolloutMeta.agentIdentity !== null ? { agentIdentity: decoded.rolloutMeta.agentIdentity } : {}),
|
|
154
|
+
...(decoded.rolloutMeta.agentDepth !== null ? { agentDepth: decoded.rolloutMeta.agentDepth } : {}),
|
|
155
|
+
},
|
|
156
|
+
observations: decoded.observations,
|
|
157
|
+
checkpoint: {
|
|
158
|
+
hostKind: 'codex',
|
|
159
|
+
rolloutIdentity,
|
|
160
|
+
byteOffset: decoded.nextByteOffset,
|
|
161
|
+
lastOrdinal: decoded.lastOrdinal >= 0 ? decoded.lastOrdinal : existing?.lastOrdinal ?? 0,
|
|
162
|
+
cliVersion: cliVersion ?? undefined,
|
|
163
|
+
rootSessionId,
|
|
164
|
+
incompleteTail: decoded.stop.kind === 'incomplete_tail',
|
|
165
|
+
},
|
|
166
|
+
...(degradations.length > 0 ? { degradations } : {}),
|
|
167
|
+
...(decoded.compactionTimestamp !== null ? { compactionTimestamp: decoded.compactionTimestamp } : {}),
|
|
168
|
+
...(decoded.rollbackTurns.length > 0 ? { rollbackTurns: decoded.rollbackTurns } : {}),
|
|
169
|
+
now,
|
|
170
|
+
});
|
|
171
|
+
const warnings = [...decoded.warnings, ...result.warnings];
|
|
172
|
+
if (!result.ok) {
|
|
173
|
+
return { status: 'degraded', reason: result.reason ?? 'governance_write_failed', nextAction: result.nextAction ?? 'inspect the workspace trajectory database.', warnings };
|
|
174
|
+
}
|
|
175
|
+
if (decoded.stop.kind === 'incomplete_tail') {
|
|
176
|
+
warnings.push('transcript_incomplete_tail');
|
|
177
|
+
}
|
|
178
|
+
if (decoded.stop.kind === 'malformed') {
|
|
179
|
+
// The store already committed the records before the malformed line and
|
|
180
|
+
// held the checkpoint at it; surface the stable failure loudly (SPEC §14.2).
|
|
181
|
+
return {
|
|
182
|
+
status: 'degraded',
|
|
183
|
+
reason: 'transcript_record_malformed',
|
|
184
|
+
nextAction: 'the record is stable-invalid; run the audited quarantine command once available (Slice D). Later records remain as lag.',
|
|
185
|
+
warnings,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (decoded.stop.kind === 'oversized_record') {
|
|
189
|
+
return {
|
|
190
|
+
status: 'degraded',
|
|
191
|
+
reason: 'transcript_record_too_large',
|
|
192
|
+
nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.',
|
|
193
|
+
warnings,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
status: 'ok',
|
|
198
|
+
inserted: result.inserted,
|
|
199
|
+
enriched: result.enriched,
|
|
200
|
+
duplicates: result.duplicates,
|
|
201
|
+
warnings,
|
|
202
|
+
lagBytes: Math.max(0, window.fileSize - decoded.nextByteOffset),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export function ingestCodexConversation(rawPayload, kind, options) {
|
|
206
|
+
const fields = extractFields(rawPayload);
|
|
207
|
+
if (fields === null || fields.sessionId.length === 0) {
|
|
208
|
+
return { status: 'degraded', reason: 'transcript_path_invalid', nextAction: 'the hook payload lacks the required Codex identity fields.', warnings: [] };
|
|
209
|
+
}
|
|
210
|
+
if (fields.transcriptPath === null) {
|
|
211
|
+
// SPEC §9: never scan for another file; keep a neutral degraded result.
|
|
212
|
+
return { status: 'degraded', reason: 'transcript_unavailable', nextAction: 'Codex supplied no transcript for this event; nothing is read or fabricated.', warnings: [] };
|
|
213
|
+
}
|
|
214
|
+
const home = resolveCodexHome(options.env);
|
|
215
|
+
if (!home.ok)
|
|
216
|
+
return { status: 'degraded', reason: home.reason, nextAction: home.nextAction, warnings: [] };
|
|
217
|
+
const validated = validateCodexTranscriptPath(fields.transcriptPath, home.home);
|
|
218
|
+
if (!validated.ok)
|
|
219
|
+
return { status: 'degraded', reason: validated.reason, nextAction: validated.nextAction, warnings: [] };
|
|
220
|
+
const now = options.now ?? new Date();
|
|
221
|
+
if (kind === 'before_prompt_build' || kind === 'after_tool_call') {
|
|
222
|
+
return ingestLiveObservation({ fields, rolloutIdentity: validated.rolloutIdentity, workspaceDir: options.workspaceDir, now });
|
|
223
|
+
}
|
|
224
|
+
return ingestTranscriptDelta({
|
|
225
|
+
fields,
|
|
226
|
+
canonicalPath: validated.canonicalPath,
|
|
227
|
+
identity: validated.identity,
|
|
228
|
+
rolloutIdentity: validated.rolloutIdentity,
|
|
229
|
+
workspaceDir: options.workspaceDir,
|
|
230
|
+
env: options.env ?? {},
|
|
231
|
+
now,
|
|
232
|
+
port: activeTranscriptPort(options),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { GovernanceObservationInput } from '@principles/host-runtime';
|
|
2
|
+
export declare const CODEX_INGESTION_MAX_BATCH_BYTES: number;
|
|
3
|
+
export declare const CODEX_INGESTION_MAX_BATCH_RECORDS = 256;
|
|
4
|
+
export interface TranscriptReadResult {
|
|
5
|
+
/** Raw bytes read from [offset, offset+maxBytes) — never a decoded string, so multi-byte UTF-8 never corrupts offset math. */
|
|
6
|
+
readonly bytes: Buffer;
|
|
7
|
+
readonly fileSize: number;
|
|
8
|
+
}
|
|
9
|
+
/** Identity the validator captured pre-open; the port must re-prove it post-open (SPEC §9 TOCTOU). */
|
|
10
|
+
export interface TranscriptExpectedIdentity {
|
|
11
|
+
readonly dev: number;
|
|
12
|
+
readonly ino: number;
|
|
13
|
+
readonly size: number;
|
|
14
|
+
readonly mtimeMs: number;
|
|
15
|
+
}
|
|
16
|
+
/** Arguments for one bounded transcript window read. */
|
|
17
|
+
export interface TranscriptReadRequest {
|
|
18
|
+
readonly canonicalPath: string;
|
|
19
|
+
readonly offset: number;
|
|
20
|
+
readonly maxBytes: number;
|
|
21
|
+
/** Pre-open identity from the validator; the port re-proves it post-open. */
|
|
22
|
+
readonly expectedIdentity?: TranscriptExpectedIdentity;
|
|
23
|
+
}
|
|
24
|
+
/** Filesystem boundary for the transcript read — injectable so tests can spy. */
|
|
25
|
+
export interface TranscriptPort {
|
|
26
|
+
read(request: TranscriptReadRequest): TranscriptReadResult;
|
|
27
|
+
}
|
|
28
|
+
/** Thrown by the port when the opened object is not the validated one (replacement/TOCTOU). */
|
|
29
|
+
export declare class TranscriptReplacedError extends Error {
|
|
30
|
+
constructor();
|
|
31
|
+
}
|
|
32
|
+
export declare function createNodeTranscriptPort(): TranscriptPort;
|
|
33
|
+
export type TranscriptDecodeStop = {
|
|
34
|
+
kind: 'eof';
|
|
35
|
+
} | {
|
|
36
|
+
kind: 'incomplete_tail';
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'malformed';
|
|
39
|
+
ordinal: number | null;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'byte_bound';
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'oversized_record';
|
|
44
|
+
};
|
|
45
|
+
export interface DecodedDelta {
|
|
46
|
+
readonly observations: readonly GovernanceObservationInput[];
|
|
47
|
+
readonly rolloutMeta: {
|
|
48
|
+
rootSessionId: string | null;
|
|
49
|
+
cliVersion: string | null;
|
|
50
|
+
parentRolloutIdentity: string | null;
|
|
51
|
+
agentIdentity: string | null;
|
|
52
|
+
agentDepth: number | null;
|
|
53
|
+
};
|
|
54
|
+
readonly compactionTimestamp: string | null;
|
|
55
|
+
readonly rollbackTurns: readonly number[];
|
|
56
|
+
readonly stop: TranscriptDecodeStop;
|
|
57
|
+
/** Checkpoint byte offset to commit: start of the first unconsumed byte. */
|
|
58
|
+
readonly nextByteOffset: number;
|
|
59
|
+
/** Highest ordinal consumed (for the checkpoint's last_ordinal). */
|
|
60
|
+
readonly lastOrdinal: number;
|
|
61
|
+
readonly warnings: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
export interface DecodeTranscriptWindowInput {
|
|
64
|
+
readonly bytes: Buffer;
|
|
65
|
+
readonly fileOffset: number;
|
|
66
|
+
/** True when bytes.length did not reach EOF — the window was cut by the bounded read. */
|
|
67
|
+
readonly byteBoundReached: boolean;
|
|
68
|
+
readonly rolloutIdentity: string;
|
|
69
|
+
readonly fallbackRootSessionId: string | null;
|
|
70
|
+
readonly nowIso: string;
|
|
71
|
+
}
|
|
72
|
+
export declare function decodeTranscriptWindow(input: DecodeTranscriptWindowInput): DecodedDelta;
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex rollout transcript incremental decoder (Codex Governance Closure
|
|
3
|
+
* Slice A; SPEC rev 2 §9, G1 probe report §6).
|
|
4
|
+
*
|
|
5
|
+
* Decodes a bounded byte window from the durable checkpoint offset and
|
|
6
|
+
* projects ONLY governance-relevant visible facts (SPEC §12 privacy
|
|
7
|
+
* boundary): genuine visible user messages (`content_item_kinds[0] ===
|
|
8
|
+
* "user.text"` — host-injected context arrives as user-role records with
|
|
9
|
+
* other kinds), visible assistant commentary/final messages, and tool
|
|
10
|
+
* execution facts anchored on the `event_msg item_completed
|
|
11
|
+
* CommandExecution` record whose `item.id` IS the hook `tool_use_id` id
|
|
12
|
+
* space (the G1-verified bridge; the model-level `call_id` is a different
|
|
13
|
+
* space). Hidden reasoning, system/developer prompts, world_state,
|
|
14
|
+
* host-injected context, and unknown record bodies are identified by shape
|
|
15
|
+
* and dropped before persistence — never projected, logged, or emitted.
|
|
16
|
+
*
|
|
17
|
+
* Failure classes are distinct (SPEC §14): an incomplete final line (no
|
|
18
|
+
* trailing newline at EOF) is a transient append/flush boundary — the
|
|
19
|
+
* checkpoint does not advance past it and the next ingestion retries; a
|
|
20
|
+
* complete line that fails JSON parsing or envelope validation is a stable
|
|
21
|
+
* malformed record — decoding stops at it without advancing, with a
|
|
22
|
+
* structured reason. Unknown-but-well-formed record types are skipped with
|
|
23
|
+
* a bounded warning and DO advance.
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
export const CODEX_INGESTION_MAX_BATCH_BYTES = 1024 * 1024;
|
|
27
|
+
export const CODEX_INGESTION_MAX_BATCH_RECORDS = 256;
|
|
28
|
+
/** Thrown by the port when the opened object is not the validated one (replacement/TOCTOU). */
|
|
29
|
+
export class TranscriptReplacedError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super('transcript_replaced_after_validation');
|
|
32
|
+
this.name = 'TranscriptReplacedError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function identityMatches(opened, expected) {
|
|
36
|
+
// Size+mtime fingerprint must ALWAYS hold. When the platform reports a
|
|
37
|
+
// Number-safe inode on both sides, the inode must match too (it is what
|
|
38
|
+
// catches delete+recreate replacements whose inode got reused on Linux CI
|
|
39
|
+
// with a same-ms write; a same-size same-ms recreate remains outside the
|
|
40
|
+
// threat model for an append-only host-owned file). On platforms whose
|
|
41
|
+
// ino exceeds Number.MAX_SAFE_INTEGER (Windows file IDs), the inode is not
|
|
42
|
+
// reliably comparable and the fingerprint alone decides. A false refusal
|
|
43
|
+
// is the safe direction: the hook degrades observably and the next Stop
|
|
44
|
+
// revalidates and retries.
|
|
45
|
+
if (opened.size !== expected.size || opened.mtimeMs !== expected.mtimeMs)
|
|
46
|
+
return false;
|
|
47
|
+
const comparableIno = expected.ino !== 0 && expected.dev !== 0
|
|
48
|
+
&& opened.ino !== 0 && Number.isSafeInteger(expected.ino) && Number.isSafeInteger(opened.ino);
|
|
49
|
+
if (comparableIno)
|
|
50
|
+
return opened.ino === expected.ino && opened.dev === expected.dev;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
export function createNodeTranscriptPort() {
|
|
54
|
+
return {
|
|
55
|
+
read({ canonicalPath, offset, maxBytes, expectedIdentity }) {
|
|
56
|
+
const fd = fs.openSync(canonicalPath, 'r');
|
|
57
|
+
try {
|
|
58
|
+
// Post-open revalidation (G1 §7 / SPEC §9): fstat the OPEN OBJECT —
|
|
59
|
+
// not the path — and re-prove it is the regular file the validator
|
|
60
|
+
// approved, still at the expected identity. A path swapped between
|
|
61
|
+
// validation and open (replacement/symlink TOCTOU) is refused before
|
|
62
|
+
// a single byte is read.
|
|
63
|
+
const stats = fs.fstatSync(fd);
|
|
64
|
+
if (!stats.isFile())
|
|
65
|
+
throw new Error('transcript_is_not_regular_file');
|
|
66
|
+
if (expectedIdentity !== undefined && !identityMatches(stats, expectedIdentity)) {
|
|
67
|
+
throw new TranscriptReplacedError();
|
|
68
|
+
}
|
|
69
|
+
const length = Math.max(0, Math.min(maxBytes, stats.size - offset));
|
|
70
|
+
if (length === 0)
|
|
71
|
+
return { bytes: Buffer.alloc(0), fileSize: stats.size };
|
|
72
|
+
const buffer = Buffer.alloc(length);
|
|
73
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
74
|
+
// Post-read byte bound re-verification (EP-08): never trust the
|
|
75
|
+
// pre-read stat for the enforced cap.
|
|
76
|
+
const actual = bytesRead < 0 ? 0 : bytesRead;
|
|
77
|
+
if (actual > maxBytes)
|
|
78
|
+
throw new Error('transcript_read_exceeded_bound');
|
|
79
|
+
return { bytes: buffer.subarray(0, actual), fileSize: stats.size };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function isRecord(value) {
|
|
88
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
function own(value, key) {
|
|
91
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
92
|
+
}
|
|
93
|
+
function metadataTurnId(payload) {
|
|
94
|
+
const passthrough = own(payload, 'internal_chat_message_metadata_passthrough');
|
|
95
|
+
if (!isRecord(passthrough))
|
|
96
|
+
return null;
|
|
97
|
+
const turnId = own(passthrough, 'turn_id');
|
|
98
|
+
return typeof turnId === 'string' && turnId.length > 0 ? turnId : null;
|
|
99
|
+
}
|
|
100
|
+
function contentItemKinds(payload) {
|
|
101
|
+
const passthrough = own(payload, 'internal_chat_message_metadata_passthrough');
|
|
102
|
+
if (!isRecord(passthrough))
|
|
103
|
+
return [];
|
|
104
|
+
const kinds = own(passthrough, 'content_item_kinds');
|
|
105
|
+
if (!Array.isArray(kinds))
|
|
106
|
+
return [];
|
|
107
|
+
return kinds.filter((kind) => typeof kind === 'string');
|
|
108
|
+
}
|
|
109
|
+
function joinContentText(payload, type) {
|
|
110
|
+
const content = own(payload, 'content');
|
|
111
|
+
if (!Array.isArray(content))
|
|
112
|
+
return null;
|
|
113
|
+
const parts = [];
|
|
114
|
+
for (const item of content) {
|
|
115
|
+
if (!isRecord(item))
|
|
116
|
+
continue;
|
|
117
|
+
if (own(item, 'type') === type && typeof own(item, 'text') === 'string')
|
|
118
|
+
parts.push(own(item, 'text'));
|
|
119
|
+
}
|
|
120
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
121
|
+
}
|
|
122
|
+
function warnBounded(context, warning) {
|
|
123
|
+
if (context.warnings.length < 16)
|
|
124
|
+
context.warnings.push(warning);
|
|
125
|
+
}
|
|
126
|
+
function applySessionMeta(context, payload) {
|
|
127
|
+
const sessionId = own(payload, 'session_id');
|
|
128
|
+
if (typeof sessionId === 'string' && sessionId.length > 0 && context.meta.rootSessionId === null) {
|
|
129
|
+
// Root-session lineage only. For subagent rollouts this value is the
|
|
130
|
+
// PARENT thread id (G1 §4 collision trap) — rollout identity comes from
|
|
131
|
+
// the file uuid, never from session_meta.session_id.
|
|
132
|
+
context.meta.rootSessionId = sessionId;
|
|
133
|
+
}
|
|
134
|
+
const cliVersion = own(payload, 'cli_version');
|
|
135
|
+
if (typeof cliVersion === 'string' && cliVersion.length > 0 && context.meta.cliVersion === null) {
|
|
136
|
+
context.meta.cliVersion = cliVersion;
|
|
137
|
+
}
|
|
138
|
+
const source = own(payload, 'source');
|
|
139
|
+
const spawn = isRecord(source) ? own(source, 'subagent') : undefined;
|
|
140
|
+
const threadSpawn = isRecord(spawn) ? own(spawn, 'thread_spawn') : undefined;
|
|
141
|
+
if (isRecord(threadSpawn)) {
|
|
142
|
+
const parentThreadId = own(threadSpawn, 'parent_thread_id');
|
|
143
|
+
if (typeof parentThreadId === 'string' && parentThreadId.length > 0)
|
|
144
|
+
context.meta.parentRolloutIdentity = parentThreadId;
|
|
145
|
+
const nickname = own(threadSpawn, 'agent_nickname');
|
|
146
|
+
const agentPath = own(threadSpawn, 'agent_path');
|
|
147
|
+
const identity = typeof nickname === 'string' && nickname.length > 0 ? nickname : typeof agentPath === 'string' && agentPath.length > 0 ? agentPath : null;
|
|
148
|
+
if (identity !== null)
|
|
149
|
+
context.meta.agentIdentity = identity;
|
|
150
|
+
const depth = own(threadSpawn, 'depth');
|
|
151
|
+
if (typeof depth === 'number' && Number.isInteger(depth) && depth >= 0)
|
|
152
|
+
context.meta.agentDepth = depth;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const forkedFrom = own(payload, 'forked_from_id');
|
|
156
|
+
if (typeof forkedFrom === 'string' && forkedFrom.length > 0 && context.meta.parentRolloutIdentity === null) {
|
|
157
|
+
context.meta.parentRolloutIdentity = forkedFrom;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function joinItemText(item) {
|
|
162
|
+
const content = own(item, 'content');
|
|
163
|
+
if (!Array.isArray(content))
|
|
164
|
+
return null;
|
|
165
|
+
const parts = [];
|
|
166
|
+
for (const entry of content) {
|
|
167
|
+
if (!isRecord(entry))
|
|
168
|
+
continue;
|
|
169
|
+
// UserMessage items use "text", AgentMessage items use "Text".
|
|
170
|
+
const type = own(entry, 'type');
|
|
171
|
+
if ((type === 'text' || type === 'Text') && typeof own(entry, 'text') === 'string') {
|
|
172
|
+
parts.push(own(entry, 'text'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
176
|
+
}
|
|
177
|
+
function observationBase(context, turnId) {
|
|
178
|
+
return {
|
|
179
|
+
hostKind: 'codex',
|
|
180
|
+
rolloutIdentity: context.rolloutIdentity,
|
|
181
|
+
rootSessionId: context.meta.rootSessionId ?? context.fallbackRootSessionId ?? turnId,
|
|
182
|
+
hostTurnId: turnId,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function projectRecord(context, record) {
|
|
186
|
+
const { type, payload, ordinal, recordByteStart, timestamp } = record;
|
|
187
|
+
const observedAt = timestamp ?? context.nowIso;
|
|
188
|
+
if (type === 'session_meta') {
|
|
189
|
+
applySessionMeta(context, payload);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (type === 'compacted') {
|
|
193
|
+
// Marker: replacement_history becomes the logical history going forward;
|
|
194
|
+
// replaced records must not be re-imported as new turns (G1 §6). The
|
|
195
|
+
// store tombstones prior unpromoted observations at ingest time.
|
|
196
|
+
context.compactionTimestamp = observedAt;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (type === 'world_state' || type === 'inter_agent_communication_metadata') {
|
|
200
|
+
// Environment/host snapshots — identified and dropped (privacy boundary).
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (type === 'turn_context') {
|
|
204
|
+
// Per-turn host configuration (sandbox/permissions/model) — known type,
|
|
205
|
+
// no governance projection, no warning.
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (type === 'response_item') {
|
|
209
|
+
const payloadType = own(payload, 'type');
|
|
210
|
+
if (payloadType === 'reasoning')
|
|
211
|
+
return; // hidden/encrypted reasoning — never projected
|
|
212
|
+
if (payloadType === 'custom_tool_call' || payloadType === 'function_call') {
|
|
213
|
+
const turnId = metadataTurnId(payload);
|
|
214
|
+
const callId = own(payload, 'call_id');
|
|
215
|
+
const name = own(payload, 'name');
|
|
216
|
+
if (turnId !== null) {
|
|
217
|
+
const queue = context.modelCallsByTurn.get(turnId) ?? [];
|
|
218
|
+
queue.push({ callId: typeof callId === 'string' ? callId : null, name: typeof name === 'string' ? name : null });
|
|
219
|
+
context.modelCallsByTurn.set(turnId, queue);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (payloadType === 'message' && own(payload, 'role') === 'user') {
|
|
224
|
+
// 0.150.1 marks genuine visible user input with content_item_kinds[0]
|
|
225
|
+
// === "user.text"; host-injected context carries other kinds. 0.148.0
|
|
226
|
+
// does not emit content_item_kinds at all, so this channel is used ONLY
|
|
227
|
+
// when the discriminator exists — the authoritative cross-version user
|
|
228
|
+
// channel is the item_completed UserMessage record below.
|
|
229
|
+
const kinds = contentItemKinds(payload);
|
|
230
|
+
if (kinds.length === 0 || kinds[0] !== 'user.text')
|
|
231
|
+
return;
|
|
232
|
+
const turnId = metadataTurnId(payload);
|
|
233
|
+
if (turnId === null) {
|
|
234
|
+
warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const text = joinContentText(payload, 'input_text');
|
|
238
|
+
context.observations.push({
|
|
239
|
+
...observationBase(context, turnId),
|
|
240
|
+
kind: 'user_turn',
|
|
241
|
+
logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|user`,
|
|
242
|
+
transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
|
|
243
|
+
recordOrdinal: ordinal,
|
|
244
|
+
recordByteStart,
|
|
245
|
+
visibleText: text ?? undefined,
|
|
246
|
+
source: 'transcript',
|
|
247
|
+
completeness: text !== null ? 'complete' : 'partial',
|
|
248
|
+
observedAt,
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Assistant/developer response_item messages are not projected here: the
|
|
253
|
+
// item_completed AgentMessage channel is the cross-version visible
|
|
254
|
+
// assistant source (0.148.0 lacks content_item_kinds discriminators).
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (type === 'event_msg') {
|
|
258
|
+
const payloadType = own(payload, 'type');
|
|
259
|
+
if (payloadType === 'item_completed') {
|
|
260
|
+
const item = own(payload, 'item');
|
|
261
|
+
if (!isRecord(item))
|
|
262
|
+
return;
|
|
263
|
+
const itemType = own(item, 'type');
|
|
264
|
+
const turnId = own(payload, 'turn_id');
|
|
265
|
+
if (itemType === 'UserMessage') {
|
|
266
|
+
// Only genuine visible user turns produce UserMessage completions —
|
|
267
|
+
// host-injected context never does (both supported versions).
|
|
268
|
+
if (typeof turnId !== 'string' || turnId.length === 0) {
|
|
269
|
+
warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const text = joinItemText(item);
|
|
273
|
+
context.observations.push({
|
|
274
|
+
...observationBase(context, turnId),
|
|
275
|
+
kind: 'user_turn',
|
|
276
|
+
logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|user`,
|
|
277
|
+
transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
|
|
278
|
+
recordOrdinal: ordinal,
|
|
279
|
+
recordByteStart,
|
|
280
|
+
visibleText: text ?? undefined,
|
|
281
|
+
source: 'transcript',
|
|
282
|
+
completeness: text !== null ? 'complete' : 'partial',
|
|
283
|
+
observedAt,
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (itemType === 'AgentMessage') {
|
|
288
|
+
if (typeof turnId !== 'string' || turnId.length === 0) {
|
|
289
|
+
warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const itemId = own(item, 'id');
|
|
293
|
+
if (typeof itemId !== 'string' || itemId.length === 0) {
|
|
294
|
+
warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const text = joinItemText(item);
|
|
298
|
+
// AgentMessage phase sits on the item (fixture-verified; keep the
|
|
299
|
+
// payload sibling as a tolerated alternative).
|
|
300
|
+
const phase = own(item, 'phase') ?? own(payload, 'phase');
|
|
301
|
+
context.observations.push({
|
|
302
|
+
...observationBase(context, turnId),
|
|
303
|
+
kind: 'assistant_turn',
|
|
304
|
+
logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|${itemId}`,
|
|
305
|
+
transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
|
|
306
|
+
recordOrdinal: ordinal,
|
|
307
|
+
recordByteStart,
|
|
308
|
+
assistantItemId: itemId,
|
|
309
|
+
phase: typeof phase === 'string' ? phase : undefined,
|
|
310
|
+
visibleText: text ?? undefined,
|
|
311
|
+
source: 'transcript',
|
|
312
|
+
completeness: text !== null ? 'complete' : 'partial',
|
|
313
|
+
observedAt,
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (itemType === 'CommandExecution') {
|
|
318
|
+
const toolUseId = own(item, 'id');
|
|
319
|
+
if (typeof toolUseId !== 'string' || toolUseId.length === 0 || typeof turnId !== 'string' || turnId.length === 0) {
|
|
320
|
+
warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// FIFO-pair with the model-level call in the same turn for the tool
|
|
324
|
+
// name / call_id enrichment (execution order; G1 bridge fixture).
|
|
325
|
+
const queue = context.modelCallsByTurn.get(turnId);
|
|
326
|
+
const paired = queue !== undefined && queue.length > 0 ? queue.shift() : undefined;
|
|
327
|
+
if (queue !== undefined && queue.length === 0)
|
|
328
|
+
context.modelCallsByTurn.delete(turnId);
|
|
329
|
+
const exitCode = own(item, 'exit_code');
|
|
330
|
+
const command = own(item, 'command');
|
|
331
|
+
const stdout = own(item, 'stdout');
|
|
332
|
+
const stderr = own(item, 'stderr');
|
|
333
|
+
context.observations.push({
|
|
334
|
+
...observationBase(context, turnId),
|
|
335
|
+
kind: 'tool_call',
|
|
336
|
+
logicalObservationKey: `codex|${context.rolloutIdentity}|${toolUseId}`,
|
|
337
|
+
transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
|
|
338
|
+
recordOrdinal: ordinal,
|
|
339
|
+
recordByteStart,
|
|
340
|
+
toolUseId,
|
|
341
|
+
transcriptToolCallId: paired?.callId ?? undefined,
|
|
342
|
+
toolFacts: {
|
|
343
|
+
toolName: paired?.name ?? null,
|
|
344
|
+
exitCode: typeof exitCode === 'number' ? exitCode : null,
|
|
345
|
+
command: Array.isArray(command) ? command.filter((part) => typeof part === 'string').slice(0, 8) : null,
|
|
346
|
+
stdout: typeof stdout === 'string' ? stdout : null,
|
|
347
|
+
stderr: typeof stderr === 'string' ? stderr : null,
|
|
348
|
+
},
|
|
349
|
+
source: 'transcript',
|
|
350
|
+
completeness: 'complete',
|
|
351
|
+
observedAt,
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (payloadType === 'thread_rolled_back') {
|
|
358
|
+
// Documented rule (G1 runtime contract, thread_rollout_truncation.rs):
|
|
359
|
+
// rollback appends a marker carrying num_turns; physical records remain
|
|
360
|
+
// while effective logical history is truncated.
|
|
361
|
+
const numTurns = own(payload, 'num_turns');
|
|
362
|
+
if (typeof numTurns === 'number' && Number.isInteger(numTurns) && numTurns > 0) {
|
|
363
|
+
context.rollbackTurns.push(numTurns);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// task_started / turn_context / token_count / task_complete /
|
|
368
|
+
// thread_settings_applied: known turn bookkeeping — no projection.
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
warnBounded(context, `unknown_record_type_skipped:${type}`);
|
|
372
|
+
}
|
|
373
|
+
function decodeLine(lineBytes) {
|
|
374
|
+
const text = lineBytes.toString('utf8');
|
|
375
|
+
let parsed;
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(text);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return { record: null, type: null, ordinal: null, timestamp: null, payload: null };
|
|
381
|
+
}
|
|
382
|
+
if (!isRecord(parsed))
|
|
383
|
+
return { record: null, type: null, ordinal: null, timestamp: null, payload: null };
|
|
384
|
+
const type = own(parsed, 'type');
|
|
385
|
+
const ordinal = own(parsed, 'ordinal');
|
|
386
|
+
const timestamp = own(parsed, 'timestamp');
|
|
387
|
+
const payload = own(parsed, 'payload');
|
|
388
|
+
const validEnvelope = typeof type === 'string' && type.length > 0
|
|
389
|
+
&& typeof ordinal === 'number' && Number.isInteger(ordinal)
|
|
390
|
+
&& (payload === undefined || isRecord(payload));
|
|
391
|
+
if (!validEnvelope) {
|
|
392
|
+
return { record: parsed, type: null, ordinal: typeof ordinal === 'number' && Number.isInteger(ordinal) ? ordinal : null, timestamp: null, payload: null };
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
record: parsed,
|
|
396
|
+
type,
|
|
397
|
+
ordinal,
|
|
398
|
+
timestamp: typeof timestamp === 'string' ? timestamp : null,
|
|
399
|
+
payload: payload === undefined ? {} : payload,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function finish(context, nextByteOffset, lastOrdinal) {
|
|
403
|
+
return {
|
|
404
|
+
observations: context.observations,
|
|
405
|
+
rolloutMeta: context.meta,
|
|
406
|
+
compactionTimestamp: context.compactionTimestamp,
|
|
407
|
+
rollbackTurns: context.rollbackTurns,
|
|
408
|
+
nextByteOffset,
|
|
409
|
+
lastOrdinal,
|
|
410
|
+
warnings: context.warnings,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export function decodeTranscriptWindow(input) {
|
|
414
|
+
const context = {
|
|
415
|
+
rolloutIdentity: input.rolloutIdentity,
|
|
416
|
+
fallbackRootSessionId: input.fallbackRootSessionId,
|
|
417
|
+
nowIso: input.nowIso,
|
|
418
|
+
observations: [],
|
|
419
|
+
meta: { rootSessionId: null, cliVersion: null, parentRolloutIdentity: null, agentIdentity: null, agentDepth: null },
|
|
420
|
+
modelCallsByTurn: new Map(),
|
|
421
|
+
compactionTimestamp: null,
|
|
422
|
+
rollbackTurns: [],
|
|
423
|
+
warnings: [],
|
|
424
|
+
};
|
|
425
|
+
const { bytes } = input;
|
|
426
|
+
if (bytes.length === 0) {
|
|
427
|
+
return { ...finish(context, input.fileOffset, -1), stop: { kind: 'eof' } };
|
|
428
|
+
}
|
|
429
|
+
// UTF-8 multi-byte sequences never contain 0x0A, so splitting the byte
|
|
430
|
+
// window at newline bytes is safe and keeps the checkpoint byte-exact.
|
|
431
|
+
const lastNewline = bytes.lastIndexOf(0x0a);
|
|
432
|
+
const completeEnd = lastNewline + 1; // exclusive byte end of the newline-terminated region
|
|
433
|
+
const tailBytes = bytes.subarray(completeEnd); // bytes after the final newline (no terminator)
|
|
434
|
+
let cursor = input.fileOffset;
|
|
435
|
+
let lastOrdinal = -1;
|
|
436
|
+
let recordsConsumed = 0;
|
|
437
|
+
let lineStart = 0;
|
|
438
|
+
while (lineStart < completeEnd) {
|
|
439
|
+
const lineEnd = bytes.indexOf(0x0a, lineStart);
|
|
440
|
+
if (lineEnd === -1 || lineEnd >= completeEnd)
|
|
441
|
+
break;
|
|
442
|
+
const lineBytes = bytes.subarray(lineStart, lineEnd);
|
|
443
|
+
const recordByteStart = cursor;
|
|
444
|
+
cursor = input.fileOffset + lineEnd + 1;
|
|
445
|
+
lineStart = lineEnd + 1;
|
|
446
|
+
if (lineBytes.length === 0 || lineBytes.toString('utf8').trim().length === 0)
|
|
447
|
+
continue;
|
|
448
|
+
if (recordsConsumed >= CODEX_INGESTION_MAX_BATCH_RECORDS) {
|
|
449
|
+
return { ...finish(context, recordByteStart, lastOrdinal), stop: { kind: 'byte_bound' } };
|
|
450
|
+
}
|
|
451
|
+
const line = decodeLine(lineBytes);
|
|
452
|
+
if (line.record === null || line.type === null) {
|
|
453
|
+
// A newline-terminated line that does not parse (or fails the record
|
|
454
|
+
// envelope) is a stable malformed record: stop here without advancing
|
|
455
|
+
// past it (SPEC §14.2).
|
|
456
|
+
return { ...finish(context, recordByteStart, lastOrdinal), stop: { kind: 'malformed', ordinal: line.ordinal } };
|
|
457
|
+
}
|
|
458
|
+
recordsConsumed += 1;
|
|
459
|
+
lastOrdinal = line.ordinal;
|
|
460
|
+
projectRecord(context, { type: line.type, payload: line.payload ?? {}, ordinal: line.ordinal, recordByteStart, timestamp: line.timestamp });
|
|
461
|
+
}
|
|
462
|
+
if (tailBytes.length > 0) {
|
|
463
|
+
const tailStart = cursor; // byte offset where the unterminated tail begins
|
|
464
|
+
if (input.byteBoundReached) {
|
|
465
|
+
// The window ended mid-line because of the byte bound — the line is not
|
|
466
|
+
// known-incomplete; leave it wholly unread and report bounded lag.
|
|
467
|
+
if (lastNewline === -1) {
|
|
468
|
+
return { ...finish(context, input.fileOffset, lastOrdinal), stop: { kind: 'oversized_record' } };
|
|
469
|
+
}
|
|
470
|
+
return { ...finish(context, tailStart, lastOrdinal), stop: { kind: 'byte_bound' } };
|
|
471
|
+
}
|
|
472
|
+
const line = decodeLine(tailBytes);
|
|
473
|
+
if (line.record !== null && line.type !== null) {
|
|
474
|
+
// A parseable final line without a trailing newline is a complete
|
|
475
|
+
// record; consume it and advance the checkpoint to EOF.
|
|
476
|
+
lastOrdinal = line.ordinal;
|
|
477
|
+
projectRecord(context, { type: line.type, payload: line.payload ?? {}, ordinal: line.ordinal, recordByteStart: tailStart, timestamp: line.timestamp });
|
|
478
|
+
return { ...finish(context, tailStart + tailBytes.length, lastOrdinal), stop: { kind: 'eof' } };
|
|
479
|
+
}
|
|
480
|
+
// Incomplete final line: transient append/flush boundary — do not
|
|
481
|
+
// advance past the previous record; the next ingestion retries (SPEC §14.1).
|
|
482
|
+
return { ...finish(context, tailStart, lastOrdinal), stop: { kind: 'incomplete_tail' } };
|
|
483
|
+
}
|
|
484
|
+
return { ...finish(context, cursor, lastOrdinal), stop: input.byteBoundReached ? { kind: 'byte_bound' } : { kind: 'eof' } };
|
|
485
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File identity captured at validation time. The open/read seam re-proves
|
|
3
|
+
* the opened object still has this identity BEFORE any byte is read — the
|
|
4
|
+
* G1 §7 / SPEC §9 post-open revalidation that closes the validate→open
|
|
5
|
+
* TOCTOU window (path replacement, symlink swap).
|
|
6
|
+
*/
|
|
7
|
+
export interface TranscriptFileIdentity {
|
|
8
|
+
readonly dev: number;
|
|
9
|
+
readonly ino: number;
|
|
10
|
+
readonly size: number;
|
|
11
|
+
readonly mtimeMs: number;
|
|
12
|
+
}
|
|
13
|
+
export type TranscriptPathValidation = {
|
|
14
|
+
ok: true;
|
|
15
|
+
canonicalPath: string;
|
|
16
|
+
rolloutIdentity: string;
|
|
17
|
+
identity: TranscriptFileIdentity;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: 'transcript_path_invalid' | 'transcript_path_outside_codex_home';
|
|
21
|
+
nextAction: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function validateCodexTranscriptPath(transcriptPath: string, codexHome: string): TranscriptPathValidation;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex transcript path authorization (Codex Governance Closure SPEC rev 2
|
|
3
|
+
* §9; Slice A SPEC §11).
|
|
4
|
+
*
|
|
5
|
+
* The ONLY valid transcript source is the path explicitly supplied by the
|
|
6
|
+
* authenticated Codex hook — never a scan of `$CODEX_HOME/sessions`, never a
|
|
7
|
+
* "latest session" guess. Validation covers the real repository/OS path
|
|
8
|
+
* model: absolute path, `..` traversal rejection, canonical (realpath)
|
|
9
|
+
* normalization, containment under the resolved Codex home's `sessions`
|
|
10
|
+
* root at segment boundaries, symlink/junction escape rejection (realpath
|
|
11
|
+
* collapses them; containment is checked on the canonical form), and a
|
|
12
|
+
* regular-file requirement. The rollout identity — one physical transcript,
|
|
13
|
+
* distinct from the root session id (G1 §4 subagent/fork traps) — is the
|
|
14
|
+
* uuid embedded in the Codex rollout file name.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { canonicalizePath } from './codex-home.js';
|
|
19
|
+
const UUID_HEX = /^[0-9a-fA-F]+$/;
|
|
20
|
+
function isHex(value) {
|
|
21
|
+
return UUID_HEX.test(value);
|
|
22
|
+
}
|
|
23
|
+
/** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
|
|
24
|
+
function parseRolloutFileName(fileName) {
|
|
25
|
+
if (!fileName.startsWith('rollout-') || !fileName.endsWith('.jsonl'))
|
|
26
|
+
return null;
|
|
27
|
+
const stem = fileName.slice('rollout-'.length, -'.jsonl'.length);
|
|
28
|
+
const parts = stem.split('-');
|
|
29
|
+
if (parts.length < 6)
|
|
30
|
+
return null; // at least one timestamp segment + the five uuid groups
|
|
31
|
+
const [a, b, c, d, e] = parts.slice(-5);
|
|
32
|
+
if (a === undefined || b === undefined || c === undefined || d === undefined || e === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
if (a.length !== 8 || b.length !== 4 || c.length !== 4 || d.length !== 4 || e.length !== 12)
|
|
35
|
+
return null;
|
|
36
|
+
if (!isHex(a) || !isHex(b) || !isHex(c) || !isHex(d) || !isHex(e))
|
|
37
|
+
return null;
|
|
38
|
+
return parts.slice(-5).join('-').toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
function isAbsolutePath(value) {
|
|
41
|
+
return path.isAbsolute(value);
|
|
42
|
+
}
|
|
43
|
+
function hasParentTraversal(value) {
|
|
44
|
+
return value.split(/[\\/]+/).includes('..');
|
|
45
|
+
}
|
|
46
|
+
function isContained(root, candidate) {
|
|
47
|
+
const relative = path.relative(root, candidate);
|
|
48
|
+
return relative === '' || (relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
49
|
+
}
|
|
50
|
+
export function validateCodexTranscriptPath(transcriptPath, codexHome) {
|
|
51
|
+
if (!isAbsolutePath(transcriptPath) || hasParentTraversal(transcriptPath)) {
|
|
52
|
+
return { ok: false, reason: 'transcript_path_invalid', nextAction: 'Codex must supply an absolute transcript_path without traversal segments.' };
|
|
53
|
+
}
|
|
54
|
+
const fileName = path.basename(transcriptPath);
|
|
55
|
+
const rolloutIdentity = parseRolloutFileName(fileName);
|
|
56
|
+
if (rolloutIdentity === null) {
|
|
57
|
+
return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript file name does not match the Codex rollout contract (rollout-<timestamp>-<uuid>.jsonl).' };
|
|
58
|
+
}
|
|
59
|
+
let canonicalHome;
|
|
60
|
+
let canonicalPath;
|
|
61
|
+
try {
|
|
62
|
+
// Canonicalize both sides to the final long form: Node's JS realpath
|
|
63
|
+
// preserves 8.3 short-name segments while payloads carry long forms.
|
|
64
|
+
canonicalHome = canonicalizePath(codexHome);
|
|
65
|
+
canonicalPath = canonicalizePath(transcriptPath);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript path (or the Codex home) does not resolve to an existing location.' };
|
|
69
|
+
}
|
|
70
|
+
// realpath collapses symlinks/junctions; checking containment on the
|
|
71
|
+
// canonical form rejects escapes that traverse links out of the sessions root.
|
|
72
|
+
if (!isContained(path.join(canonicalHome, 'sessions'), canonicalPath)) {
|
|
73
|
+
return { ok: false, reason: 'transcript_path_outside_codex_home', nextAction: 'the transcript is not inside the resolved CODEX_HOME sessions root; refusing to read outside the authenticated Codex home.' };
|
|
74
|
+
}
|
|
75
|
+
let stats;
|
|
76
|
+
try {
|
|
77
|
+
stats = fs.statSync(canonicalPath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript file disappeared during validation.' };
|
|
81
|
+
}
|
|
82
|
+
if (!stats.isFile()) {
|
|
83
|
+
return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript path is not a regular file.' };
|
|
84
|
+
}
|
|
85
|
+
const identity = { dev: stats.dev, ino: Number(stats.ino), size: stats.size, mtimeMs: stats.mtimeMs };
|
|
86
|
+
return { ok: true, canonicalPath, rolloutIdentity: rolloutIdentity.toLowerCase(), identity };
|
|
87
|
+
}
|
package/dist/pd-hook.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createProductionHostRuntime, loadPdConfigForPlugin, resolveNearestPdWor
|
|
|
6
6
|
import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
7
7
|
import { CodexHooksHostAdapter } from './host-adapter.js';
|
|
8
8
|
import { CodexDecoderError, CodexEncoderError } from './codec/index.js';
|
|
9
|
+
import { ingestCodexConversation } from './ingestion/ingestion.js';
|
|
9
10
|
const MAX_DIAGNOSTIC = 500;
|
|
10
11
|
function diagnostic(reason, nextAction) {
|
|
11
12
|
const boundedReason = reason.replace(/\s+/g, ' ').trim().slice(0, MAX_DIAGNOSTIC);
|
|
@@ -15,6 +16,32 @@ function diagnostic(reason, nextAction) {
|
|
|
15
16
|
function errorMessage(error) {
|
|
16
17
|
return error instanceof Error ? error.message.slice(0, MAX_DIAGNOSTIC) : 'unknown_error';
|
|
17
18
|
}
|
|
19
|
+
// Bounded governance-observation ingestion (Codex Governance Closure Slice
|
|
20
|
+
// A). Runs only when BOTH host.codex and codex_conversation_ingestion are
|
|
21
|
+
// enabled — the flag gate below happens BEFORE any transcript path
|
|
22
|
+
// validation or filesystem I/O, so flag-off means the transcript boundary
|
|
23
|
+
// receives zero calls (SPEC §10 hard privacy invariant).
|
|
24
|
+
function runConversationIngestion(args) {
|
|
25
|
+
const { rawPayload, kind, workspaceDir, env } = args;
|
|
26
|
+
if (kind !== 'turn_complete' && kind !== 'before_prompt_build' && kind !== 'after_tool_call')
|
|
27
|
+
return [];
|
|
28
|
+
const diagnostics = [];
|
|
29
|
+
try {
|
|
30
|
+
const outcome = ingestCodexConversation(rawPayload, kind, { workspaceDir, env });
|
|
31
|
+
if (outcome.status === 'degraded') {
|
|
32
|
+
diagnostics.push(diagnostic(outcome.reason, outcome.nextAction));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
for (const warning of outcome.warnings.slice(0, 2)) {
|
|
36
|
+
diagnostics.push(diagnostic(warning, 'Inspect PD Workspace governance-observation state; ingestion continued.'));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
diagnostics.push(diagnostic(`codex_ingestion_unexpected:${errorMessage(error)}`, 'Retry the next Codex turn; if it repeats, inspect PD stderr and the Workspace trajectory database.'));
|
|
42
|
+
}
|
|
43
|
+
return diagnostics;
|
|
44
|
+
}
|
|
18
45
|
export async function processHookInvocation(rawStdin, _env = process.env, cwd = process.cwd()) {
|
|
19
46
|
let parsed;
|
|
20
47
|
try {
|
|
@@ -50,6 +77,20 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
|
|
|
50
77
|
if (flags['host.codex']?.enabled !== true) {
|
|
51
78
|
return { stdout: {}, exitCode: 0, stderr: [diagnostic('host.codex_disabled', 'Set features.host.codex.enabled=true in the selected Workspace to enable PD.')] };
|
|
52
79
|
}
|
|
80
|
+
const ingestionEnabled = flags.codex_conversation_ingestion?.enabled === true;
|
|
81
|
+
if (event.kind === 'turn_complete') {
|
|
82
|
+
// Stop is the turn-complete ingestion trigger (G1 §2): no dispatch route,
|
|
83
|
+
// and Codex's Stop output schema has no hookSpecificOutput — the neutral
|
|
84
|
+
// result is exactly `{}` on stdout (runtime contract: "PD emits empty
|
|
85
|
+
// stdout on Stop"). Flag-off preserves the zero-transcript-read invariant
|
|
86
|
+
// and emits ONE bounded structured feature_disabled fact per completed
|
|
87
|
+
// turn on stderr — never stdout, and never on the per-tool events (that
|
|
88
|
+
// would be per-event noise).
|
|
89
|
+
const stderr = ingestionEnabled
|
|
90
|
+
? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
91
|
+
: [diagnostic('feature_disabled', 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.')];
|
|
92
|
+
return { stdout: {}, exitCode: 0, stderr };
|
|
93
|
+
}
|
|
53
94
|
try {
|
|
54
95
|
if (event.kind === 'session_start') {
|
|
55
96
|
const health = await createProductionHostRuntime().health(resolution.workspaceDir);
|
|
@@ -57,8 +98,11 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
|
|
|
57
98
|
return { stdout: {}, exitCode: 0, stderr: [diagnostic(health.reason ?? 'runtime_unhealthy', health.nextAction ?? 'Inspect the Workspace runtime.')] };
|
|
58
99
|
return { stdout: adapter.encodeOutput({ decision: 'allow', source: event.source }, 'session_start'), exitCode: 0, stderr: [] };
|
|
59
100
|
}
|
|
101
|
+
const ingestionDiagnostics = ingestionEnabled
|
|
102
|
+
? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
103
|
+
: [];
|
|
60
104
|
const result = await createProductionHostRuntime().dispatch(event);
|
|
61
|
-
const stderr = (result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.'));
|
|
105
|
+
const stderr = [...(result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.')), ...ingestionDiagnostics];
|
|
62
106
|
return { stdout: adapter.encodeOutput(result, event.kind), exitCode: 0, stderr };
|
|
63
107
|
}
|
|
64
108
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@principles/codex-adapter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Codex CLI host adapter for Principles Disciple — implements HostAdapter interface for OpenAI Codex CLI's stdin/stdout JSON hook model (ADR-0020).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|