@bitkyc08/opencodex 2.50.0 → 2.51.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/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
|
@@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort";
|
|
|
4
4
|
import { buildSystemPrompt } from "../coding-agent/protocol";
|
|
5
5
|
import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn";
|
|
6
6
|
import { QODER_PROFILES, type QoderProfile } from "./profiles";
|
|
7
|
+
import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard";
|
|
7
8
|
|
|
8
9
|
export type QoderAdapterDeps = CodingAgentDeps;
|
|
9
10
|
|
|
@@ -31,6 +32,73 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo
|
|
|
31
32
|
return args;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Wrap the turn's outbound channel with the scaffolding guard (#4190).
|
|
37
|
+
*
|
|
38
|
+
* The vendor CLI can put its own agent layer into the text channel despite being launched
|
|
39
|
+
* with tools and MCP disabled, and the shared stream-json parser forwards a text delta
|
|
40
|
+
* without inspecting it. This is the last point that is still qoder-specific, so the guard
|
|
41
|
+
* sits here rather than in the parser every coding-agent CLI shares.
|
|
42
|
+
*
|
|
43
|
+
* A terminal event flushes both channels first. The held tail is text the filter could not
|
|
44
|
+
* yet prove was not the start of a marker; dropping it would truncate a legitimate answer,
|
|
45
|
+
* and swallowing an entire response before forwarding a "done" reads downstream as an empty
|
|
46
|
+
* completion rather than as the refusal it is.
|
|
47
|
+
*/
|
|
48
|
+
export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void {
|
|
49
|
+
const textFilter = new QoderScaffoldFilter();
|
|
50
|
+
const thinkingFilter = new QoderScaffoldFilter();
|
|
51
|
+
let closed = false;
|
|
52
|
+
|
|
53
|
+
const refuse = (reason: string): void => {
|
|
54
|
+
if (closed) return;
|
|
55
|
+
closed = true;
|
|
56
|
+
emit({
|
|
57
|
+
type: "error",
|
|
58
|
+
message: qoderScaffoldErrorMessage(reason),
|
|
59
|
+
status: 502,
|
|
60
|
+
errorType: "upstream_error",
|
|
61
|
+
code: QODER_SCAFFOLD_ERROR_CODE,
|
|
62
|
+
// Intermittent, but a silent retry spends the operator's vendor credits on a
|
|
63
|
+
// contract violation the proxy cannot influence. Surface it instead.
|
|
64
|
+
retryable: false,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (event: AdapterEvent): void => {
|
|
69
|
+
if (closed) return;
|
|
70
|
+
if (event.type === "text_delta") {
|
|
71
|
+
const cleaned = textFilter.push(event.text);
|
|
72
|
+
if (cleaned.text) emit({ ...event, text: cleaned.text });
|
|
73
|
+
if (cleaned.fail) refuse(cleaned.fail);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (event.type === "thinking_delta") {
|
|
77
|
+
const cleaned = thinkingFilter.push(event.thinking);
|
|
78
|
+
if (cleaned.text) emit({ ...event, thinking: cleaned.text });
|
|
79
|
+
if (cleaned.fail) refuse(cleaned.fail);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
|
|
83
|
+
const tail = textFilter.flush();
|
|
84
|
+
const reasoning = thinkingFilter.flush();
|
|
85
|
+
if (tail.text) emit({ type: "text_delta", text: tail.text });
|
|
86
|
+
if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text });
|
|
87
|
+
const fail = tail.fail ?? reasoning.fail;
|
|
88
|
+
// A vendor error already carries the better explanation for why the turn ended;
|
|
89
|
+
// only a terminal that claims success is replaced.
|
|
90
|
+
if (fail && event.type !== "error") {
|
|
91
|
+
refuse(fail);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
closed = true;
|
|
95
|
+
emit(event);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
emit(event);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
34
102
|
export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter {
|
|
35
103
|
return {
|
|
36
104
|
name: "qoder",
|
|
@@ -60,7 +128,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
|
|
|
60
128
|
provider,
|
|
61
129
|
parsed,
|
|
62
130
|
incoming,
|
|
63
|
-
emit,
|
|
131
|
+
emit: guardQoderScaffolding(emit),
|
|
64
132
|
buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov),
|
|
65
133
|
buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
|
|
66
134
|
deps,
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-scaffolding guard for the qoder route (#4190).
|
|
3
|
+
*
|
|
4
|
+
* The qoder route is contractually a text and reasoning surface: the CLI is spawned with
|
|
5
|
+
* `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership.
|
|
6
|
+
* The vendor CLI does not always honour that. It has been observed emitting its own agent
|
|
7
|
+
* layer into the assistant text channel — an MCP lazy-loading `<system-reminder>` block
|
|
8
|
+
* listing the local machine's configured MCP servers, and framework tool-call markup with a
|
|
9
|
+
* mismatched closer. Both reached the client verbatim, because the shared stream-json parser
|
|
10
|
+
* forwards a text delta without inspecting it.
|
|
11
|
+
*
|
|
12
|
+
* Two shapes, two answers. A complete `<system-reminder>` block is recognizable and
|
|
13
|
+
* self-delimiting, so it is removed and the surrounding answer survives. Anything else that
|
|
14
|
+
* carries a scaffolding signature is not repairable by guesswork — a partial tool-call block
|
|
15
|
+
* has no reliable end, and the text around it may already be the vendor's own agent
|
|
16
|
+
* narration rather than the model's answer — so the turn fails closed instead.
|
|
17
|
+
*
|
|
18
|
+
* The filter is a stream, not a regex over a finished string: a marker can be split across
|
|
19
|
+
* deltas, so a tail that is still a possible marker prefix is held back rather than emitted.
|
|
20
|
+
* Callers must therefore `flush()` before forwarding a terminal event, or a legitimate
|
|
21
|
+
* answer ending in "<" would lose its last character.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Error code for a turn refused because vendor scaffolding reached the text channel. */
|
|
25
|
+
export const QODER_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected";
|
|
26
|
+
|
|
27
|
+
const REMINDER_OPEN = "<system-reminder";
|
|
28
|
+
const REMINDER_CLOSE = "</system-reminder>";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Scaffolding signatures that are never repaired.
|
|
32
|
+
*
|
|
33
|
+
* `<functions.` and the invoke pair are the vendor runtime's tool-call markup. The reported
|
|
34
|
+
* leak carried `<functions.exec>` opened and `</invoke>` closed — mismatched, which is what
|
|
35
|
+
* a model emitting remembered markup looks like, and exactly why reconstructing the intended
|
|
36
|
+
* text is not possible. A stray `</system-reminder>` with no opener is in the same class:
|
|
37
|
+
* the block it belonged to was already partly forwarded, or never existed.
|
|
38
|
+
*/
|
|
39
|
+
const UNREPAIRABLE_MARKERS = ["<functions.", "<invoke name=", "<invoke>", "</invoke>", REMINDER_CLOSE] as const;
|
|
40
|
+
|
|
41
|
+
/** Markers that end a block rather than start one; their prefix is never an answer. */
|
|
42
|
+
const CLOSING_MARKERS = new Set<string>(["</invoke>", REMINDER_CLOSE]);
|
|
43
|
+
|
|
44
|
+
/** Every marker the scanner must be able to recognize mid-split. */
|
|
45
|
+
const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const;
|
|
46
|
+
|
|
47
|
+
const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when `<system-reminder` at `at` is the tag rather than the start of a longer word.
|
|
51
|
+
*
|
|
52
|
+
* The opener is matched without its `>` so a truncated or self-closed tag still suppresses,
|
|
53
|
+
* which means `<system-reminders>` in an ordinary answer would otherwise open a block and
|
|
54
|
+
* refuse the turn. A stem running to the end of the buffer still counts: more text may be
|
|
55
|
+
* arriving, and reading it as prose is the one reading that could release the block body.
|
|
56
|
+
*/
|
|
57
|
+
function reminderOpensHere(lowered: string, at: number): boolean {
|
|
58
|
+
const after = lowered[at + REMINDER_OPEN.length];
|
|
59
|
+
return after === undefined || /[\s/>]/.test(after);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Ceiling on a suppressed block before it is treated as unterminated.
|
|
64
|
+
*
|
|
65
|
+
* The block itself is discarded as it arrives, so this is not a memory bound — only the
|
|
66
|
+
* trailing bytes needed to spot a split closer are retained. It bounds how much of a turn a
|
|
67
|
+
* single unclosed reminder is allowed to swallow silently before the turn is refused.
|
|
68
|
+
*/
|
|
69
|
+
const MAX_SUPPRESSED_CHARS = 64 * 1024;
|
|
70
|
+
|
|
71
|
+
/** Result of feeding one chunk: the text safe to forward, and a refusal reason once tripped. */
|
|
72
|
+
export interface ScaffoldFilterResult {
|
|
73
|
+
/** Text cleared for the client. Empty when everything in the chunk was held or dropped. */
|
|
74
|
+
text: string;
|
|
75
|
+
/** Non-null exactly once, on the chunk that trips the guard. */
|
|
76
|
+
fail: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Longest suffix of `text` that could still grow into one of the markers. */
|
|
80
|
+
function heldSuffixLength(text: string): number {
|
|
81
|
+
const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length);
|
|
82
|
+
for (let length = limit; length > 0; length--) {
|
|
83
|
+
const suffix = text.slice(text.length - length).toLowerCase();
|
|
84
|
+
for (const marker of ALL_MARKERS) {
|
|
85
|
+
if (marker.length > length && marker.startsWith(suffix)) return length;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Streaming scaffolding filter for one channel (text or reasoning).
|
|
93
|
+
*
|
|
94
|
+
* One instance per channel: the two never share suppression state, so a reminder block
|
|
95
|
+
* opened in reasoning cannot swallow the answer text.
|
|
96
|
+
*/
|
|
97
|
+
export class QoderScaffoldFilter {
|
|
98
|
+
private mode: "pass" | "suppress" = "pass";
|
|
99
|
+
private pending = "";
|
|
100
|
+
private suppressedTail = "";
|
|
101
|
+
private suppressedChars = 0;
|
|
102
|
+
private failed = false;
|
|
103
|
+
/** True once a reminder block has been suppressed on this channel. */
|
|
104
|
+
private suppressedBlock = false;
|
|
105
|
+
/** Open reminder blocks; only the closer that unwinds the last one ends suppression. */
|
|
106
|
+
private suppressDepth = 0;
|
|
107
|
+
|
|
108
|
+
push(chunk: string): ScaffoldFilterResult {
|
|
109
|
+
if (this.failed || !chunk) return { text: "", fail: null };
|
|
110
|
+
let cleared = "";
|
|
111
|
+
let buffer = this.mode === "pass" ? this.pending + chunk : chunk;
|
|
112
|
+
this.pending = "";
|
|
113
|
+
|
|
114
|
+
for (;;) {
|
|
115
|
+
if (this.mode === "suppress") {
|
|
116
|
+
const scan = this.suppressedTail + buffer;
|
|
117
|
+
const scanned = scan.toLowerCase();
|
|
118
|
+
// Unwind nesting rather than ending at the first closer. A reminder containing another
|
|
119
|
+
// reminder would otherwise hand the outer block's remaining body — the MCP server list
|
|
120
|
+
// in the reported leak — to the client as the model's answer, with a successful
|
|
121
|
+
// terminal and nothing to signal that anything had gone wrong.
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
let close = -1;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor);
|
|
126
|
+
if (nextClose < 0) break;
|
|
127
|
+
let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor);
|
|
128
|
+
while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) {
|
|
129
|
+
nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1);
|
|
130
|
+
}
|
|
131
|
+
if (nextOpen >= 0 && nextOpen < nextClose) {
|
|
132
|
+
this.suppressDepth += 1;
|
|
133
|
+
cursor = nextOpen + REMINDER_OPEN.length;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
this.suppressDepth -= 1;
|
|
137
|
+
cursor = nextClose + REMINDER_CLOSE.length;
|
|
138
|
+
if (this.suppressDepth === 0) {
|
|
139
|
+
close = nextClose;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (close < 0) {
|
|
144
|
+
this.suppressedChars += buffer.length;
|
|
145
|
+
if (this.suppressedChars > MAX_SUPPRESSED_CHARS) {
|
|
146
|
+
return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`);
|
|
147
|
+
}
|
|
148
|
+
// The block is discarded as it arrives; only enough tail to spot a split closer is kept.
|
|
149
|
+
// The tail must cover a split opener too, now that nesting is counted.
|
|
150
|
+
this.suppressedTail = scan.slice(Math.max(0, scan.length - (MAX_MARKER_LENGTH - 1)));
|
|
151
|
+
return { text: cleared, fail: null };
|
|
152
|
+
}
|
|
153
|
+
buffer = scan.slice(close + REMINDER_CLOSE.length);
|
|
154
|
+
this.mode = "pass";
|
|
155
|
+
this.suppressedTail = "";
|
|
156
|
+
this.suppressedChars = 0;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let earliest = -1;
|
|
161
|
+
let found = "";
|
|
162
|
+
const lowered = buffer.toLowerCase();
|
|
163
|
+
for (const marker of ALL_MARKERS) {
|
|
164
|
+
let at = lowered.indexOf(marker);
|
|
165
|
+
while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) {
|
|
166
|
+
at = lowered.indexOf(marker, at + 1);
|
|
167
|
+
}
|
|
168
|
+
if (at < 0) continue;
|
|
169
|
+
// A closer sitting exactly where an opener starts cannot happen, so ties are impossible.
|
|
170
|
+
if (earliest < 0 || at < earliest) {
|
|
171
|
+
earliest = at;
|
|
172
|
+
found = marker;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (earliest < 0) {
|
|
177
|
+
const held = heldSuffixLength(buffer);
|
|
178
|
+
cleared += held > 0 ? buffer.slice(0, buffer.length - held) : buffer;
|
|
179
|
+
this.pending = held > 0 ? buffer.slice(buffer.length - held) : "";
|
|
180
|
+
return { text: cleared, fail: null };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Text produced before the scaffolding is the model's own answer, and it is kept — but
|
|
184
|
+
// only while this channel has not already suppressed a block. Once it has, the text
|
|
185
|
+
// between that block and an unrepairable marker is not an answer that happens to
|
|
186
|
+
// precede a leak; it is the region the vendor was narrating in, and in the reported
|
|
187
|
+
// case it carries the MCP server list. Forwarding it on the way to a refusal would
|
|
188
|
+
// publish exactly what the refusal exists to contain.
|
|
189
|
+
// A closer with no opener never keeps its prefix either: the block it belonged to was
|
|
190
|
+
// already partly forwarded or never existed, so the text ahead of it is that body.
|
|
191
|
+
if (!CLOSING_MARKERS.has(found) && (found === REMINDER_OPEN || !this.suppressedBlock)) {
|
|
192
|
+
cleared += buffer.slice(0, earliest);
|
|
193
|
+
}
|
|
194
|
+
if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`);
|
|
195
|
+
this.suppressedBlock = true;
|
|
196
|
+
this.mode = "suppress";
|
|
197
|
+
this.suppressDepth = 1;
|
|
198
|
+
this.suppressedTail = "";
|
|
199
|
+
this.suppressedChars = 0;
|
|
200
|
+
buffer = buffer.slice(earliest + REMINDER_OPEN.length);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Release the held tail. Call before forwarding a terminal event, never mid-stream. */
|
|
205
|
+
flush(): ScaffoldFilterResult {
|
|
206
|
+
if (this.failed) return { text: "", fail: null };
|
|
207
|
+
if (this.mode === "suppress") return this.fail("", `an unterminated ${REMINDER_OPEN}> block`);
|
|
208
|
+
const text = this.pending;
|
|
209
|
+
this.pending = "";
|
|
210
|
+
return { text, fail: null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private fail(cleared: string, reason: string): ScaffoldFilterResult {
|
|
214
|
+
this.failed = true;
|
|
215
|
+
this.pending = "";
|
|
216
|
+
this.suppressedTail = "";
|
|
217
|
+
return { text: cleared, fail: reason };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Message for a refused turn.
|
|
223
|
+
*
|
|
224
|
+
* It names the marker class and nothing else. The leaked reminder in the report enumerated
|
|
225
|
+
* the operator's own MCP servers, so echoing the offending text back — into an error the
|
|
226
|
+
* client renders, and that a user may paste into an issue — would publish the thing this
|
|
227
|
+
* guard exists to contain.
|
|
228
|
+
*/
|
|
229
|
+
export function qoderScaffoldErrorMessage(reason: string): string {
|
|
230
|
+
return `Qoder CLI emitted ${reason} in the assistant text channel. This route runs the CLI with`
|
|
231
|
+
+ " its own tools and MCP servers disabled and Codex owns tool control, so the turn was refused"
|
|
232
|
+
+ " rather than forwarding vendor agent scaffolding to the client.";
|
|
233
|
+
}
|
|
@@ -94,7 +94,23 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri
|
|
|
94
94
|
return { alias: claudeCodeNativeAlias(entry), id: entry, provider: "native" };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export function buildClaudeAgentDefs(
|
|
97
|
+
export function buildClaudeAgentDefs(
|
|
98
|
+
config: OcxConfig,
|
|
99
|
+
windows: Record<string, number>,
|
|
100
|
+
configDir = claudeConfigDir(),
|
|
101
|
+
/**
|
|
102
|
+
* The roster to generate defs from, overriding local `config.subagentModels` (#4236).
|
|
103
|
+
*
|
|
104
|
+
* A connected client's local roster is whatever it had before it joined the hub — on a fresh
|
|
105
|
+
* client, the five native defaults — while the hub's featured roster is the list that
|
|
106
|
+
* actually routes. Passing it in keeps this function pure and keeps the override visible at
|
|
107
|
+
* the call site instead of hidden behind a config read.
|
|
108
|
+
*
|
|
109
|
+
* Undefined preserves today's behaviour exactly, including "unset means the defaults, an
|
|
110
|
+
* explicit `[]` means none".
|
|
111
|
+
*/
|
|
112
|
+
rosterOverride?: readonly string[],
|
|
113
|
+
): ClaudeAgentDef[] {
|
|
98
114
|
const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
|
|
99
115
|
const blockedSkillsFor = (model: string): readonly string[] => {
|
|
100
116
|
const unmarked = stripOneMillionMarker(model);
|
|
@@ -137,7 +153,8 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
|
|
|
137
153
|
|
|
138
154
|
// Default roster applies only when the field is UNSET — an explicit [] is
|
|
139
155
|
// respected (audit 071 #6: an upgraded config must not lose the default five).
|
|
140
|
-
const roster =
|
|
156
|
+
const roster = rosterOverride
|
|
157
|
+
?? (config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels);
|
|
141
158
|
for (const entry of roster.slice(0, 5)) {
|
|
142
159
|
if (typeof entry !== "string" || entry.trim() === "") continue;
|
|
143
160
|
const { alias, id, provider } = entryParts(entry.trim(), config);
|
|
@@ -260,13 +277,20 @@ export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir =
|
|
|
260
277
|
}
|
|
261
278
|
|
|
262
279
|
/** Launch-time hook: gate + build + sync in one call (used by ocx claude and systemEnv). */
|
|
263
|
-
export function injectClaudeAgentDefs(
|
|
280
|
+
export function injectClaudeAgentDefs(
|
|
281
|
+
config: OcxConfig,
|
|
282
|
+
windows: Record<string, number>,
|
|
283
|
+
configDir?: string,
|
|
284
|
+
/** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */
|
|
285
|
+
rosterOverride?: readonly string[],
|
|
286
|
+
): string[] | null {
|
|
264
287
|
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
|
|
265
288
|
// Disabled: prune verified-owned files so stale definitions stop loading
|
|
266
|
-
// in future sessions (audit 071 #3).
|
|
289
|
+
// in future sessions (audit 071 #3). The roster override is irrelevant here by
|
|
290
|
+
// construction: there is nothing to build.
|
|
267
291
|
return syncClaudeAgentDefs([], configDir);
|
|
268
292
|
}
|
|
269
|
-
return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir), configDir);
|
|
293
|
+
return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir, rosterOverride), configDir);
|
|
270
294
|
}
|
|
271
295
|
/**
|
|
272
296
|
* Dispatcher directive appended to every ocx-* description. The ocx-route body
|
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type DesktopProfileModel,
|
|
23
23
|
} from "./desktop-profile";
|
|
24
24
|
import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog";
|
|
25
|
+
import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
|
|
25
26
|
import { assertDesktop3pModelsValid } from "./desktop-3p-guard";
|
|
26
27
|
|
|
27
28
|
export interface Desktop3pModelEntry {
|
|
@@ -322,9 +323,14 @@ export function activeDesktop3pAlias(provider: string, modelId: string): string
|
|
|
322
323
|
* channel for supports1m/tier pins and it overrides discovery anyway (no merge), so
|
|
323
324
|
* discovery stays off for determinism. supports1m makes Desktop offer a separate 1M
|
|
324
325
|
* row; selecting it sends the bare id + `anthropic-beta: context-1m-2025-08-07`.
|
|
326
|
+
*
|
|
327
|
+
* `portOrOrigin` is the LOCAL destination Desktop should dial, already resolved by the caller
|
|
328
|
+
* (see `writeDesktop3pConfig`): on a hub that is the unauthenticated loopback listener, and with
|
|
329
|
+
* no listener the bind address — which is why an ORIGIN is accepted and not only a port. A bare
|
|
330
|
+
* port keeps meaning `http://127.0.0.1:<port>`, so every existing caller and test is unchanged.
|
|
325
331
|
*/
|
|
326
332
|
export function generateDesktop3pConfig(
|
|
327
|
-
|
|
333
|
+
portOrOrigin: number | string,
|
|
328
334
|
nativeSlugs: string[],
|
|
329
335
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
330
336
|
apiKey = "ocx",
|
|
@@ -335,7 +341,7 @@ export function generateDesktop3pConfig(
|
|
|
335
341
|
const base = {
|
|
336
342
|
inferenceProvider: "gateway",
|
|
337
343
|
inferenceCredentialKind: "static",
|
|
338
|
-
inferenceGatewayBaseUrl: `http://127.0.0.1:${
|
|
344
|
+
inferenceGatewayBaseUrl: typeof portOrOrigin === "number" ? `http://127.0.0.1:${portOrOrigin}` : portOrOrigin,
|
|
339
345
|
inferenceGatewayApiKey: apiKey,
|
|
340
346
|
};
|
|
341
347
|
if (mode === "discovery") {
|
|
@@ -620,8 +626,30 @@ export function writeDesktop3pConfig(
|
|
|
620
626
|
if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") {
|
|
621
627
|
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" };
|
|
622
628
|
}
|
|
629
|
+
// Claude Desktop runs on this machine, so it dials the unauthenticated loopback listener
|
|
630
|
+
// when one is enabled — on such a hub that is the only credential-free local socket
|
|
631
|
+
// (#4236) — and otherwise the bind address, which answers but demands data-plane
|
|
632
|
+
// admission. Resolved here, from the config this write already re-read, rather than in
|
|
633
|
+
// the pure generator: `latest.config` is the freshest answer any caller could pass in.
|
|
634
|
+
const destination = localInferenceDestination(latest.config, port);
|
|
635
|
+
// Desktop can carry a credential, so it does: the key the caller passed (the first
|
|
636
|
+
// configured `apiKeys` entry), else the env token / hardened service token file. This is
|
|
637
|
+
// the DATA-PLANE secret only — an admin token must never enter an exported client
|
|
638
|
+
// configuration (reviewer constraint on #4236).
|
|
639
|
+
const gatewayKey = destination.requiresAdmissionToken
|
|
640
|
+
? apiKey ?? localAdmissionToken(latest.config)
|
|
641
|
+
: apiKey;
|
|
642
|
+
if (destination.requiresAdmissionToken && !gatewayKey) {
|
|
643
|
+
// The placeholder the generator defaults to would 401 on this bind. Write the profile
|
|
644
|
+
// anyway — a reachable URL with a visible auth failure beats a dead socket — but say so.
|
|
645
|
+
console.error(
|
|
646
|
+
`⚠ Claude Desktop will dial ${destination.origin}, which requires an opencodex data-plane `
|
|
647
|
+
+ "credential that could not be resolved. Configure an API key or enable "
|
|
648
|
+
+ "`unauthenticatedLoopbackListener`.",
|
|
649
|
+
);
|
|
650
|
+
}
|
|
623
651
|
return writeDesktop3pConfigWithGenerator(() => (
|
|
624
|
-
generateDesktop3pConfig(
|
|
652
|
+
generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap)
|
|
625
653
|
));
|
|
626
654
|
}), lifecycleLockDeps);
|
|
627
655
|
} catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; }
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import {
|
|
16
|
+
import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
|
|
17
17
|
import type { OcxConfig } from "../types";
|
|
18
18
|
|
|
19
19
|
export interface GatewayModelRow {
|
|
@@ -24,7 +24,12 @@ export interface GatewayModelRow {
|
|
|
24
24
|
export interface GatewayModelCacheRefreshOptions {
|
|
25
25
|
timeoutMs?: number;
|
|
26
26
|
configDir?: string;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Admission credential source AND local destination source: the cache file's `baseUrl` must
|
|
29
|
+
* equal the `ANTHROPIC_BASE_URL` the CLI is launched with or Claude Code ignores the whole
|
|
30
|
+
* cache, so this has to resolve the same loopback listener `buildClaudeEnv` resolves (#4236).
|
|
31
|
+
*/
|
|
32
|
+
admissionConfig?: Pick<OcxConfig, "apiKeys" | "hostname" | "unauthenticatedLoopbackListener">;
|
|
28
33
|
env?: NodeJS.ProcessEnv;
|
|
29
34
|
fetchImpl?: typeof fetch;
|
|
30
35
|
}
|
|
@@ -60,19 +65,6 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
|
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
/**
|
|
64
|
-
* Hardened service-token file, the same precedence `ocx opencode` uses. A service
|
|
65
|
-
* install writes the admission token to disk rather than the interactive environment,
|
|
66
|
-
* so an interactive `ocx claude` with neither env token nor configured key would
|
|
67
|
-
* otherwise still get a 401 and keep a stale picker list.
|
|
68
|
-
*/
|
|
69
|
-
function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
|
|
70
|
-
const lookup = env.OCX_API_TOKEN_FILE?.trim()
|
|
71
|
-
? env
|
|
72
|
-
: { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
|
|
73
|
-
return loadServiceTokenFromFile(lookup as Record<string, string | undefined>);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
/** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
|
|
77
69
|
export async function refreshGatewayModelCacheFromProxy(
|
|
78
70
|
port: number,
|
|
@@ -92,17 +84,16 @@ export async function refreshGatewayModelCacheFromProxy(
|
|
|
92
84
|
// request sent to its local 127.0.0.1 address. Reuse the same dedicated
|
|
93
85
|
// credential domain as /v1/models admission; never place it in Authorization,
|
|
94
86
|
// which can belong to an upstream provider on other data-plane surfaces.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
?.key.trim();
|
|
87
|
+
// Env token, then the hardened service token file (a service install writes the admission
|
|
88
|
+
// token to disk rather than the interactive environment), then a configured key — one
|
|
89
|
+
// shared ladder, so this cannot drift from what `buildClaudeEnv` puts in the launch env.
|
|
99
90
|
const admissionToken = typeof portOrTarget === "number"
|
|
100
|
-
?
|
|
91
|
+
? localAdmissionToken(options.admissionConfig, options.env ?? process.env)
|
|
101
92
|
: portOrTarget.admissionToken;
|
|
102
93
|
if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
|
|
103
94
|
|
|
104
95
|
const baseUrl = typeof portOrTarget === "number"
|
|
105
|
-
?
|
|
96
|
+
? localInferenceDestination(options.admissionConfig, portOrTarget).origin
|
|
106
97
|
: new URL(portOrTarget.baseUrl).origin;
|
|
107
98
|
|
|
108
99
|
// ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -132,6 +132,34 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
132
132
|
json: "envelope",
|
|
133
133
|
details: ["Reads /healthz plus local config; drives no management API route."],
|
|
134
134
|
},
|
|
135
|
+
{
|
|
136
|
+
command: ["hub", "invite"],
|
|
137
|
+
summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
|
|
138
|
+
// Deliberately empty. The command DOES drive `POST /api/gui/pairing-grants` -- the attested
|
|
139
|
+
// local mint route `ocx gui pair` uses, authorized by a capability HMAC'd with the running
|
|
140
|
+
// proxy's own attestation secret rather than by the admin token, which is why it needs
|
|
141
|
+
// nothing exported in the shell. That route is answered in the composition root, ahead of
|
|
142
|
+
// `handleManagementAPI`, so it is not in MANAGEMENT_ROUTES; declaring it here would fail the
|
|
143
|
+
// capability/registry reconciliation rather than inform anyone. Widening the registry's scope
|
|
144
|
+
// to `src/server/index.ts` is its own change.
|
|
145
|
+
routes: [],
|
|
146
|
+
flags: [
|
|
147
|
+
{ name: "--json", value: "boolean", summary: "Emit code, expiresAt, dataUrl, managementUrl, and command." },
|
|
148
|
+
{ name: "--data-url", value: "string", summary: "Advertise this data origin instead of hub.dataPublicOrigin or the bind address." },
|
|
149
|
+
{ name: "--management-url", value: "string", summary: "Confirm the management origin; it must equal hub.managementPublicOrigin." },
|
|
150
|
+
{ name: "--clients", value: "string", summary: "Pre-select codex and/or claude in the printed connect command." },
|
|
151
|
+
],
|
|
152
|
+
mutates: true,
|
|
153
|
+
json: "envelope",
|
|
154
|
+
details: [
|
|
155
|
+
"Hub only: refuses when runtimeRole is not hub, and requires a running attested proxy.",
|
|
156
|
+
"The code is secret, single-use and short-lived; it is bound to hub.managementPublicOrigin and to the connecting machine's loopback browser origin.",
|
|
157
|
+
"The bound browser origin is always printed; when it is not http://localhost:10100 the warning names the port the connecting machine must use.",
|
|
158
|
+
"Refuses when the advertised data origin would be loopback (a loopback or wildcard bind with no hub.dataPublicOrigin and no --data-url) rather than printing a line that dials the other machine itself.",
|
|
159
|
+
"Prints no data-plane token. Remote machines receive their own revocable per-client key from the exchange.",
|
|
160
|
+
"Mints through the attested local pairing-grant route, the same one ocx gui pair uses; no admin token is read.",
|
|
161
|
+
],
|
|
162
|
+
},
|
|
135
163
|
{
|
|
136
164
|
command: ["connect", "rotate"],
|
|
137
165
|
summary: "Rotate the connected client's data key against the hub, with commit and abort.",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig } from "../types";
|
|
2
2
|
import { injectClaudeAgentDefs } from "../claude/agents-inject";
|
|
3
|
+
import { readCachedHubState } from "../client/hub-state";
|
|
3
4
|
import { fetchClaudeContextWindows } from "./claude";
|
|
4
5
|
import type { ReadinessGate } from "../server/readiness";
|
|
5
6
|
|
|
@@ -7,6 +8,30 @@ export interface ClaudeAgentStartupSyncDeps {
|
|
|
7
8
|
fetchContextWindows?: typeof fetchClaudeContextWindows;
|
|
8
9
|
injectAgentDefs?: typeof injectClaudeAgentDefs;
|
|
9
10
|
warn?: (message: string) => void;
|
|
11
|
+
/** Seam for the hub roster lookup; the default reads only the on-disk cache. */
|
|
12
|
+
readHubRoster?: (config: OcxConfig) => readonly string[] | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The hub's roster for a connected client, from the CACHE only (#4236).
|
|
17
|
+
*
|
|
18
|
+
* Startup deliberately makes no network call for this. The roster is a convenience here — `ocx
|
|
19
|
+
* claude` does the live read on the path where it matters — and a hub round trip on every proxy
|
|
20
|
+
* start would put an offline hub in the way of a local launch. Undefined falls back to local
|
|
21
|
+
* `subagentModels`, which is what this path has always used.
|
|
22
|
+
*/
|
|
23
|
+
function cachedHubRoster(config: OcxConfig): readonly string[] | undefined {
|
|
24
|
+
if (config.runtimeRole !== "client" || !config.client) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const cached = readCachedHubState({
|
|
27
|
+
serverUrl: config.client.serverUrl,
|
|
28
|
+
apiKeyId: config.client.apiKeyId,
|
|
29
|
+
connectedAt: config.client.connectedAt,
|
|
30
|
+
});
|
|
31
|
+
return cached?.state.subagentModels;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
10
35
|
}
|
|
11
36
|
|
|
12
37
|
/**
|
|
@@ -70,7 +95,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
|
|
|
70
95
|
// Startup remains best-effort. The next management mutation or `ocx claude` launch can
|
|
71
96
|
// restore context markers after a transient catalog/Management API failure.
|
|
72
97
|
}
|
|
73
|
-
return inject(config, windows);
|
|
98
|
+
return inject(config, windows, undefined, (deps.readHubRoster ?? cachedHubRoster)(config));
|
|
74
99
|
} catch (error) {
|
|
75
100
|
warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
101
|
return null;
|