@sabaiway/agent-workflow-kit 1.34.0 → 1.35.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.
@@ -439,6 +439,23 @@ describe('codex-exec.sh — resume entrypoint restates every invariant (3.1)', (
439
439
  assert.match(r.capEnv, /^FOO_API_KEY=<unset>$/m);
440
440
  assert.match(r.argv, /(^|\n)--ignore-user-config(\n|$)/);
441
441
  });
442
+
443
+ it('resume keeps the FULL restated policy plus the tier when set (2.3.0)', () => {
444
+ const sb = makeSandbox();
445
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go', env: { CODEX_SERVICE_TIER: 'priority' } });
446
+ rmSync(sb.root, { recursive: true, force: true });
447
+ assert.equal(r.status, 0, r.stderr);
448
+ for (const inv of RESUME_INVARIANTS) assert.match(r.argv, inv, `resume argv must include ${inv}`);
449
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/, 'a resume must not silently drop the tier');
450
+ });
451
+
452
+ it('resume without the tier carries no service_tier flag (2.3.0)', () => {
453
+ const sb = makeSandbox();
454
+ const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go' });
455
+ rmSync(sb.root, { recursive: true, force: true });
456
+ assert.equal(r.status, 0, r.stderr);
457
+ assert.doesNotMatch(r.argv, /service_tier/);
458
+ });
442
459
  });
443
460
 
444
461
  describe('codex-exec.sh — enforced git-write boundary shim (3.2)', () => {
@@ -750,3 +767,274 @@ describe('codex-exec.sh — source-level reverse guard (parser arms ⟷ manifest
750
767
  assert.ok(covered(PROBE_RELAXABLE, EXEC_CONTRACT.passthrough.probeRelaxed), 'every probe pattern has a behavioural sample');
751
768
  });
752
769
  });
770
+
771
+ // ── bridge settings file + service tier knob (bridges 2.3.0) ─────────────────────
772
+ // ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf holds KEY=VALUE
773
+ // lines, PARSED (never sourced). Precedence: explicit env (even empty: KEY= disables the
774
+ // knob) > file > built-in default. run() sets HOME to the sandbox repo, so the default
775
+ // settings path is hermetic per test.
776
+
777
+ const writeSettings = (sb, text) => {
778
+ const dir = join(sb.repo, '.config', 'agent-workflow');
779
+ mkdirSync(dir, { recursive: true });
780
+ const file = join(dir, 'bridge-settings.conf');
781
+ writeFileSync(file, text);
782
+ return file;
783
+ };
784
+ // chmod-based unreadability is void for root (root reads anything) — skip there.
785
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
786
+
787
+ describe('codex-exec.sh — service tier knob (bridges 2.3.0)', () => {
788
+ it('default: no env, no file → NO service_tier flag in codex argv', () => {
789
+ const sb = makeSandbox();
790
+ const r = run(sb);
791
+ rmSync(sb.root, { recursive: true, force: true });
792
+ assert.equal(r.status, 0, r.stderr);
793
+ assert.doesNotMatch(r.argv, /service_tier/, 'default OFF: the flag must be absent');
794
+ assert.doesNotMatch(r.stderr, /bridge settings/, 'no file → no settings chatter');
795
+ });
796
+
797
+ it('env CODEX_SERVICE_TIER=priority → -c service_tier=priority reaches codex argv', () => {
798
+ const sb = makeSandbox();
799
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'priority' } });
800
+ rmSync(sb.root, { recursive: true, force: true });
801
+ assert.equal(r.status, 0, r.stderr);
802
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
803
+ });
804
+
805
+ it('a file-set tier lands (file wins over the built-in default)', () => {
806
+ const sb = makeSandbox();
807
+ writeSettings(sb, 'CODEX_SERVICE_TIER=priority\n');
808
+ const r = run(sb);
809
+ rmSync(sb.root, { recursive: true, force: true });
810
+ assert.equal(r.status, 0, r.stderr);
811
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
812
+ });
813
+
814
+ it('an EXPLICITLY EMPTY env (CODEX_SERVICE_TIER=) disables a file-set tier for one run', () => {
815
+ const sb = makeSandbox();
816
+ writeSettings(sb, 'CODEX_SERVICE_TIER=priority\n');
817
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: '' } });
818
+ rmSync(sb.root, { recursive: true, force: true });
819
+ assert.equal(r.status, 0, r.stderr);
820
+ assert.doesNotMatch(r.argv, /service_tier/, 'env wins over file — empty means knob off');
821
+ });
822
+
823
+ it('an invalid env tier warns and runs on the standard tier (never passed to codex)', () => {
824
+ const sb = makeSandbox();
825
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'turbo' } });
826
+ rmSync(sb.root, { recursive: true, force: true });
827
+ assert.equal(r.status, 0, r.stderr);
828
+ assert.match(r.stderr, /not a supported service tier/);
829
+ assert.doesNotMatch(r.argv, /service_tier/, 'an unvalidated value must never reach codex');
830
+ });
831
+
832
+ it('an invalid file tier warns and falls back to the built-in default', () => {
833
+ const sb = makeSandbox();
834
+ writeSettings(sb, 'CODEX_SERVICE_TIER=turbo\n');
835
+ const r = run(sb);
836
+ rmSync(sb.root, { recursive: true, force: true });
837
+ assert.equal(r.status, 0, r.stderr);
838
+ assert.match(r.stderr, /invalid value 'turbo'/);
839
+ assert.doesNotMatch(r.argv, /service_tier/);
840
+ });
841
+
842
+ });
843
+
844
+ describe('codex-exec.sh — bridge settings file semantics (bridges 2.3.0)', () => {
845
+ it('env overrides file: CODEX_HARD_TIMEOUT env=2 file=9999 → killed at the env cap', () => {
846
+ const sb = makeSandbox();
847
+ writeSettings(sb, 'CODEX_HARD_TIMEOUT=9999\n');
848
+ const r = run(sb, { env: { CODEX_FAKE_SLEEP: '5', CODEX_HARD_TIMEOUT: '2' } });
849
+ rmSync(sb.root, { recursive: true, force: true });
850
+ assert.notEqual(r.status, 0, 'the env cap (2s) must win over the file cap (9999s)');
851
+ assert.match(r.stderr, /exceeded the hard cap CODEX_HARD_TIMEOUT=2s/);
852
+ });
853
+
854
+ it('a file-set CODEX_HARD_TIMEOUT is effective (killed at the file cap)', () => {
855
+ const sb = makeSandbox();
856
+ writeSettings(sb, 'CODEX_HARD_TIMEOUT=2\n');
857
+ const r = run(sb, { env: { CODEX_FAKE_SLEEP: '5' } });
858
+ rmSync(sb.root, { recursive: true, force: true });
859
+ assert.notEqual(r.status, 0, 'the file cap must apply when the env is unset');
860
+ assert.match(r.stderr, /exceeded the hard cap CODEX_HARD_TIMEOUT=2s/);
861
+ });
862
+
863
+ it("another wrapper's / another bridge's valid key is skipped silently", () => {
864
+ const sb = makeSandbox();
865
+ writeSettings(sb, 'CODEX_REVIEW_MAX_TOTAL_BYTES=100\nAGY_HARD_TIMEOUT=30m\nAGY_REVIEW_ALLOW_ADDDIR=1\n');
866
+ const r = run(sb);
867
+ rmSync(sb.root, { recursive: true, force: true });
868
+ assert.equal(r.status, 0, r.stderr);
869
+ assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
870
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
871
+ });
872
+
873
+ it('a truly unknown key warns ONCE naming the file; the run is unaffected', () => {
874
+ const sb = makeSandbox();
875
+ writeSettings(sb, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
876
+ const r = run(sb);
877
+ rmSync(sb.root, { recursive: true, force: true });
878
+ assert.equal(r.status, 0, r.stderr);
879
+ const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
880
+ assert.equal(warns.length, 1, `exactly one warning per unknown key, got ${warns.length}`);
881
+ assert.match(r.stderr, /bridge-settings\.conf/, 'the warning must name the settings file');
882
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
883
+ });
884
+
885
+ it('duplicate key → the LAST occurrence wins (invalid then valid → applied, no warning)', () => {
886
+ const sb = makeSandbox();
887
+ writeSettings(sb, 'CODEX_SERVICE_TIER=bogus\nCODEX_SERVICE_TIER=priority\n');
888
+ const r = run(sb);
889
+ rmSync(sb.root, { recursive: true, force: true });
890
+ assert.equal(r.status, 0, r.stderr);
891
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
892
+ assert.doesNotMatch(r.stderr, /invalid value/, 'only the LAST occurrence is the value');
893
+ });
894
+
895
+ it('duplicate key → the LAST occurrence wins (valid then invalid → warned + default)', () => {
896
+ const sb = makeSandbox();
897
+ writeSettings(sb, 'CODEX_SERVICE_TIER=priority\nCODEX_SERVICE_TIER=bogus\n');
898
+ const r = run(sb);
899
+ rmSync(sb.root, { recursive: true, force: true });
900
+ assert.equal(r.status, 0, r.stderr);
901
+ assert.match(r.stderr, /invalid value 'bogus'/);
902
+ assert.doesNotMatch(r.argv, /service_tier/);
903
+ });
904
+
905
+ it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
906
+ const sb = makeSandbox();
907
+ writeSettings(sb, '# a comment\n\nNOT A KEY VALUE LINE\nCODEX_SERVICE_TIER=priority\n');
908
+ const r = run(sb);
909
+ rmSync(sb.root, { recursive: true, force: true });
910
+ assert.equal(r.status, 0, r.stderr);
911
+ assert.match(r.stderr, /malformed line/);
912
+ const malformed = r.stderr.match(/malformed line/g) ?? [];
913
+ assert.equal(malformed.length, 1, 'comments/blank lines must NOT count as malformed');
914
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/, 'valid lines still apply');
915
+ });
916
+
917
+ it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
918
+ const sb = makeSandbox();
919
+ const file = writeSettings(sb, 'CODEX_SERVICE_TIER=priority\n');
920
+ chmodSync(file, 0o000);
921
+ const r = run(sb);
922
+ rmSync(sb.root, { recursive: true, force: true });
923
+ assert.equal(r.status, 0, r.stderr);
924
+ assert.match(r.stderr, /unreadable/);
925
+ assert.doesNotMatch(r.argv, /service_tier/, 'an unreadable file must yield built-in defaults');
926
+ });
927
+
928
+ it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
929
+ const sb = makeSandbox();
930
+ const pwned = join(sb.repo, 'pwned');
931
+ const pwned2 = join(sb.repo, 'pwned2');
932
+ writeSettings(
933
+ sb,
934
+ `CODEX_SERVICE_TIER=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned2}\`\n`,
935
+ );
936
+ const r = run(sb);
937
+ const executed = existsSync(pwned) || existsSync(pwned2);
938
+ rmSync(sb.root, { recursive: true, force: true });
939
+ assert.equal(r.status, 0, r.stderr);
940
+ assert.equal(executed, false, 'file content must be parsed, never evaluated');
941
+ assert.doesNotMatch(r.argv, /service_tier/, 'the payload value must fail validation');
942
+ });
943
+
944
+ it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', () => {
945
+ const sb = makeSandbox();
946
+ mkdirSync(join(sb.repo, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
947
+ const r = run(sb);
948
+ rmSync(sb.root, { recursive: true, force: true });
949
+ assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
950
+ assert.match(r.stderr, /unreadable or not a regular file/);
951
+ assert.doesNotMatch(r.stderr, /Is a directory/, 'no raw bash error may leak');
952
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'the run must proceed on built-ins');
953
+ });
954
+
955
+ it('a FIFO at the settings path warns and falls back (never opened — no pre-timeout hang)', () => {
956
+ const sb = makeSandbox();
957
+ const dir = join(sb.repo, '.config', 'agent-workflow');
958
+ mkdirSync(dir, { recursive: true });
959
+ const fifo = join(dir, 'bridge-settings.conf');
960
+ const mk = spawnSync('mkfifo', [fifo]);
961
+ if (mk.status !== 0) { rmSync(sb.root, { recursive: true, force: true }); return; } // no mkfifo here — the directory case covers the class
962
+ const r = run(sb);
963
+ rmSync(sb.root, { recursive: true, force: true });
964
+ assert.equal(r.status, 0, r.stderr);
965
+ assert.match(r.stderr, /unreadable or not a regular file/);
966
+ assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
967
+ });
968
+
969
+ it('XDG_CONFIG_HOME relocates the settings file', () => {
970
+ const sb = makeSandbox();
971
+ const xdg = join(sb.root, 'xdg');
972
+ mkdirSync(join(xdg, 'agent-workflow'), { recursive: true });
973
+ writeFileSync(join(xdg, 'agent-workflow', 'bridge-settings.conf'), 'CODEX_SERVICE_TIER=priority\n');
974
+ const r = run(sb, { env: { XDG_CONFIG_HOME: xdg } });
975
+ rmSync(sb.root, { recursive: true, force: true });
976
+ assert.equal(r.status, 0, r.stderr);
977
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
978
+ });
979
+ });
980
+
981
+ // ── settings surface ⟷ manifest (drift guard, D6) ────────────────────────────────
982
+ // The manifest `settings` block is the single source: the --help Settings section
983
+ // renders this wrapper's applied subset, and the shell constants (registry, applied
984
+ // subset, typed validation arms) stay set-equal to the UNION of both bridges' blocks
985
+ // (the reader recognizes every family key). The sibling manifest resolves identically
986
+ // in the repo layout and in the kit's bridges/ mirror layout.
987
+ const SETTINGS_HEADER = 'Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):';
988
+ const SIBLING_MANIFEST = JSON.parse(readFileSync(join(HERE, '..', '..', 'antigravity-cli-bridge', 'capability.json'), 'utf8'));
989
+ const ALL_SETTINGS = [...(MANIFEST.settings ?? []), ...(SIBLING_MANIFEST.settings ?? [])];
990
+ const SETTINGS_CMD = 'codex-exec';
991
+
992
+ describe('codex-exec.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () => {
993
+ it('--help Settings section keys set-EQUAL the manifest appliesTo subset', () => {
994
+ const help = runHelp('--help').stdout;
995
+ const section = helpSection(help, SETTINGS_HEADER);
996
+ const got = section.filter((l) => /^[A-Z][A-Z0-9_]+ —/.test(l)).map((l) => l.split(' ')[0]);
997
+ const want = (MANIFEST.settings ?? []).filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
998
+ assert.ok(want.length > 0, 'the manifest must declare settings for this wrapper');
999
+ setEq(got, want, 'help Settings keys ⟷ manifest settings.appliesTo');
1000
+ assert.ok(section.some((l) => l.includes('agent-workflow/bridge-settings.conf')), 'the section names the settings file');
1001
+ });
1002
+
1003
+ const source = readFileSync(WRAPPER, 'utf8');
1004
+
1005
+ it('aw_settings_known carries exactly the UNION of both bridges settings keys', () => {
1006
+ const m = source.match(/aw_settings_known\(\) \{\n case " ([^"]+) " in/);
1007
+ assert.ok(m, 'aw_settings_known registry case not found');
1008
+ assert.ok(ALL_SETTINGS.length >= 5, 'both manifests must contribute settings');
1009
+ setEq(m[1].trim().split(/\s+/), ALL_SETTINGS.map((s) => s.key), 'shell registry ⟷ manifest union');
1010
+ });
1011
+
1012
+ it('AW_SETTINGS_APPLIED equals the manifest appliesTo subset for this wrapper', () => {
1013
+ const m = source.match(/^AW_SETTINGS_APPLIED="([^"]*)"$/m);
1014
+ assert.ok(m, 'AW_SETTINGS_APPLIED not found');
1015
+ const want = ALL_SETTINGS.filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
1016
+ assert.ok(want.length > 0);
1017
+ setEq(m[1].trim().split(/\s+/), want, 'applied subset ⟷ manifest appliesTo');
1018
+ });
1019
+
1020
+ it('aw_settings_valid arms carry the manifest typed constants per key', () => {
1021
+ const body = source.match(/aw_settings_valid\(\) \{[\s\S]*?\n\}/);
1022
+ assert.ok(body, 'aw_settings_valid not found');
1023
+ const armKeys = [...body[0].matchAll(/^ ([A-Z][A-Z0-9_]*)\)/gm)].map((x) => x[1]);
1024
+ setEq(armKeys, ALL_SETTINGS.map((s) => s.key), 'validation arms ⟷ manifest keys');
1025
+ for (const s of ALL_SETTINGS) {
1026
+ const arm = body[0].match(new RegExp(`^ ${s.key}\\) (.*) ;;$`, 'm'));
1027
+ assert.ok(arm, `no validation arm for ${s.key}`);
1028
+ if (s.kind === 'enum') for (const v of s.values) assert.ok(arm[1].includes(`"${v}"`), `${s.key}: enum value '${v}' not pinned`);
1029
+ if (s.kind === 'integer') {
1030
+ assert.match(arm[1], new RegExp(`>= ${s.min}\\b`), `${s.key}: min ${s.min} not pinned`);
1031
+ assert.match(arm[1], new RegExp(`<= ${s.max}\\b`), `${s.key}: max ${s.max} not pinned`);
1032
+ }
1033
+ if (s.kind === 'boolean') assert.ok(arm[1].includes('"0"') && arm[1].includes('"1"'), `${s.key}: boolean 0/1 not pinned`);
1034
+ if (s.kind === 'duration') {
1035
+ assert.ok(arm[1].includes('$dur_re'), `${s.key}: duration grammar not pinned`);
1036
+ assert.ok(arm[1].includes('$zero_re'), `${s.key}: zero-duration rejection not pinned (timeout 0 disables the cap)`);
1037
+ }
1038
+ }
1039
+ });
1040
+ });
@@ -61,6 +61,12 @@ Receipt:
61
61
  verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge,
62
62
  factsHash null); a write failure warns, never fails the review
63
63
 
64
+ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
65
+ ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
66
+ CODEX_SERVICE_TIER — service tier: 'priority' (Fast — ~1.5x speed at a 2.5x credit rate on gpt-5.5); a consented SPEND knob, default off (standard tier)
67
+ CODEX_HARD_TIMEOUT — hard wall-clock cap, integer seconds 1..86400 (built-in default 1800)
68
+ CODEX_REVIEW_MAX_TOTAL_BYTES — inline-payload cap, integer bytes 1..100000000 (default 1500000); above it the diff rides via a git-dir temp file
69
+
64
70
  Environment: CODEX_REVIEW_SCHEMA=1 (structured JSON findings), CODEX_HARD_TIMEOUT (seconds, default 1800), CODEX_PROBE=1 (throwaway probe only), AW_REVIEW_RECEIPTS (receipt file override).
65
71
  Requires at run time: the codex CLI on PATH, a ChatGPT-subscription login, a git work tree with a root AGENTS.md (--help needs none of these).
66
72
  HELP
@@ -68,12 +74,102 @@ HELP
68
74
  ;;
69
75
  esac
70
76
 
77
+ # This wrapper's applied settings-file subset (see the shared reader block below).
78
+ AW_SETTINGS_APPLIED="CODEX_SERVICE_TIER CODEX_HARD_TIMEOUT CODEX_REVIEW_MAX_TOTAL_BYTES"
79
+
80
+ # --- Bridge settings file (host-level, kit-independent) — byte-identical across the four wrappers ---
81
+ # ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf holds KEY=VALUE lines,
82
+ # PARSED (grep/case), NEVER sourced — a file line can never execute code. Precedence: explicit
83
+ # env (even empty: KEY= disables the knob for one run) > file > built-in default. Each wrapper
84
+ # APPLIES only its own subset ($AW_SETTINGS_APPLIED, set above this block) but RECOGNIZES the
85
+ # whole registry: a key belonging to another wrapper or another bridge is skipped silently; only
86
+ # a key unknown to the entire registry warns (once per key), naming this file as the source.
87
+ # A malformed line warns and is ignored; a value failing the key's typed validation warns and
88
+ # falls back to the built-in default (never passed to the binary); duplicate key → the LAST
89
+ # occurrence wins; a missing file is silent; an existing-but-unreadable or non-regular file
90
+ # warns loudly and falls back to built-in defaults (a directory or FIFO is never opened).
91
+ # Diagnostics are emitted once per user-visible run: a delegating wrapper (agy-review →
92
+ # agy-run) exports AW_SETTINGS_NOTIFIED so the child never repeats the same file's warnings.
93
+ # The registry, per-wrapper subsets, and typed constants mirror
94
+ # the bridges' capability.json `settings` blocks (manifest-as-source, drift-guarded by tests).
95
+ aw_settings_file() {
96
+ printf '%s/agent-workflow/bridge-settings.conf' "${XDG_CONFIG_HOME:-$HOME/.config}"
97
+ }
98
+ aw_settings_known() {
99
+ case " CODEX_SERVICE_TIER CODEX_HARD_TIMEOUT CODEX_REVIEW_MAX_TOTAL_BYTES AGY_HARD_TIMEOUT AGY_REVIEW_ALLOW_ADDDIR " in
100
+ *" $1 "*) return 0 ;;
101
+ *) return 1 ;;
102
+ esac
103
+ }
104
+ aw_settings_valid() {
105
+ local k="$1" v="$2" int_re='^[0-9]+$' dur_re='^[0-9]+(\.[0-9]+)?[smhd]$' zero_re='^0+(\.0+)?[smhd]$'
106
+ case "$k" in
107
+ CODEX_SERVICE_TIER) [[ "$v" == "priority" ]] ;;
108
+ CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 86400 )) ;;
109
+ CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 100000000 )) ;;
110
+ AGY_HARD_TIMEOUT) [[ "$v" =~ $dur_re && ! "$v" =~ $zero_re ]] ;;
111
+ AGY_REVIEW_ALLOW_ADDDIR) [[ "$v" == "0" || "$v" == "1" ]] ;;
112
+ *) return 1 ;;
113
+ esac
114
+ }
115
+ aw_apply_settings() {
116
+ local file line key value warned notify
117
+ file="$(aw_settings_file)"
118
+ [[ -e "$file" ]] || return 0
119
+ notify=1
120
+ [[ -n "${AW_SETTINGS_NOTIFIED:-}" ]] && notify=0
121
+ export AW_SETTINGS_NOTIFIED=1
122
+ if [[ ! -f "$file" || ! -r "$file" ]]; then
123
+ if (( notify )); then
124
+ echo "warning: bridge settings file '$file' exists but is unreadable or not a regular file — using built-in defaults." >&2
125
+ fi
126
+ return 0
127
+ fi
128
+ if (( notify )); then
129
+ warned=" "
130
+ while IFS= read -r line || [[ -n "$line" ]]; do
131
+ [[ -z "${line//[[:space:]]/}" ]] && continue
132
+ case "${line#"${line%%[![:space:]]*}"}" in "#"*) continue ;; esac
133
+ if [[ ! "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
134
+ echo "warning: malformed line in bridge settings file '$file' (ignored): $line" >&2
135
+ continue
136
+ fi
137
+ key="${line%%=*}"
138
+ if ! aw_settings_known "$key"; then
139
+ case "$warned" in
140
+ *" $key "*) : ;;
141
+ *)
142
+ warned="$warned$key "
143
+ echo "warning: unknown key '$key' in bridge settings file '$file' (ignored)." >&2
144
+ ;;
145
+ esac
146
+ fi
147
+ done <"$file"
148
+ fi
149
+ for key in $AW_SETTINGS_APPLIED; do
150
+ if [[ -n "${!key+x}" ]]; then continue; fi
151
+ value="$(grep "^${key}=" "$file" 2>/dev/null || true)"
152
+ [[ -n "$value" ]] || continue
153
+ value="${value##*$'\n'}"
154
+ value="${value#*=}"
155
+ if ! aw_settings_valid "$key" "$value"; then
156
+ if (( notify )); then
157
+ echo "warning: invalid value '$value' for $key in bridge settings file '$file' — using the built-in default." >&2
158
+ fi
159
+ continue
160
+ fi
161
+ export "$key=$value"
162
+ done
163
+ return 0
164
+ }
165
+ aw_apply_settings
166
+
71
167
  DEFAULT_CODEX_MODEL="gpt-5.5"
72
168
  DEFAULT_CODEX_EFFORT="xhigh"
73
169
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
74
170
  # version (drift-guarded by codex-review.test.mjs against capability.json).
75
171
  AW_RECEIPT_BACKEND="codex"
76
- AW_BRIDGE_VERSION="2.2.0"
172
+ AW_BRIDGE_VERSION="2.3.0"
77
173
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
78
174
  CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
79
175
  # Generous hard cap for a slow xhigh review (subscription latency varies).
@@ -81,6 +177,22 @@ CODEX_HARD_TIMEOUT="${CODEX_HARD_TIMEOUT:-1800}"
81
177
  # Above this assembled-payload size (bytes), the diff goes via a git-dir-local temp
82
178
  # file instead of inline — never truncated.
83
179
  CODEX_REVIEW_MAX_TOTAL_BYTES="${CODEX_REVIEW_MAX_TOTAL_BYTES:-1500000}"
180
+ # Codex service tier (quality-neutral speed knob; live-probed 2026-07-05): default EMPTY ⇒ no
181
+ # service_tier flag (standard tier) — enabling Fast is a consented per-host SPEND act, never a
182
+ # silent default. The only server-catalog tier id on this subscription is 'priority' (catalog
183
+ # display name "Fast": ~1.5x token speed at a 2.5x credit rate on gpt-5.5; quality-neutral —
184
+ # same model). codex itself accepts ANY -c service_tier string silently (probe-verified), so
185
+ # the wrapper validates the effective value: an unsupported one warns and runs on the standard
186
+ # tier — a typo can never silently masquerade as Fast.
187
+ CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
188
+ if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
189
+ echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
190
+ CODEX_SERVICE_TIER=""
191
+ fi
192
+ tier_flags=()
193
+ if [[ -n "$CODEX_SERVICE_TIER" ]]; then
194
+ tier_flags=(-c "service_tier=$CODEX_SERVICE_TIER")
195
+ fi
84
196
  CHATGPT_LOGIN_GUARD="Logged in using ChatGPT"
85
197
 
86
198
  # --- Quality-first guard: refuse a silent model/effort downgrade ---------------
@@ -360,6 +472,7 @@ codex_flags=(codex exec
360
472
  -c model_reasoning_effort="$CODEX_EFFORT"
361
473
  -c hide_agent_reasoning=true
362
474
  -c model_reasoning_summary=none
475
+ "${tier_flags[@]+"${tier_flags[@]}"}"
363
476
  --color never
364
477
  -o "$out"
365
478
  --json