viber-channel 0.8.32 → 0.8.33
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/lib/bridge_spawn.ts +3 -0
- package/lib/capabilities.ts +13 -7
- package/lib/codex_model_pair.ts +166 -0
- package/lib/runner_registry.ts +1 -1
- package/lib/team_template_tool.ts +2 -2
- package/package.json +1 -1
- package/viber-codex-bridge.ts +116 -5
package/lib/bridge_spawn.ts
CHANGED
|
@@ -75,6 +75,9 @@ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
|
|
|
75
75
|
// authoritatively below (codex-309-review blocking #1).
|
|
76
76
|
delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
77
77
|
delete env.VIBER_AGENT_ROLE;
|
|
78
|
+
// #665: a parent's codex model/effort must never pin a fresh agent; spec.env may set them.
|
|
79
|
+
delete env.VIBER_CODEX_MODEL;
|
|
80
|
+
delete env.VIBER_CODEX_EFFORT;
|
|
78
81
|
env.VIBER_AGENT_TIER = spec.tier;
|
|
79
82
|
if (spec.env) Object.assign(env, spec.env);
|
|
80
83
|
// Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
|
package/lib/capabilities.ts
CHANGED
|
@@ -96,8 +96,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
96
96
|
" → agent \"g1-dev\", team \"g1-solo\"",
|
|
97
97
|
" runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
|
|
98
98
|
" only; all roles of one template must share one permission) · count optional;",
|
|
99
|
-
" model/effort: leave them OUT (runtime default) UNLESS the owner
|
|
100
|
-
"
|
|
99
|
+
" model/effort: on claude leave them OUT (runtime default) UNLESS the owner asks:",
|
|
100
|
+
" model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max. On codex BOTH are",
|
|
101
|
+
" REQUIRED: gpt-5.6-sol / low unless the owner asks for another listed pair.",
|
|
101
102
|
" (On the web, sidebar ⋯ → Add agent launches ONE named agent without a template edit.)",
|
|
102
103
|
"",
|
|
103
104
|
"DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
|
|
@@ -145,11 +146,16 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
145
146
|
" runtime REQUIRED claude | codex | gemma",
|
|
146
147
|
" permission REQUIRED read-only | read-write (gemma: read-only only)",
|
|
147
148
|
" rolePrompt REQUIRED non-empty, ≤ 600 chars AFTER {prefix} expands to 32 chars",
|
|
148
|
-
" model
|
|
149
|
-
" version);
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
149
|
+
" model claude: optional, opus | sonnet | haiku | fable (a family, never a",
|
|
150
|
+
" version); codex: REQUIRED, one of gpt-6.1-sol gpt-6-astra",
|
|
151
|
+
" gpt-6-sol gpt-6-luna gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna",
|
|
152
|
+
" gpt-5.5; gemma: ≤ 64, stored but not applied",
|
|
153
|
+
" effort claude: optional, low | medium | high | xhigh | max;",
|
|
154
|
+
" codex: REQUIRED, one the model accepts — low..xhigh on all,",
|
|
155
|
+
" max except gpt-5.5, ultra except the luna models and gpt-5.5;",
|
|
156
|
+
" gemma: refused",
|
|
157
|
+
" claude: leave model/effort OUT (runtime default) unless the",
|
|
158
|
+
" owner asks; codex: gpt-5.6-sol / low unless the owner asks",
|
|
153
159
|
" count optional integer 1..10",
|
|
154
160
|
" name: non-empty, ≤ 80, unique per owner, case-insensitive (409 without code = taken).",
|
|
155
161
|
" update: send ONLY the fields you change (name / spec / project_id) plus",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #665 — the model and reasoning effort a codex agent runs with.
|
|
3
|
+
*
|
|
4
|
+
* vibe-master hands them to the bridge as `VIBER_CODEX_MODEL` / `VIBER_CODEX_EFFORT`: BOTH or
|
|
5
|
+
* NEITHER. Neither is a role stored before #665 — the bridge then behaves exactly as before (no
|
|
6
|
+
* catalogue call, no field on `thread/start` / `turn/start`). A pair is:
|
|
7
|
+
* - checked ONCE at startup against the catalogue of THIS codex (`model/list`), before any
|
|
8
|
+
* thread or turn exists — the target is the pair SET by the template, never the model a
|
|
9
|
+
* resumed thread reports (that one is the OLD model);
|
|
10
|
+
* - applied on `thread/start` (a fresh thread) and on EVERY `turn/start`, which also moves a
|
|
11
|
+
* resumed thread onto the template's model.
|
|
12
|
+
* Anything it cannot honour stops the bridge with a message naming the pair: no silent fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CodexPair {
|
|
16
|
+
model: string;
|
|
17
|
+
effort: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A page of `model/list`, as codex-cli 0.159.3 answers it (only the fields read here). */
|
|
21
|
+
export interface ModelListPage {
|
|
22
|
+
data?: { id?: unknown; model?: unknown; supportedReasoningEfforts?: unknown }[];
|
|
23
|
+
nextCursor?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ModelListFetch = (cursor: string | null) => Promise<ModelListPage>;
|
|
27
|
+
|
|
28
|
+
/** Read the pair from the env: both, or neither (`""` is unset, the #411 convention). */
|
|
29
|
+
export function readCodexPair(env: NodeJS.ProcessEnv): CodexPair | undefined {
|
|
30
|
+
const model = env.VIBER_CODEX_MODEL?.trim() ?? "";
|
|
31
|
+
const effort = env.VIBER_CODEX_EFFORT?.trim() ?? "";
|
|
32
|
+
if (model === "" && effort === "") return undefined;
|
|
33
|
+
if (model === "" || effort === "") {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`codex model/effort must come together: got VIBER_CODEX_MODEL="${model}", VIBER_CODEX_EFFORT="${effort}" — ` +
|
|
36
|
+
"relaunch from a template that sets both",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return { model, effort };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `thread/start` fields for a pair (codex has no direct effort field there: it is config). */
|
|
43
|
+
export function threadStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
|
|
44
|
+
if (pair === undefined) return {};
|
|
45
|
+
return { model: pair.model, config: { model_reasoning_effort: pair.effort } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `turn/start` fields for a pair — they persist for later turns, resumed threads included. */
|
|
49
|
+
export function turnStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
|
|
50
|
+
if (pair === undefined) return {};
|
|
51
|
+
return { model: pair.model, effort: pair.effort };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The efforts a catalogue entry accepts (`[{ reasoningEffort }]`, or bare strings). */
|
|
55
|
+
function effortsOf(entry: { supportedReasoningEfforts?: unknown }): string[] {
|
|
56
|
+
const raw = entry.supportedReasoningEfforts;
|
|
57
|
+
if (!Array.isArray(raw)) throw new Error("model/list entry without supportedReasoningEfforts");
|
|
58
|
+
return raw.map((e) => {
|
|
59
|
+
const v = typeof e === "string" ? e : (e as { reasoningEffort?: unknown } | null)?.reasoningEffort;
|
|
60
|
+
if (typeof v !== "string") throw new Error("model/list entry with a malformed reasoning effort");
|
|
61
|
+
return v;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Walk every page of `model/list`; a repeated cursor is a loop, not a catalogue. */
|
|
66
|
+
async function collectCatalogue(fetchPage: ModelListFetch, expired: () => boolean): Promise<Map<string, string[]>> {
|
|
67
|
+
const catalogue = new Map<string, string[]>();
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
let cursor: string | null = null;
|
|
70
|
+
do {
|
|
71
|
+
// Past the deadline the caller has already refused: stop asking, do not walk on in the dark.
|
|
72
|
+
if (expired()) throw new Error("model/list walk stopped at the deadline");
|
|
73
|
+
const page = await fetchPage(cursor);
|
|
74
|
+
if (!Array.isArray(page.data)) throw new Error("model/list answered without a data array");
|
|
75
|
+
for (const entry of page.data) {
|
|
76
|
+
// `model` is the slug `thread/start` / `turn/start` take, guaranteed by 0.159.3. `id` equals
|
|
77
|
+
// it today but is not read: an entry without `model` is malformed, not resolved by guess.
|
|
78
|
+
const id = entry.model;
|
|
79
|
+
if (typeof id !== "string") throw new Error("model/list entry without a model");
|
|
80
|
+
catalogue.set(id, effortsOf(entry));
|
|
81
|
+
}
|
|
82
|
+
const next = page.nextCursor;
|
|
83
|
+
if (next !== null && next !== undefined && typeof next !== "string") {
|
|
84
|
+
throw new Error("model/list answered a non-string nextCursor");
|
|
85
|
+
}
|
|
86
|
+
cursor = next ?? null;
|
|
87
|
+
if (cursor !== null) {
|
|
88
|
+
if (seen.has(cursor)) throw new Error(`model/list repeated the cursor "${cursor}"`);
|
|
89
|
+
seen.add(cursor);
|
|
90
|
+
}
|
|
91
|
+
} while (cursor !== null);
|
|
92
|
+
return catalogue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Throw unless THIS codex's catalogue carries the pair. One deadline covers the whole pagination;
|
|
97
|
+
* an RPC failure, a malformed answer or the deadline all throw — the caller stops the bridge.
|
|
98
|
+
*/
|
|
99
|
+
export async function verifyCodexPair(
|
|
100
|
+
pair: CodexPair,
|
|
101
|
+
fetchPage: ModelListFetch,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let expired = false;
|
|
106
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
expired = true;
|
|
109
|
+
reject(new Error(`model/list did not answer within ${timeoutMs} ms`));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
});
|
|
112
|
+
const walk = collectCatalogue(fetchPage, () => expired);
|
|
113
|
+
walk.catch(() => {}); // after the deadline its own rejection has nobody left to tell
|
|
114
|
+
let catalogue: Map<string, string[]>;
|
|
115
|
+
try {
|
|
116
|
+
catalogue = await Promise.race([walk, deadline]);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
119
|
+
throw new Error(`cannot check codex model ${pair.model} / effort ${pair.effort}: ${why}`);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
}
|
|
123
|
+
const efforts = catalogue.get(pair.model);
|
|
124
|
+
if (efforts === undefined) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`codex model ${pair.model} is absent from the catalogue of THIS codex (has: ${[...catalogue.keys()].join(", ")}) — ` +
|
|
127
|
+
"update codex or re-edit the template",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!efforts.includes(pair.effort)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`codex model ${pair.model} does not accept effort ${pair.effort} on THIS codex (accepts: ${efforts.join(", ")}) — ` +
|
|
133
|
+
"re-edit the template",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Why codex is NOT running the pair, judged from what `thread/start` / `thread/resume` answer
|
|
140
|
+
* (`model`, `reasoningEffort` — the thread's EFFECTIVE settings), or undefined when it is. A null
|
|
141
|
+
* effort is "not reported", not a mismatch. No pair: nothing to compare (pre-#665 behaviour).
|
|
142
|
+
*/
|
|
143
|
+
export function pairMismatch(
|
|
144
|
+
pair: CodexPair | undefined,
|
|
145
|
+
response: unknown,
|
|
146
|
+
method: string,
|
|
147
|
+
): string | undefined {
|
|
148
|
+
if (pair === undefined) return undefined;
|
|
149
|
+
const r = (typeof response === "object" && response !== null ? response : {}) as {
|
|
150
|
+
model?: unknown;
|
|
151
|
+
reasoningEffort?: unknown;
|
|
152
|
+
};
|
|
153
|
+
if (r.model !== pair.model) {
|
|
154
|
+
return `codex ${method} runs model ${String(r.model)}, not the template's ${pair.model} — stopping rather than run the wrong model`;
|
|
155
|
+
}
|
|
156
|
+
// null = "not reported" (the field is nullable in 0.159.3); absent or any other type is a
|
|
157
|
+
// malformed answer, refused rather than read as a pass (Codex-2 review of cecd6ff7).
|
|
158
|
+
if (r.reasoningEffort === null) return undefined;
|
|
159
|
+
if (typeof r.reasoningEffort !== "string") {
|
|
160
|
+
return `codex ${method} answered a malformed reasoningEffort (${JSON.stringify(r.reasoningEffort) ?? "absent"}) — stopping rather than guess the effort`;
|
|
161
|
+
}
|
|
162
|
+
if (r.reasoningEffort !== pair.effort) {
|
|
163
|
+
return `codex ${method} runs effort ${r.reasoningEffort}, not the template's ${pair.effort} — stopping rather than run the wrong effort`;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
package/lib/runner_registry.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface RegistryRole {
|
|
|
74
74
|
permission: string;
|
|
75
75
|
rolePrompt?: string;
|
|
76
76
|
model?: string;
|
|
77
|
-
/** #656
|
|
77
|
+
/** #656 claude / #665 codex reasoning effort, replayed as `--effort` on a relaunch. */
|
|
78
78
|
effort?: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -56,8 +56,8 @@ export const TEAM_TEMPLATES_TOOL_DEF = {
|
|
|
56
56
|
description:
|
|
57
57
|
"The roster, for create/update: { roles: [{ role, runtime, permission, rolePrompt, model?, effort?, count? }] } — same shape `get` returns. " +
|
|
58
58
|
"On update a spec REPLACES the whole spec: send every role. " +
|
|
59
|
-
"
|
|
60
|
-
"effort
|
|
59
|
+
"claude: leave model and effort OUT (the runtime picks its own default) UNLESS the owner explicitly asks; model is a family (opus, sonnet, haiku or fable), effort low, medium, high, xhigh or max. " +
|
|
60
|
+
"codex: model AND effort are REQUIRED — use gpt-5.6-sol / low unless the owner asks for another listed model and an effort it accepts. " +
|
|
61
61
|
"Full field rules: the capabilities tool.",
|
|
62
62
|
},
|
|
63
63
|
project_id: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.33",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -123,6 +123,15 @@ import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host
|
|
|
123
123
|
import type { AgentToolsContext } from "./lib/agent_tools.ts";
|
|
124
124
|
import type { OpenDmResult } from "./lib/peers.ts";
|
|
125
125
|
import { resolveLockDir } from "./lib/lock_dir.ts";
|
|
126
|
+
import {
|
|
127
|
+
type CodexPair,
|
|
128
|
+
type ModelListPage,
|
|
129
|
+
readCodexPair,
|
|
130
|
+
threadStartPairParams,
|
|
131
|
+
turnStartPairParams,
|
|
132
|
+
pairMismatch,
|
|
133
|
+
verifyCodexPair,
|
|
134
|
+
} from "./lib/codex_model_pair.ts";
|
|
126
135
|
|
|
127
136
|
// #480: transport host vs logical identity. IDENTITY_URL is what the session and
|
|
128
137
|
// bridge-lock hashes are keyed on (this file builds several of them, below) —
|
|
@@ -673,7 +682,8 @@ class OutboundTurnTracker {
|
|
|
673
682
|
}
|
|
674
683
|
}
|
|
675
684
|
|
|
676
|
-
|
|
685
|
+
/** Exported for its #665 witnesses only (a fake child stands in for `codex app-server`). */
|
|
686
|
+
export class CodexAppServer {
|
|
677
687
|
private child: ChildProcessWithoutNullStreams;
|
|
678
688
|
private nextId = 1;
|
|
679
689
|
private stdoutBuffer = "";
|
|
@@ -700,6 +710,10 @@ class CodexAppServer {
|
|
|
700
710
|
* env (read through `bearer_token_env_var`) so it never lands in argv/config.
|
|
701
711
|
*/
|
|
702
712
|
mcp?: { url: string; bearerEnvVar: string; bearerValue: string },
|
|
713
|
+
/** #665: the model/effort the template set, or undefined (a role stored before #665). */
|
|
714
|
+
private readonly pair?: CodexPair,
|
|
715
|
+
/** Test seam (#665): what launches `codex app-server`. Production passes nothing. */
|
|
716
|
+
spawnChild: typeof spawn = spawn,
|
|
703
717
|
) {
|
|
704
718
|
const codexBin = process.env.VIBER_CODEX_BIN?.trim() || "codex";
|
|
705
719
|
const args = ["app-server"];
|
|
@@ -735,7 +749,7 @@ class CodexAppServer {
|
|
|
735
749
|
args.push("-c", `mcp_servers.viber.bearer_token_env_var="${mcp.bearerEnvVar}"`);
|
|
736
750
|
childEnv[mcp.bearerEnvVar] = mcp.bearerValue;
|
|
737
751
|
}
|
|
738
|
-
this.child =
|
|
752
|
+
this.child = spawnChild(codexBin, args, {
|
|
739
753
|
cwd: process.cwd(),
|
|
740
754
|
env: childEnv,
|
|
741
755
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -800,6 +814,7 @@ class CodexAppServer {
|
|
|
800
814
|
const existing = state.threads[key]?.thread_id;
|
|
801
815
|
const t0 = nowMs(); // #409: measure thread resume/start (the first-DM cost)
|
|
802
816
|
let resumeAttempted = false;
|
|
817
|
+
let resumeMismatch: string | undefined;
|
|
803
818
|
|
|
804
819
|
if (existing && !options.newThread) {
|
|
805
820
|
try {
|
|
@@ -813,17 +828,26 @@ class CodexAppServer {
|
|
|
813
828
|
sandbox: AGENT_SANDBOX,
|
|
814
829
|
developerInstructions: AGENT_INSTRUCTIONS,
|
|
815
830
|
excludeTurns: true,
|
|
831
|
+
// #665: the pair at resume time too — a resumed thread was created on the model of
|
|
832
|
+
// its FIRST launch; this moves it onto the template's, like turn/start does.
|
|
833
|
+
...threadStartPairParams(this.pair),
|
|
816
834
|
});
|
|
817
835
|
// #409 (Codex): validate/extract the id BEFORE logging success — a malformed
|
|
818
836
|
// response must NOT be logged as resume then again as resume-failed→start.
|
|
819
837
|
const resumedId = extractThreadId(resumed);
|
|
820
|
-
|
|
821
|
-
|
|
838
|
+
// #665: codex answers the thread's EFFECTIVE model — checked, never assumed. A mismatch
|
|
839
|
+
// leaves this try (it must not fall back to a fresh thread and hide the problem).
|
|
840
|
+
resumeMismatch = pairMismatch(this.pair, resumed, "thread/resume");
|
|
841
|
+
if (resumeMismatch === undefined) {
|
|
842
|
+
logTiming("ensureThread", nowMs() - t0, "outcome=resume");
|
|
843
|
+
return resumedId;
|
|
844
|
+
}
|
|
822
845
|
} catch (err) {
|
|
823
846
|
process.stderr.write(
|
|
824
847
|
`[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
|
|
825
848
|
);
|
|
826
849
|
}
|
|
850
|
+
if (resumeMismatch !== undefined) this.stopOnPairMismatch(resumeMismatch);
|
|
827
851
|
}
|
|
828
852
|
|
|
829
853
|
process.stderr.write("[viber-codex-bridge] codex: starting fresh bridge-owned thread\n");
|
|
@@ -836,8 +860,18 @@ class CodexAppServer {
|
|
|
836
860
|
ephemeral: false,
|
|
837
861
|
threadSource: "user",
|
|
838
862
|
sessionStartSource: options.newThread ? "clear" : "startup",
|
|
863
|
+
...threadStartPairParams(this.pair),
|
|
864
|
+
}).catch((err: unknown) => {
|
|
865
|
+
// #665: a refusal caused by the pair must name it, not surface as a bare RPC error.
|
|
866
|
+
if (this.pair === undefined) throw err;
|
|
867
|
+
throw new Error(
|
|
868
|
+
`codex refused thread/start with model ${this.pair.model} / effort ${this.pair.effort}: ${safeErrorMessage(err)}`,
|
|
869
|
+
);
|
|
839
870
|
});
|
|
840
871
|
const threadId = extractThreadId(started);
|
|
872
|
+
// #665: same check on a fresh thread — what codex SAYS it runs, not what we asked.
|
|
873
|
+
const startMismatch = pairMismatch(this.pair, started, "thread/start");
|
|
874
|
+
if (startMismatch !== undefined) this.stopOnPairMismatch(startMismatch);
|
|
841
875
|
state.threads[key] = {
|
|
842
876
|
thread_id: threadId,
|
|
843
877
|
conversation_id: options.conversationId,
|
|
@@ -867,11 +901,36 @@ class CodexAppServer {
|
|
|
867
901
|
sandboxPolicy: buildTurnSandboxPolicy(AGENT_TIER),
|
|
868
902
|
developerInstructions: AGENT_INSTRUCTIONS,
|
|
869
903
|
responsesapiClientMetadata: { source: "viber-codex-bridge" },
|
|
904
|
+
// #665: on EVERY turn — it also moves a resumed thread onto the template's model.
|
|
905
|
+
...turnStartPairParams(this.pair),
|
|
870
906
|
});
|
|
871
907
|
const turnId = this.extractTurnId(response);
|
|
872
908
|
return await this.waitForTurn(threadId, turnId);
|
|
873
909
|
}
|
|
874
910
|
|
|
911
|
+
/**
|
|
912
|
+
* #665: check the pair against THIS codex's catalogue (`model/list`) — no thread, no turn, so
|
|
913
|
+
* it runs before any `turn/start`. No pair: nothing is called (the pre-#665 behaviour).
|
|
914
|
+
*/
|
|
915
|
+
/** #665: codex is not running the template's pair — stop the bridge in clear, never run on. */
|
|
916
|
+
private stopOnPairMismatch(reason: string): never {
|
|
917
|
+
process.stderr.write(`${LOG_PREFIX} #665: ${reason}\n`);
|
|
918
|
+
this.onUnexpectedExit?.(reason);
|
|
919
|
+
throw new Error(reason);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async verifyPair(timeoutMs: number): Promise<void> {
|
|
923
|
+
if (this.pair === undefined) return;
|
|
924
|
+
await verifyCodexPair(
|
|
925
|
+
this.pair,
|
|
926
|
+
async (cursor) => (await this.request("model/list", { cursor, includeHidden: false })) as ModelListPage,
|
|
927
|
+
timeoutMs,
|
|
928
|
+
);
|
|
929
|
+
process.stderr.write(
|
|
930
|
+
`${LOG_PREFIX} #665: codex model ${this.pair.model} / effort ${this.pair.effort} — found in this codex's catalogue\n`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
875
934
|
close(): void {
|
|
876
935
|
this.closing = true;
|
|
877
936
|
this.child.kill();
|
|
@@ -1382,6 +1441,8 @@ function setupCodexRuntime(deps: {
|
|
|
1382
1441
|
requestShutdown: (err: BridgeShutdownError) => void;
|
|
1383
1442
|
/** #627: why the bridge is paused (null = may write). */
|
|
1384
1443
|
pauseReason: () => string | null;
|
|
1444
|
+
/** #665: the template's model/effort, or undefined. */
|
|
1445
|
+
pair: CodexPair | undefined;
|
|
1385
1446
|
}): { codex: CodexAppServer; toolHost: BridgeToolHost } {
|
|
1386
1447
|
const { tracker } = deps;
|
|
1387
1448
|
const bridgeToolsCtx: AgentToolsContext = {
|
|
@@ -1417,10 +1478,39 @@ function setupCodexRuntime(deps: {
|
|
|
1417
1478
|
deps.requestShutdown(new BridgeShutdownError(reason, 3));
|
|
1418
1479
|
},
|
|
1419
1480
|
{ url: toolHost.url, bearerEnvVar: "VIBER_MCP_TOKEN", bearerValue: toolHost.bearerToken },
|
|
1481
|
+
deps.pair,
|
|
1420
1482
|
);
|
|
1421
1483
|
return { codex, toolHost };
|
|
1422
1484
|
}
|
|
1423
1485
|
|
|
1486
|
+
/** #665: one deadline for the whole `model/list` walk at startup (it may refresh over network). */
|
|
1487
|
+
const CODEX_PAIR_CHECK_TIMEOUT_MS = 30_000;
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* #665: initialize codex and check its pair. On ANY failure — initialize, catalogue refusal,
|
|
1491
|
+
* deadline — `release` runs (child, tool host, lock) BEFORE the error leaves as a
|
|
1492
|
+
* BridgeShutdownError (a refused pair) or as itself (initialize): on the `--conversation` path nothing
|
|
1493
|
+
* else would release them.
|
|
1494
|
+
*/
|
|
1495
|
+
export async function startCheckedCodex(
|
|
1496
|
+
codex: Pick<CodexAppServer, "initialize" | "verifyPair">,
|
|
1497
|
+
timeoutMs: number,
|
|
1498
|
+
release: () => void,
|
|
1499
|
+
): Promise<void> {
|
|
1500
|
+
try {
|
|
1501
|
+
await codex.initialize();
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
release();
|
|
1504
|
+
throw err; // unchanged type: an initialize failure stays a crash, as before #665 (Opus review)
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
await codex.verifyPair(timeoutMs);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
release();
|
|
1510
|
+
throw new BridgeShutdownError(safeErrorMessage(err), 2); // a refused pair is a deliberate end
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1424
1514
|
function logCodexSafety(): void {
|
|
1425
1515
|
process.stderr.write(
|
|
1426
1516
|
`${LOG_PREFIX} codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=${networkForTier(AGENT_TIER)}, ` +
|
|
@@ -1436,6 +1526,8 @@ function logCodexSafety(): void {
|
|
|
1436
1526
|
|
|
1437
1527
|
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
1438
1528
|
const options = parseArgs(argv);
|
|
1529
|
+
// #665: both or neither — a partial pair stops the bridge HERE, before any identity or child.
|
|
1530
|
+
const pair = readCodexPair(process.env);
|
|
1439
1531
|
const { auth, fingerprint } = loadBridgeAuthAndFingerprint();
|
|
1440
1532
|
// #317 (iii): per-turn outbound tracker (anti-double-post + send_message target).
|
|
1441
1533
|
const tracker = new OutboundTurnTracker();
|
|
@@ -1512,10 +1604,20 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1512
1604
|
startConversation: helpers.startConversation,
|
|
1513
1605
|
requestShutdown: helpers.requestShutdown,
|
|
1514
1606
|
pauseReason: helpers.pauseReason,
|
|
1607
|
+
pair,
|
|
1515
1608
|
});
|
|
1516
1609
|
codex = rt.codex;
|
|
1517
1610
|
toolHost = rt.toolHost;
|
|
1518
1611
|
await codex.initialize();
|
|
1612
|
+
// #665: before the control stream opens, so before any join, thread or turn. A refusal is
|
|
1613
|
+
// a deliberate end (presence `ended`), never a crash the runner would read as alive.
|
|
1614
|
+
try {
|
|
1615
|
+
await codex.verifyPair(CODEX_PAIR_CHECK_TIMEOUT_MS);
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
const stop = new BridgeShutdownError(safeErrorMessage(err), 2);
|
|
1618
|
+
helpers.requestShutdown(stop);
|
|
1619
|
+
throw stop;
|
|
1620
|
+
}
|
|
1519
1621
|
logCodexSafety();
|
|
1520
1622
|
},
|
|
1521
1623
|
onShutdown: () => {
|
|
@@ -1570,10 +1672,19 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1570
1672
|
if (!shutdown.signal.aborted) shutdown.abort(err);
|
|
1571
1673
|
},
|
|
1572
1674
|
pauseReason: () => gateRef.gate?.reason() ?? REASON_STARTING,
|
|
1675
|
+
pair,
|
|
1573
1676
|
});
|
|
1574
1677
|
codex = rt.codex;
|
|
1575
1678
|
toolHost = rt.toolHost;
|
|
1576
|
-
|
|
1679
|
+
// #665: before the presence record and the stream, so before any turn. This runs BEFORE the
|
|
1680
|
+
// try/finally below, so a failure must release what is already held (Codex-2 review of d6d127a5).
|
|
1681
|
+
const started = codex;
|
|
1682
|
+
const host = toolHost;
|
|
1683
|
+
await startCheckedCodex(started, CODEX_PAIR_CHECK_TIMEOUT_MS, () => {
|
|
1684
|
+
started.close();
|
|
1685
|
+
host.stop();
|
|
1686
|
+
res.bridgeLock.release();
|
|
1687
|
+
});
|
|
1577
1688
|
logCodexSafety();
|
|
1578
1689
|
process.stderr.write(`${LOG_PREFIX} ready: joined ${res.minted.conversation_id} as instance ${res.instanceKey.slice(0, 8)}\n`);
|
|
1579
1690
|
// #315: no control stream here, so beat the instance liveness directly.
|