polygram 0.38.7 → 0.39.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/.claude-plugin/plugin.json +1 -1
- package/config.example.json +1 -0
- package/docs/2026-08-16-003-codex-protected-hook-artifact-runbook.md +323 -0
- package/lib/attachments.js +5 -2
- package/lib/codex/hook-artifacts.js +1033 -0
- package/lib/codex/hook-bundle.js +296 -0
- package/lib/codex/hook-command.js +192 -0
- package/lib/codex/hook-runtime.js +533 -0
- package/lib/codex/hook-source-policy.js +440 -0
- package/lib/codex/hooks/hook-observer.bundle.js +185 -0
- package/lib/codex/hooks/observer-capture.js +61 -0
- package/lib/codex/hooks/observer-entry.js +74 -0
- package/lib/codex/runtime-controller.js +52 -2
- package/lib/codex/runtime-profile.js +215 -31
- package/lib/handlers/config-ui.js +1 -1
- package/lib/model-costs.js +6 -3
- package/lib/sdk/build-options.js +16 -2
- package/package.json +10 -4
- package/polygram.js +5 -0
- package/scripts/build-codex-hook-bundle.js +74 -0
- package/scripts/codex-hook-artifacts.js +136 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stamped at process entry, before stdin is touched. Stamping after the drain
|
|
4
|
+
// measures the writer rather than the hook, which silently inflates every
|
|
5
|
+
// margin derived from the capture.
|
|
6
|
+
const OBSERVED_AT_EPOCH_MS = Date.now();
|
|
7
|
+
|
|
8
|
+
const { buildCaptureRecord, writeCaptureRecord } = require('./observer-capture.js');
|
|
9
|
+
|
|
10
|
+
const MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
11
|
+
const STDIN_TIMEOUT_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
// An oversized payload is rejected whole. Parsing the prefix that arrived
|
|
14
|
+
// before the limit would record a truncated turn as a complete observation.
|
|
15
|
+
function readPayload(stdin, limit, timeoutMs) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let bytes = 0;
|
|
19
|
+
let settled = false;
|
|
20
|
+
const finish = (status, payload) => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
stdin.removeAllListeners();
|
|
25
|
+
stdin.pause();
|
|
26
|
+
resolve({ status, payload, bytes });
|
|
27
|
+
};
|
|
28
|
+
const timer = setTimeout(() => finish('timeout', null), timeoutMs);
|
|
29
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
30
|
+
stdin.on('data', (chunk) => {
|
|
31
|
+
bytes += chunk.length;
|
|
32
|
+
if (bytes > limit) {
|
|
33
|
+
finish('overflow', null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
stdin.once('error', () => finish('unreadable', null));
|
|
39
|
+
stdin.once('end', () => finish('ok', Buffer.concat(chunks).toString('utf8')));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main(argv, stdin) {
|
|
44
|
+
const [event, captureDir] = argv;
|
|
45
|
+
if (typeof event !== 'string' || event.length === 0
|
|
46
|
+
|| typeof captureDir !== 'string' || captureDir.length === 0) {
|
|
47
|
+
process.setExitCode(2);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const read = await readPayload(stdin, MAX_PAYLOAD_BYTES, STDIN_TIMEOUT_MS);
|
|
51
|
+
let status = read.status;
|
|
52
|
+
let parsed = null;
|
|
53
|
+
if (status === 'ok') {
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(read.payload);
|
|
56
|
+
} catch {
|
|
57
|
+
status = 'unparsable';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
writeCaptureRecord(captureDir, buildCaptureRecord({
|
|
61
|
+
event,
|
|
62
|
+
status,
|
|
63
|
+
observedAtEpochMs: OBSERVED_AT_EPOCH_MS,
|
|
64
|
+
payload: read.payload,
|
|
65
|
+
payloadBytes: read.bytes,
|
|
66
|
+
parsed,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main(process.argv.slice(2), process.stdin).catch(() => {
|
|
71
|
+
// A hook that cannot record its observation must not take the turn down
|
|
72
|
+
// with it; the missing capture is the signal the gate reads.
|
|
73
|
+
process.setExitCode(1);
|
|
74
|
+
});
|
|
@@ -376,6 +376,7 @@ function createCodexRuntimeController({
|
|
|
376
376
|
codexHome,
|
|
377
377
|
binaryPath: configured.binary ?? processEnv.POLYGRAM_CODEX_BIN,
|
|
378
378
|
sessionLauncher,
|
|
379
|
+
hooks: configured.hooks ?? null,
|
|
379
380
|
daemonSecretRoots: [
|
|
380
381
|
...new Set([...defaultDaemonSecretRoots, ...configuredSecretRoots]),
|
|
381
382
|
],
|
|
@@ -452,14 +453,28 @@ function createCodexRuntimeController({
|
|
|
452
453
|
}
|
|
453
454
|
|
|
454
455
|
async function prepareCodexRequest({ sessionKey, request, state }) {
|
|
456
|
+
const configured = deploymentConfig();
|
|
457
|
+
const hookConfigFingerprint = digest(configured.hooks);
|
|
455
458
|
const existing = receiptsBySession.get(sessionKey);
|
|
456
459
|
if (existing && sameProfileRequest(
|
|
457
460
|
existing.receipt.expectedStaticProfile,
|
|
458
461
|
request,
|
|
459
|
-
)) {
|
|
462
|
+
) && existing.hookConfigFingerprint === hookConfigFingerprint) {
|
|
460
463
|
const catalogAgeMs = now() - existing.catalogPreparedAt;
|
|
461
464
|
let currentCredentialState;
|
|
462
465
|
try {
|
|
466
|
+
if (existing.receipt.expectedStaticProfile.hookManifest != null) {
|
|
467
|
+
if (typeof runtimeProfileBuilder.reattest !== 'function') {
|
|
468
|
+
throw controllerError(
|
|
469
|
+
'Codex hook profile cannot be re-attested.',
|
|
470
|
+
'CODEX_OWNED_HOOKS_DRIFT',
|
|
471
|
+
'Discard the profile and restore the hook-aware runtime builder.',
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
runtimeProfileBuilder.reattest(
|
|
475
|
+
existing.receipt.expectedStaticProfile,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
463
478
|
const homeAttestation = await attestCodexHome(
|
|
464
479
|
existing.receipt.expectedStaticProfile.codexHome,
|
|
465
480
|
existing.receipt.expectedStaticProfile.ownedConfigSha256,
|
|
@@ -478,14 +493,17 @@ function createCodexRuntimeController({
|
|
|
478
493
|
}
|
|
479
494
|
invalidatePreparedSession(sessionKey, existing);
|
|
480
495
|
}
|
|
496
|
+
if (existing && receiptsBySession.get(sessionKey) === existing) {
|
|
497
|
+
invalidatePreparedSession(sessionKey, existing);
|
|
498
|
+
}
|
|
481
499
|
|
|
482
|
-
const configured = deploymentConfig();
|
|
483
500
|
const key = digest({
|
|
484
501
|
sessionKey,
|
|
485
502
|
request,
|
|
486
503
|
codexHome: configured.codexHome,
|
|
487
504
|
binaryPath: configured.binaryPath,
|
|
488
505
|
sessionLauncher: configured.sessionLauncher,
|
|
506
|
+
hooks: configured.hooks,
|
|
489
507
|
daemonSecretRoots: configured.daemonSecretRoots,
|
|
490
508
|
});
|
|
491
509
|
if (preparingByKey.has(key)) return preparingByKey.get(key);
|
|
@@ -504,7 +522,18 @@ function createCodexRuntimeController({
|
|
|
504
522
|
effort: request.effort,
|
|
505
523
|
processEnv,
|
|
506
524
|
sessionLauncher: configured.sessionLauncher,
|
|
525
|
+
...(configured.hooks == null ? {} : { hooks: configured.hooks }),
|
|
507
526
|
});
|
|
527
|
+
if (expectedStaticProfile.hookManifest != null) {
|
|
528
|
+
if (typeof runtimeProfileBuilder.reattest !== 'function') {
|
|
529
|
+
throw controllerError(
|
|
530
|
+
'Codex hook profile cannot be re-attested.',
|
|
531
|
+
'CODEX_OWNED_HOOKS_DRIFT',
|
|
532
|
+
'Discard the profile and restore the hook-aware runtime builder.',
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
runtimeProfileBuilder.reattest(expectedStaticProfile);
|
|
536
|
+
}
|
|
508
537
|
const homeAttestation = await attestCodexHome(
|
|
509
538
|
expectedStaticProfile.codexHome,
|
|
510
539
|
expectedStaticProfile.ownedConfigSha256,
|
|
@@ -533,6 +562,13 @@ function createCodexRuntimeController({
|
|
|
533
562
|
'Choose a model and effort returned by the authenticated Codex catalog.',
|
|
534
563
|
);
|
|
535
564
|
}
|
|
565
|
+
if (digest(deploymentConfig().hooks) !== hookConfigFingerprint) {
|
|
566
|
+
throw controllerError(
|
|
567
|
+
'Codex hook configuration changed during preflight.',
|
|
568
|
+
'CODEX_PREFLIGHT_PROFILE_MISMATCH',
|
|
569
|
+
'Retry after configuration changes settle.',
|
|
570
|
+
);
|
|
571
|
+
}
|
|
536
572
|
const receipt = catalog.spawnProfile;
|
|
537
573
|
const entry = {
|
|
538
574
|
receipt,
|
|
@@ -543,6 +579,7 @@ function createCodexRuntimeController({
|
|
|
543
579
|
catalogPreparedAt: now(),
|
|
544
580
|
credentialStateFingerprint: attestedCredentialState,
|
|
545
581
|
deploymentIdentity,
|
|
582
|
+
hookConfigFingerprint,
|
|
546
583
|
};
|
|
547
584
|
receiptsBySession.set(sessionKey, entry);
|
|
548
585
|
return entry;
|
|
@@ -681,12 +718,25 @@ function createCodexRuntimeController({
|
|
|
681
718
|
'Discard the stale spawn and rerun exact preflight.',
|
|
682
719
|
);
|
|
683
720
|
}
|
|
721
|
+
if (expectedStaticProfile.hookManifest != null) {
|
|
722
|
+
if (typeof runtimeProfileBuilder.reattest !== 'function') {
|
|
723
|
+
throw controllerError(
|
|
724
|
+
'Codex hook profile cannot be re-attested before spawn.',
|
|
725
|
+
'CODEX_OWNED_HOOKS_DRIFT',
|
|
726
|
+
'Discard the profile and restore the hook-aware runtime builder.',
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
runtimeProfileBuilder.reattest(expectedStaticProfile);
|
|
730
|
+
}
|
|
684
731
|
return new orchestra.CodexAppServerClient({
|
|
685
732
|
binary: expectedStaticProfile.binary,
|
|
686
733
|
codexHome: expectedStaticProfile.codexHome,
|
|
687
734
|
cwd: expectedStaticProfile.cwd,
|
|
688
735
|
env: expectedStaticProfile.env,
|
|
689
736
|
expectedConfigSha256: expectedStaticProfile.ownedConfigSha256,
|
|
737
|
+
...(expectedStaticProfile.hookManifest == null ? {} : {
|
|
738
|
+
hookManifest: expectedStaticProfile.hookManifest,
|
|
739
|
+
}),
|
|
690
740
|
...(expectedStaticProfile.sessionLauncher == null ? {} : {
|
|
691
741
|
sessionLauncher: expectedStaticProfile.sessionLauncher,
|
|
692
742
|
expectedSessionLauncherSha256:
|
|
@@ -21,6 +21,12 @@ const {
|
|
|
21
21
|
characterizePinnedSessionLauncher,
|
|
22
22
|
resolveCodexTargetPin,
|
|
23
23
|
} = require('@shumkov/orchestra');
|
|
24
|
+
const {
|
|
25
|
+
CAPTURE_DIRECTORY,
|
|
26
|
+
prepareHookRuntime,
|
|
27
|
+
reattestHookRuntime,
|
|
28
|
+
renderTrustTables,
|
|
29
|
+
} = require('./hook-runtime');
|
|
24
30
|
|
|
25
31
|
const CODEX_PERMISSION_PROFILE_ID = 'polygram-session';
|
|
26
32
|
const CONTROLLED_PATH = '/usr/bin:/bin';
|
|
@@ -375,6 +381,8 @@ function renderOwnedConfig(config, {
|
|
|
375
381
|
'trust_level = "untrusted"',
|
|
376
382
|
'',
|
|
377
383
|
);
|
|
384
|
+
const hookTables = renderTrustTables(config);
|
|
385
|
+
if (hookTables) lines.push(...hookTables.split('\n'));
|
|
378
386
|
if (
|
|
379
387
|
filesystem[':minimal'] !== 'read'
|
|
380
388
|
|| filesystem[codexHome] !== 'deny'
|
|
@@ -580,33 +588,6 @@ function provisionRuntimeFiles(options, temporaryRoots) {
|
|
|
580
588
|
daemonSecretRoots,
|
|
581
589
|
});
|
|
582
590
|
const configPath = path.join(codexHome, 'config.toml');
|
|
583
|
-
if (existsSync(configPath)) {
|
|
584
|
-
assertOwnedMode(configPath, {
|
|
585
|
-
code: 'CODEX_OWNED_CONFIG_DRIFT',
|
|
586
|
-
kind: 'owned config.toml',
|
|
587
|
-
expectedMode: 0o600,
|
|
588
|
-
directory: false,
|
|
589
|
-
});
|
|
590
|
-
if (readFileSync(configPath, 'utf8') !== rawConfig) {
|
|
591
|
-
throw fail(
|
|
592
|
-
'Codex owned config.toml differs from the native-beta policy.',
|
|
593
|
-
'CODEX_OWNED_CONFIG_DRIFT',
|
|
594
|
-
'Back up config.toml outside CODEX_HOME, then remove or move the original and rerun startup to reprovision the exact owned config; Polygram never migrates or overwrites it automatically.',
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
} else {
|
|
598
|
-
try {
|
|
599
|
-
writeFileSync(configPath, rawConfig, { flag: 'wx', mode: 0o600 });
|
|
600
|
-
chmodSync(configPath, 0o600);
|
|
601
|
-
} catch (error) {
|
|
602
|
-
throw fail(
|
|
603
|
-
'Codex owned config.toml could not be provisioned.',
|
|
604
|
-
'CODEX_OWNED_CONFIG_DRIFT',
|
|
605
|
-
'Provision the exact owner-only config before enabling Codex.',
|
|
606
|
-
error,
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
591
|
|
|
611
592
|
const authPath = path.join(codexHome, 'auth.json');
|
|
612
593
|
if (existsSync(authPath)) {
|
|
@@ -624,6 +605,7 @@ function provisionRuntimeFiles(options, temporaryRoots) {
|
|
|
624
605
|
daemonSecretRoots,
|
|
625
606
|
commandHome,
|
|
626
607
|
commandTmp,
|
|
608
|
+
configPath,
|
|
627
609
|
ownedConfig,
|
|
628
610
|
rawConfig,
|
|
629
611
|
ownedConfigSha256: digest(rawConfig),
|
|
@@ -635,6 +617,38 @@ function provisionRuntimeFiles(options, temporaryRoots) {
|
|
|
635
617
|
};
|
|
636
618
|
}
|
|
637
619
|
|
|
620
|
+
function provisionBaseConfig(materialized) {
|
|
621
|
+
const { configPath, rawConfig } = materialized;
|
|
622
|
+
if (existsSync(configPath)) {
|
|
623
|
+
assertOwnedMode(configPath, {
|
|
624
|
+
code: 'CODEX_OWNED_CONFIG_DRIFT',
|
|
625
|
+
kind: 'owned config.toml',
|
|
626
|
+
expectedMode: 0o600,
|
|
627
|
+
directory: false,
|
|
628
|
+
});
|
|
629
|
+
if (readFileSync(configPath, 'utf8') !== rawConfig) {
|
|
630
|
+
throw fail(
|
|
631
|
+
'Codex owned config.toml differs from the native-beta policy.',
|
|
632
|
+
'CODEX_OWNED_CONFIG_DRIFT',
|
|
633
|
+
'Back up config.toml outside CODEX_HOME, then remove or move the original and rerun startup to reprovision the exact owned config; Polygram never migrates or overwrites it automatically.',
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return materialized;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
writeFileSync(configPath, rawConfig, { flag: 'wx', mode: 0o600 });
|
|
640
|
+
chmodSync(configPath, 0o600);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
throw fail(
|
|
643
|
+
'Codex owned config.toml could not be provisioned.',
|
|
644
|
+
'CODEX_OWNED_CONFIG_DRIFT',
|
|
645
|
+
'Provision the exact owner-only config before enabling Codex.',
|
|
646
|
+
error,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return materialized;
|
|
650
|
+
}
|
|
651
|
+
|
|
638
652
|
function expectedFilesystem(materialized) {
|
|
639
653
|
return {
|
|
640
654
|
glob_scan_max_depth: null,
|
|
@@ -831,10 +845,100 @@ async function collectPermissionProfiles(client, workspace) {
|
|
|
831
845
|
return profiles.sort((left, right) => left.id.localeCompare(right.id));
|
|
832
846
|
}
|
|
833
847
|
|
|
848
|
+
async function verifyHookPhase(materialized, {
|
|
849
|
+
binaryReceipt,
|
|
850
|
+
sessionLauncherReceipt,
|
|
851
|
+
clientFactory,
|
|
852
|
+
expectedConfigSha256,
|
|
853
|
+
hookManifest,
|
|
854
|
+
phase,
|
|
855
|
+
}) {
|
|
856
|
+
let latchedFault = null;
|
|
857
|
+
const client = clientFactory({
|
|
858
|
+
binary: binaryReceipt.path,
|
|
859
|
+
sessionLauncher: sessionLauncherReceipt?.path ?? null,
|
|
860
|
+
expectedSessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
|
|
861
|
+
cwd: materialized.workspace,
|
|
862
|
+
codexHome: materialized.codexHome,
|
|
863
|
+
env: materialized.env,
|
|
864
|
+
expectedConfigSha256,
|
|
865
|
+
hookManifest,
|
|
866
|
+
onNotification: async () => {
|
|
867
|
+
throw fail(
|
|
868
|
+
'Codex hook verification received an unexpected notification.',
|
|
869
|
+
'CODEX_RUNTIME_POLICY_DRIFT',
|
|
870
|
+
'Disable hooks and inspect the pinned app-server protocol drift.',
|
|
871
|
+
);
|
|
872
|
+
},
|
|
873
|
+
onFault: (outcome) => {
|
|
874
|
+
outcome?.assertActive?.();
|
|
875
|
+
latchedFault = outcome;
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
if (
|
|
879
|
+
!client
|
|
880
|
+
|| typeof client.start !== 'function'
|
|
881
|
+
|| typeof client.verifyHooks !== 'function'
|
|
882
|
+
|| typeof client.close !== 'function'
|
|
883
|
+
|| typeof client.waitForFault !== 'function'
|
|
884
|
+
) {
|
|
885
|
+
throw new TypeError('Codex hook clientFactory returned an invalid client');
|
|
886
|
+
}
|
|
887
|
+
let result;
|
|
888
|
+
let failure = null;
|
|
889
|
+
try {
|
|
890
|
+
await client.start();
|
|
891
|
+
result = await client.verifyHooks({ phase });
|
|
892
|
+
} catch (error) {
|
|
893
|
+
failure = error;
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
await client.close();
|
|
897
|
+
} catch (error) {
|
|
898
|
+
throw fail(
|
|
899
|
+
'Codex hook verification cleanup failed.',
|
|
900
|
+
'CODEX_RUNTIME_CLIENT_CLOSE_FAILED',
|
|
901
|
+
'Disable hooks and inspect the pinned app-server child cleanup.',
|
|
902
|
+
error,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
let terminalFault;
|
|
906
|
+
try {
|
|
907
|
+
terminalFault = await client.waitForFault();
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw fail(
|
|
910
|
+
'Codex hook verification fault handoff failed.',
|
|
911
|
+
'CODEX_RUNTIME_CLIENT_FAULT',
|
|
912
|
+
'Disable hooks and inspect the pinned app-server fault handoff.',
|
|
913
|
+
error,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (latchedFault || terminalFault) {
|
|
917
|
+
throw fail(
|
|
918
|
+
'Codex hook verification client faulted.',
|
|
919
|
+
'CODEX_RUNTIME_CLIENT_FAULT',
|
|
920
|
+
'Disable hooks and inspect the pinned app-server fault.',
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
if (failure) {
|
|
924
|
+
if (failure instanceof CodexRuntimeProfileError) throw failure;
|
|
925
|
+
throw fail(
|
|
926
|
+
'Codex hook verification failed closed.',
|
|
927
|
+
failure?.code === 'CODEX_HOOK_TRUST_UNVERIFIED'
|
|
928
|
+
? 'CODEX_HOOK_TRUST_UNVERIFIED'
|
|
929
|
+
: 'CODEX_RUNTIME_POLICY_DRIFT',
|
|
930
|
+
'Restore the exact owned hook manifest and runtime policy.',
|
|
931
|
+
failure,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
|
|
834
937
|
async function characterizeRuntime(materialized, {
|
|
835
938
|
binaryReceipt,
|
|
836
939
|
sessionLauncherReceipt,
|
|
837
940
|
clientFactory,
|
|
941
|
+
hookManifest = null,
|
|
838
942
|
}) {
|
|
839
943
|
let latchedFault = null;
|
|
840
944
|
const client = clientFactory({
|
|
@@ -845,6 +949,7 @@ async function characterizeRuntime(materialized, {
|
|
|
845
949
|
codexHome: materialized.codexHome,
|
|
846
950
|
env: materialized.env,
|
|
847
951
|
expectedConfigSha256: materialized.ownedConfigSha256,
|
|
952
|
+
...(hookManifest == null ? {} : { hookManifest }),
|
|
848
953
|
onNotification: async () => {
|
|
849
954
|
throw fail(
|
|
850
955
|
'Codex runtime characterization received a state notification.',
|
|
@@ -871,6 +976,12 @@ async function characterizeRuntime(materialized, {
|
|
|
871
976
|
let failure = null;
|
|
872
977
|
try {
|
|
873
978
|
await client.start();
|
|
979
|
+
if (hookManifest != null) {
|
|
980
|
+
if (typeof client.verifyHooks !== 'function') {
|
|
981
|
+
throw new TypeError('Codex runtime client lacks hook verification');
|
|
982
|
+
}
|
|
983
|
+
await client.verifyHooks({ phase: 'trusted' });
|
|
984
|
+
}
|
|
874
985
|
const configRead = await client.request('config/read', {
|
|
875
986
|
cwd: materialized.workspace,
|
|
876
987
|
includeLayers: true,
|
|
@@ -952,7 +1063,8 @@ function createCodexRuntimeProfileBuilder({
|
|
|
952
1063
|
throw new TypeError('Codex runtime profile builder options are invalid');
|
|
953
1064
|
}
|
|
954
1065
|
|
|
955
|
-
|
|
1066
|
+
const hookProfiles = new WeakMap();
|
|
1067
|
+
const builder = {
|
|
956
1068
|
async prepare(options = {}) {
|
|
957
1069
|
const targetPin = resolveTargetPin();
|
|
958
1070
|
validateBinaryReceipt(options.binaryReceipt, protocolSchema, targetPin);
|
|
@@ -984,15 +1096,59 @@ function createCodexRuntimeProfileBuilder({
|
|
|
984
1096
|
'Restore the exact root-owned session launcher before enabling Codex.',
|
|
985
1097
|
);
|
|
986
1098
|
}
|
|
987
|
-
|
|
1099
|
+
let materialized = provisionRuntimeFiles(options, temporaryRoots);
|
|
1100
|
+
let hookMaterial = null;
|
|
1101
|
+
if (options.hooks == null) {
|
|
1102
|
+
provisionBaseConfig(materialized);
|
|
1103
|
+
} else {
|
|
1104
|
+
if (options.hooks.enabled === true) {
|
|
1105
|
+
ensurePrivateDirectory(
|
|
1106
|
+
path.join(materialized.workspace, CAPTURE_DIRECTORY),
|
|
1107
|
+
'CODEX_COMMAND_DIR_UNSAFE',
|
|
1108
|
+
'hook observation directory',
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const hookState = await prepareHookRuntime({
|
|
1112
|
+
hookOptions: options.hooks,
|
|
1113
|
+
codexHome: materialized.codexHome,
|
|
1114
|
+
workspace: materialized.workspace,
|
|
1115
|
+
baseOwnedConfig: materialized.ownedConfig,
|
|
1116
|
+
baseRawConfig: materialized.rawConfig,
|
|
1117
|
+
renderOwnedConfig: (ownedConfig) => renderOwnedConfig(ownedConfig, {
|
|
1118
|
+
codexHome: materialized.codexHome,
|
|
1119
|
+
workspace: materialized.workspace,
|
|
1120
|
+
daemonSecretRoots: materialized.daemonSecretRoots,
|
|
1121
|
+
}),
|
|
1122
|
+
verifyDiscovery: (hookManifest, expectedConfigSha256) => (
|
|
1123
|
+
verifyHookPhase(materialized, {
|
|
1124
|
+
binaryReceipt: options.binaryReceipt,
|
|
1125
|
+
sessionLauncherReceipt,
|
|
1126
|
+
clientFactory,
|
|
1127
|
+
expectedConfigSha256,
|
|
1128
|
+
hookManifest,
|
|
1129
|
+
phase: 'discovery',
|
|
1130
|
+
})
|
|
1131
|
+
),
|
|
1132
|
+
});
|
|
1133
|
+
hookMaterial = hookState.hookMaterial;
|
|
1134
|
+
materialized = {
|
|
1135
|
+
...materialized,
|
|
1136
|
+
ownedConfig: hookState.ownedConfig,
|
|
1137
|
+
rawConfig: hookState.rawConfig,
|
|
1138
|
+
ownedConfigSha256: digest(hookState.rawConfig),
|
|
1139
|
+
hookManifest: hookState.hookManifest,
|
|
1140
|
+
hookArtifactsSha256: hookState.hookArtifactsSha256,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
988
1143
|
const characterized = await characterizeRuntime(materialized, {
|
|
989
1144
|
binaryReceipt: options.binaryReceipt,
|
|
990
1145
|
sessionLauncherReceipt,
|
|
991
1146
|
clientFactory,
|
|
1147
|
+
hookManifest: materialized.hookManifest ?? null,
|
|
992
1148
|
});
|
|
993
1149
|
const model = requiredString(options.model, 'model');
|
|
994
1150
|
const effort = requiredString(options.effort, 'reasoning effort');
|
|
995
|
-
|
|
1151
|
+
const profile = deepFreeze({
|
|
996
1152
|
runtime: 'codex',
|
|
997
1153
|
binary: options.binaryReceipt.path,
|
|
998
1154
|
target: targetPin.target,
|
|
@@ -1016,9 +1172,37 @@ function createCodexRuntimeProfileBuilder({
|
|
|
1016
1172
|
effort,
|
|
1017
1173
|
sessionLauncher: sessionLauncherReceipt?.path ?? null,
|
|
1018
1174
|
sessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
|
|
1175
|
+
...(materialized.hookManifest == null ? {} : {
|
|
1176
|
+
hookManifest: materialized.hookManifest,
|
|
1177
|
+
hookArtifactsSha256: materialized.hookArtifactsSha256,
|
|
1178
|
+
}),
|
|
1019
1179
|
});
|
|
1180
|
+
if (hookMaterial != null) {
|
|
1181
|
+
hookProfiles.set(profile, Object.freeze({
|
|
1182
|
+
hookOptions: Object.freeze({ ...options.hooks }),
|
|
1183
|
+
codexHome: materialized.codexHome,
|
|
1184
|
+
workspace: materialized.workspace,
|
|
1185
|
+
}));
|
|
1186
|
+
}
|
|
1187
|
+
return profile;
|
|
1020
1188
|
},
|
|
1021
|
-
|
|
1189
|
+
reattest(profile) {
|
|
1190
|
+
if (profile?.hookManifest == null) return null;
|
|
1191
|
+
const binding = hookProfiles.get(profile);
|
|
1192
|
+
if (!binding) {
|
|
1193
|
+
throw fail(
|
|
1194
|
+
'Codex hook profile is not owned by this runtime builder.',
|
|
1195
|
+
'CODEX_OWNED_HOOKS_DRIFT',
|
|
1196
|
+
'Discard the stale profile and rerun exact preflight.',
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return reattestHookRuntime({
|
|
1200
|
+
...binding,
|
|
1201
|
+
expected: profile,
|
|
1202
|
+
});
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
return Object.freeze(builder);
|
|
1022
1206
|
}
|
|
1023
1207
|
|
|
1024
1208
|
const defaultBuilder = createCodexRuntimeProfileBuilder();
|
|
@@ -220,7 +220,7 @@ function resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText) {
|
|
|
220
220
|
// polygram passes the alias (opus / sonnet / haiku) and lets claude
|
|
221
221
|
// resolve. Bump on Claude release.
|
|
222
222
|
const MODEL_VERSIONS_DESC = {
|
|
223
|
-
opus: 'claude-opus-5',
|
|
223
|
+
opus: 'claude-opus-5-5',
|
|
224
224
|
sonnet: 'claude-sonnet-4-6',
|
|
225
225
|
haiku: 'claude-haiku-4-5',
|
|
226
226
|
};
|
package/lib/model-costs.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
19
|
// Per-million-token rates ($ USD). Cache-read and cache-creation
|
|
20
|
-
// follow Anthropic's prompt-caching pricing — read is ~10% of
|
|
21
|
-
// input;
|
|
20
|
+
// follow Anthropic's prompt-caching pricing — read is usually ~10% of
|
|
21
|
+
// normal input (Opus 5.5: 5%); 5-minute creation is 125% of normal input.
|
|
22
22
|
const MODEL_COSTS = {
|
|
23
23
|
// Claude Sonnet 4.6
|
|
24
24
|
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
@@ -31,8 +31,11 @@ const MODEL_COSTS = {
|
|
|
31
31
|
// Claude Opus 4.8 — kept for turns recorded before the CLI pin moved to
|
|
32
32
|
// 2.1.220; without an entry those fall back to the Sonnet `default`.
|
|
33
33
|
'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
|
|
34
|
-
// Claude Opus 5 —
|
|
34
|
+
// Claude Opus 5 — kept for turns recorded before the CLI pin moved to
|
|
35
|
+
// 2.1.283; without an entry those fall back to the Sonnet `default`.
|
|
35
36
|
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.50, cacheCreation: 6.25 },
|
|
37
|
+
// Claude Opus 5.5 — what `--model opus` resolves to on the pinned CLI.
|
|
38
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.20, cacheCreation: 5 },
|
|
36
39
|
// Default fallback — Sonnet rates (safest mid-tier estimate).
|
|
37
40
|
default: { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
38
41
|
};
|
package/lib/sdk/build-options.js
CHANGED
|
@@ -36,6 +36,19 @@ const CHILD_ENV_ALLOWLIST = new Set([
|
|
|
36
36
|
]);
|
|
37
37
|
const CHILD_ENV_PREFIXES = ['LC_', 'NODE_', 'CLAUDE_', 'ANTHROPIC_'];
|
|
38
38
|
|
|
39
|
+
// Claude Code records a conversation's system prompt once and replays that
|
|
40
|
+
// record on every resume until compaction. The appended display hint depends
|
|
41
|
+
// on richText and changes between releases, so opt out of the recording to
|
|
42
|
+
// let changes reach resumed sessions. Bare string / string[] prompts can only
|
|
43
|
+
// opt out in the custom-object form.
|
|
44
|
+
function withoutPromptSnapshot(systemPrompt) {
|
|
45
|
+
if (systemPrompt == null) return systemPrompt;
|
|
46
|
+
if (typeof systemPrompt === 'string' || Array.isArray(systemPrompt)) {
|
|
47
|
+
return { type: 'custom', prompt: systemPrompt, snapshot: false };
|
|
48
|
+
}
|
|
49
|
+
return { ...systemPrompt, snapshot: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
function filterEnv(src) {
|
|
40
53
|
const out = {};
|
|
41
54
|
for (const [k, v] of Object.entries(src)) {
|
|
@@ -173,13 +186,13 @@ function createBuildSdkOptions({
|
|
|
173
186
|
// inlineMedia: replies on this path are delivered by the streamer, which
|
|
174
187
|
// resolves and uploads local media into the rich payload. The reply-tool
|
|
175
188
|
// path cannot, so it asks for the hint without those paragraphs.
|
|
176
|
-
composed.systemPrompt = appendDisplayHint(
|
|
189
|
+
composed.systemPrompt = withoutPromptSnapshot(appendDisplayHint(
|
|
177
190
|
composed.systemPrompt,
|
|
178
191
|
buildPolygramDisplayHint(
|
|
179
192
|
resolveRichTextEnabled(config, chatId, threadId),
|
|
180
193
|
{ inlineMedia: true },
|
|
181
194
|
),
|
|
182
|
-
);
|
|
195
|
+
));
|
|
183
196
|
return composed;
|
|
184
197
|
};
|
|
185
198
|
}
|
|
@@ -189,6 +202,7 @@ module.exports = {
|
|
|
189
202
|
// Exposed for tests + adjacent extractions that need the same env
|
|
190
203
|
// discipline (e.g. lib/sdk/callbacks.js when it ships).
|
|
191
204
|
filterEnv,
|
|
205
|
+
withoutPromptSnapshot,
|
|
192
206
|
CHILD_ENV_ALLOWLIST,
|
|
193
207
|
CHILD_ENV_PREFIXES,
|
|
194
208
|
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"polygram": "polygram.js",
|
|
8
8
|
"polygram-split-db": "scripts/split-db.js",
|
|
9
9
|
"polygram-ipc": "scripts/ipc-smoke.js",
|
|
10
|
-
"polygram-doctor": "scripts/doctor.js"
|
|
10
|
+
"polygram-doctor": "scripts/doctor.js",
|
|
11
|
+
"polygram-codex-hook-artifacts": "scripts/codex-hook-artifacts.js",
|
|
12
|
+
"polygram-build-codex-hook-bundle": "scripts/build-codex-hook-bundle.js"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"polygram.js",
|
|
@@ -16,11 +18,14 @@
|
|
|
16
18
|
"scripts/split-db.js",
|
|
17
19
|
"scripts/ipc-smoke.js",
|
|
18
20
|
"scripts/doctor.js",
|
|
21
|
+
"scripts/build-codex-hook-bundle.js",
|
|
22
|
+
"scripts/codex-hook-artifacts.js",
|
|
19
23
|
"skills/",
|
|
20
24
|
"commands/",
|
|
21
25
|
".claude-plugin/",
|
|
22
26
|
"ops/polygram.plist.example",
|
|
23
27
|
"ops/README.md",
|
|
28
|
+
"docs/2026-08-16-003-codex-protected-hook-artifact-runbook.md",
|
|
24
29
|
"config.example.json"
|
|
25
30
|
],
|
|
26
31
|
"scripts": {
|
|
@@ -61,9 +66,10 @@
|
|
|
61
66
|
"homepage": "https://github.com/shumkov/polygram#readme",
|
|
62
67
|
"type": "commonjs",
|
|
63
68
|
"dependencies": {
|
|
64
|
-
"@anthropic-ai/claude-agent-sdk": "0.
|
|
69
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.283",
|
|
65
70
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"@shumkov/orchestra": "0.
|
|
71
|
+
"@shumkov/orchestra": "0.11.0",
|
|
72
|
+
"acorn": "8.18.0",
|
|
67
73
|
"better-sqlite3": "^12.9.0",
|
|
68
74
|
"grammy": "^1.42.0",
|
|
69
75
|
"markdown-it": "^14.1.1",
|
package/polygram.js
CHANGED
|
@@ -4184,6 +4184,11 @@ async function main() {
|
|
|
4184
4184
|
+ `${binCheck.vendored ? ' (vendored)' : ''}`,
|
|
4185
4185
|
);
|
|
4186
4186
|
pinnedClaudeBin = binCheck.path;
|
|
4187
|
+
// CliProcess checks `claude --help` before passing newer flags; the
|
|
4188
|
+
// probe is synchronous and takes up to a few seconds, so run it here
|
|
4189
|
+
// while boot already blocks instead of on the first chat's spawn.
|
|
4190
|
+
const { supportsClaudeFlag } = require('@shumkov/orchestra').claudeBin;
|
|
4191
|
+
supportsClaudeFlag(pinnedClaudeBin, '--system-prompt-snapshot', { logger: console });
|
|
4187
4192
|
} else {
|
|
4188
4193
|
console.warn(`[polygram] WARNING: ${binCheck.reason}`);
|
|
4189
4194
|
}
|