From 10eca731964407e5b4a8f7ccb54917e119e25fe6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:47:32 +0900 Subject: [PATCH 1/4] fix(adapters): bound coding-agent tool state and linearize ID reminting CodeBuddy and Qoder share the coding-agent stream-json parser. It retained every tool_use argument fragment until the block closed without charging the request's translator budget, and the per-turn call ceiling was checked only after a block had been allocated and only when a CodeBuddy tool bridge was present. The parser now owns one admission check before allocation (16 starts, or the bridge's tighter limit), charges retained tool IDs, names and argument fragments to the shared budget, and releases every reservation on close, EOF, protocol error and abort. IDs the tool bridge keeps for deduplication after a block closes stay charged, one lease per ID, until turn cleanup. Budget overflow reports translation_buffer_limit and the call ceiling reports tool_call_limit. createToolCallIdReminter probed -2, -3, ... from the start for each repeat of an ID, which made a long run of duplicates quadratic, and siblings whose retained prefixes diverged at -9 could converge at -10. The reminter now keeps a next-suffix cursor per (suffix width, retained prefix) group, so no occupied candidate is probed twice. Carries #6081 and reimplements #6083. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/adapters/coding-agent/protocol.ts | 69 +++++++++++- src/adapters/coding-agent/turn.ts | 42 ++++---- .../openai-chat/tool-call-id-remint.ts | 13 ++- structure/providers-and-adapters.md | 12 ++- .../openai-chat-tool-call-id-remint.test.ts | 38 +++++++ tests/providers/codebuddy-protocol.test.ts | 101 ++++++++++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 57 +++++++++- tests/providers/qoder-adapter.test.ts | 24 +++++ 8 files changed, 325 insertions(+), 31 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 43c76ea29fb..ac7130db078 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -1,4 +1,5 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types"; +import type { TranslatorBudget } from "../../lib/translator-budget"; /** * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI). @@ -19,6 +20,8 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../.. export const MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024; /** Hard ceiling on the total stdout bytes consumed for one turn. */ export const MAX_STREAM_TOTAL_BYTES = 64 * 1024 * 1024; +/** Shared ceiling for upstream tool starts, including turns without a capture bridge. */ +export const MAX_TOOL_BLOCK_STARTS = 16; /** Hard ceiling on projected conversation history text (characters) to prevent runaway memory. */ export const MAX_PROJECTED_HISTORY_CHARS = 200_000; /** @@ -235,6 +238,12 @@ export interface StreamParseState { * when its own stop arrives, or when a new tool_use start reuses its index. */ openToolBlocks?: Map; + /** Shared request budget for tool identity and buffered argument fragments. */ + translatorBudget?: TranslatorBudget; + /** A capture bridge may impose a tighter ceiling than the shared parser limit. */ + maxToolBlockStarts?: number; + /** Set before an over-limit block can be allocated or emitted. */ + toolCallLimitExceeded?: boolean; /** Synthetic decreasing keys for tool_use start frames that omit the block index. */ nextSyntheticToolBlockKey?: number; /** Tool_use blocks opened in this stream, whether or not they have closed yet. */ @@ -245,6 +254,8 @@ export interface StreamParseState { strictToolBlockCapture?: boolean; /** Tool IDs already captured through partial events, for complete-assistant deduplication. */ partialToolCallIds?: Set; + /** One budget lease per ID retained by complete-assistant deduplication, released at turn cleanup. */ + partialToolCallBudgetIds?: string[]; /** A complete assistant tool block had no matching partial capture. */ uncapturedToolUse?: boolean; /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */ @@ -354,8 +365,12 @@ export interface OpenToolBlock { name: string; argParts: string[]; indexed: boolean; + /** Unique budget identity even when upstream reuses a public tool-call ID. */ + budgetCallId?: string; } +let nextBudgetCallOrdinal = 0; + /** Key a tool_use start frame by content-block index, falling back to a synthetic key. */ function toolBlockKey(state: StreamParseState, event: StreamMessage): number { const index = event.index; @@ -408,12 +423,23 @@ function closeToolBlock(state: StreamParseState, key: number, events: AdapterEve } } state.openToolBlocks.delete(key); + if (block.budgetCallId) state.translatorBudget?.closeCall(block.budgetCallId); events.push({ type: "tool_call_start", id: block.id, name: block.name }); for (const part of block.argParts) events.push({ type: "tool_call_delta", arguments: part }); events.push({ type: "tool_call_end" }); state.completedToolCalls = (state.completedToolCalls ?? 0) + 1; } +/** Release reservations left by EOF, failed decode, protocol error, or abort. */ +export function releaseOpenToolBlocks(state: StreamParseState): void { + for (const block of state.openToolBlocks?.values() ?? []) { + if (block.budgetCallId) state.translatorBudget?.closeCall(block.budgetCallId); + } + state.openToolBlocks?.clear(); + for (const leaseId of state.partialToolCallBudgetIds ?? []) state.translatorBudget?.closeCall(leaseId); + state.partialToolCallBudgetIds = undefined; +} + /** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): AdapterEvent[] { const events: AdapterEvent[] = []; @@ -454,7 +480,12 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt "Coding-agent CLI sent a tool argument delta that cannot be attributed to an open tool block.", ); } - if (block) block.argParts.push(partial); + if (block) { + state.translatorBudget?.chargeRetained(Buffer.byteLength(partial), { + kind: "tool_args", callId: block.budgetCallId, + }); + block.argParts.push(partial); + } } } return events; @@ -466,6 +497,11 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt const id = asString(block?.id) ?? ""; const name = asString(block?.name) ?? "tool"; if (id) { + const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); + if ((state.toolBlockStarts ?? 0) >= limit) { + state.toolCallLimitExceeded = true; + return events; + } const key = toolBlockKey(state, event); if (state.openToolBlocks?.has(key)) { // CodeBuddy reuses one content-block index for a parallel batch: every call in the @@ -475,14 +511,43 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt // block only after the capture path verifies its arguments form a complete object. closeToolBlock(state, key, events, true); } + const budget = state.translatorBudget; + const budgetCallId = budget ? `coding-agent:${++nextBudgetCallOrdinal}` : undefined; + if (budget && budgetCallId) { + budget.openCall(budgetCallId); + try { + // Bridge deduplication keeps the ID on the turn lease after this block closes. + budget.chargeRetained(Buffer.byteLength(name) + (state.partialToolCallIds ? 0 : Buffer.byteLength(id)), { + kind: "tool_args", callId: budgetCallId, + }); + } catch (error) { + budget.closeCall(budgetCallId); + throw error; + } + } (state.openToolBlocks ??= new Map()).set(key, { id, name, argParts: [], indexed: typeof event.index === "number" && Number.isInteger(event.index), + budgetCallId, }); state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1; - state.partialToolCallIds?.add(id); + if (state.partialToolCallIds && !state.partialToolCallIds.has(id)) { + if (budget) { + // A lease per ID: the per-call byte limit must not pool IDs from different calls. + const leaseId = `coding-agent-id:${++nextBudgetCallOrdinal}`; + budget.openCall(leaseId); + try { + budget.chargeRetained(Buffer.byteLength(id), { kind: "tool_args", callId: leaseId }); + } catch (error) { + budget.closeCall(leaseId); + throw error; + } + (state.partialToolCallBudgetIds ??= []).push(leaseId); + } + state.partialToolCallIds.add(id); + } } } return events; diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ba731b701d5..f60363108cb 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { isTranslatorBudgetExceededError } from "../../lib/translator-budget"; import { commandInvocation } from "../../lib/win-exec"; import { isStandaloneBinary } from "../../lib/standalone"; import { modelRecordValue } from "../../reasoning-effort"; @@ -10,9 +11,11 @@ import type { IncomingMeta } from "../base"; import { buildConversationInput, CodingAgentProtocolError, + MAX_TOOL_BLOCK_STARTS, mapStreamMessageToEvents, projectedHistoryCharLimit, readJsonLines, + releaseOpenToolBlocks, toolBridgeInitError, type StreamParseState, } from "./protocol"; @@ -369,6 +372,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise() : undefined, }; @@ -404,7 +410,6 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise toolCallStarts) { - // The parser buffers a block until its stop (or same-index replacement), so the - // per-turn limit must be checked when the block opens, not when its buffered - // tool_call_start is finally emitted. The init handshake is already gated above. - toolCallStarts = state.toolBlockStarts!; - if (toolCallStarts > toolBridge.maxTurnToolCalls) { - emitOnce({ - type: "error", - message: `Coding-agent CLI returned more than the ${toolBridge.maxTurnToolCalls}-tool-call turn limit.`, - status: 502, - errorType: "upstream_error", - code: "tool_call_limit", - retryable: false, - }); - failClosed = true; - kill(); - break; - } - } for (const event of mappedEvents) { if (toolBridge && !initValidated && event.type === "done") { emitOnce({ @@ -620,11 +618,13 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); @@ -671,7 +671,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise): (rawId: string) => string { const occupied = new Set(reservedIds); + const nextSuffixByWidthAndPrefix = new Map(); return rawId => { if (!occupied.has(rawId)) { occupied.add(rawId); @@ -25,16 +26,24 @@ export function createToolCallIdReminter(reservedIds: Iterable): (rawId: // A non-conforming source is sanitized, never dropped: the wire still needs an id, and the // occupied check below covers a sanitized form that now equals some other call's id. const base = isConformingToolCallId(rawId) ? rawId : rawId.replace(/[^a-zA-Z0-9_-]/g, "_"); - for (let n = 2; ; n++) { + for (let n = 2; ;) { // Hyphen, not underscore: an id that extends another id as `_` is parsed by // at least one client as a batch sub-call of ``, which pairs the second call's // result to the first call. A `-` suffix is in the same id family without that reading. const suffix = `-${n}`; - const candidate = base.slice(0, Math.max(1, MAX_TOOL_CALL_ID_LENGTH - suffix.length)) + suffix; + // A wider suffix retains less of the base, so siblings that were separate at -9 + // can converge at -10. Resume in the candidate's actual width/prefix domain. + const prefix = base.slice(0, Math.max(1, MAX_TOOL_CALL_ID_LENGTH - suffix.length)); + const cursorKey = `${suffix.length}:${prefix}`; + const next = nextSuffixByWidthAndPrefix.get(cursorKey); + if (next !== undefined && next > n) { n = next; continue; } + const candidate = prefix + suffix; + nextSuffixByWidthAndPrefix.set(cursorKey, n + 1); if (!occupied.has(candidate)) { occupied.add(candidate); return candidate; } + n++; } }; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index f92c4efc563..fc9089732dd 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -2,8 +2,11 @@ The coding-agent stream parser buffers each tool-use block by its content-block index and emits a complete start/delta/end sequence on closure. Distinct indices can interleave. -For the CodeBuddy capture-only bridge, the init handshake and turn-call limit are checked -when a block opens, before its buffered events can be emitted. A new start on an occupied +For the CodeBuddy capture-only bridge, the init handshake is checked before buffering. +The shared parser admits a valid-ID tool start before allocating its block, with a 16-call +ceiling for CodeBuddy and Qoder and any tighter bridge ceiling applied there. IDs, names, +and argument fragments charge the request's translator budget while buffered; closing, +replacement, and turn cleanup release those reservations. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty argument delta that cannot be attributed fails immediately. Turn completion @@ -11,7 +14,8 @@ requires every opened block to close, preserving the downstream single-open-call An indexless argument delta belongs to the sole open block; with multiple blocks open, the parser fails the turn before releasing their buffered calls. The capture-only bridge checks each raw tool-use start against the init handshake before -buffering; a later init cannot authorize a call that started earlier. +buffering; a later init cannot authorize a call that started earlier. The 8 MiB JSONL line +ceiling is independent of the retained tool-block budget. Direct MCP names emitted in a verified custom code-mode catalog follow the [Responses restoration boundary](transports/responses-wire-shapes.md#direct-mcp-calls-in-code-mode). @@ -123,7 +127,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | -| `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | +| `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | diff --git a/tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts b/tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts index c638fd86127..2cb1f915775 100644 --- a/tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts +++ b/tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts @@ -49,6 +49,44 @@ describe("createToolCallIdReminter", () => { expect(new Set(ids).size).toBe(3); }); + test("thousands of repeats use a bounded number of occupied-set probes", () => { + const remint = createToolCallIdReminter([]); + const originalHas = Set.prototype.has; + let probes = 0; + Set.prototype.has = function (value) { probes++; return originalHas.call(this, value); }; + const ids: string[] = []; + try { + for (let i = 0; i < 2_000; i++) ids.push(remint("call-0-0")); + } finally { + Set.prototype.has = originalHas; + } + expect(ids[0]).toBe("call-0-0"); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.every(id => id.length <= MAX_TOOL_CALL_ID_LENGTH)).toBe(true); + expect(probes).toBeLessThan(4_100); + }); + + test("62-character sibling IDs share a cursor when suffix width grows", () => { + const rawIds = [..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"].slice(0, 40) + .map(char => "p".repeat(61) + char); + const remint = createToolCallIdReminter([]); + const originalHas = Set.prototype.has; + let probes = 0; + Set.prototype.has = function (value) { probes++; return originalHas.call(this, value); }; + const emitted: string[] = []; + try { + for (let round = 0; round < 20; round++) { + for (const rawId of rawIds) emitted.push(remint(rawId)); + } + } finally { + Set.prototype.has = originalHas; + } + expect(emitted.slice(0, rawIds.length)).toEqual(rawIds); + expect(new Set(emitted).size).toBe(emitted.length); + expect(emitted.every(id => id.length <= MAX_TOOL_CALL_ID_LENGTH)).toBe(true); + expect(probes).toBeLessThan(4_000); + }); + test("skips a suffix the reserved set already occupies", () => { const remint = createToolCallIdReminter(["call-0-0", "call-0-0-2"]); diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts index 94f33c5c2c0..f530af382c1 100644 --- a/tests/providers/codebuddy-protocol.test.ts +++ b/tests/providers/codebuddy-protocol.test.ts @@ -7,10 +7,12 @@ import { mapStreamMessageToEvents, projectedHistoryCharLimit, readJsonLines, + releaseOpenToolBlocks, type StreamParseState, usageFromResult, } from "../../src/adapters/coding-agent/protocol"; import type { OcxParsedRequest } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; // The stream-json protocol for coding-agent CLIs // (src/adapters/coding-agent/protocol.ts); these fixtures exercise it via CodeBuddy frames. @@ -256,6 +258,105 @@ describe("codebuddy stream-json event mapping", () => { expect(state.openToolBlocks?.size ?? 0).toBe(0); }); + test("charges identity and interleaved fragments, then releases each closed block", () => { + const translatorBudget = createTestTranslatorBudget({ maxCallArgumentBytes: 12 }); + const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, translatorBudget }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + feed({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "id1", name: "exec" } }); + feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "id2", name: "exec" } }); + feed({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 16, activeCalls: 2 }); + feed({ type: "content_block_stop", index: 1 }); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 7, activeCalls: 1 }); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("closed bridge tool IDs remain charged until turn cleanup and can exhaust the budget", () => { + const translatorBudget = createTestTranslatorBudget({ maxTurnBytes: 22 }); + const state: StreamParseState = { + sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, + translatorBudget, partialToolCallIds: new Set(), + }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + for (const [index, id] of ["abcdefgh", "ijklmnop"].entries()) { + feed({ type: "content_block_start", index, content_block: { type: "tool_use", id, name: "x" } }); + feed({ type: "content_block_stop", index }); + } + expect(state.partialToolCallIds?.size).toBe(2); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 16, activeCalls: 2 }); + expect(() => feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "qrstuvwx", name: "x" } })) + .toThrow("translator tool_args buffer exceeded 22 bytes"); + expect(translatorBudget.snapshot().overflows).toBe(1); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("retained bridge IDs are leased per call, so the per-call limit never pools them", () => { + const translatorBudget = createTestTranslatorBudget({ maxCallArgumentBytes: 12 }); + const state: StreamParseState = { + sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, + translatorBudget, partialToolCallIds: new Set(), + }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + for (const [index, id] of ["abcdefgh", "ijklmnop", "qrstuvwx"].entries()) { + feed({ type: "content_block_start", index, content_block: { type: "tool_use", id, name: "x" } }); + feed({ type: "content_block_stop", index }); + } + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 24, activeCalls: 3, overflows: 0 }); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("rejects an over-budget fragment before retention and releases on cleanup", () => { + const translatorBudget = createTestTranslatorBudget({ maxCallArgumentBytes: 9 }); + const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, translatorBudget }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + feed({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "i", name: "exec" } }); + feed({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "1234" } }); + expect(() => feed({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "5" } })).toThrow("translator tool_args buffer exceeded 9 bytes"); + expect(state.openToolBlocks?.get(1)?.argParts).toEqual(["1234"]); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + }); + + test("same-index replacement releases the previous identity and arguments", () => { + const translatorBudget = createTestTranslatorBudget(); + const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, translatorBudget, strictToolBlockCapture: true }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "first", name: "exec" } }); + feed({ type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{}" } }); + expect(feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "next", name: "exec" } }).map(e => e.type)) + .toEqual(["tool_call_start", "tool_call_delta", "tool_call_end"]); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 8, activeCalls: 1 }); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("failed same-index replacement keeps its reservation until error cleanup", () => { + const translatorBudget = createTestTranslatorBudget(); + const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, translatorBudget, strictToolBlockCapture: true }; + const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); + feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "first", name: "exec" } }); + feed({ type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{" } }); + expect(() => feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "next", name: "exec" } })) + .toThrow("incomplete JSON arguments"); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 10, activeCalls: 1 }); + releaseOpenToolBlocks(state); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("refuses the seventeenth valid start before allocation; empty IDs do not count", () => { + const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, maxToolBlockStarts: 16 }; + const feed = (index: number, id: string) => mapStreamMessageToEvents({ type: "stream_event", event: { type: "content_block_start", index, content_block: { type: "tool_use", id, name: "exec" } } }, state); + expect(feed(-1, "")).toEqual([]); + for (let i = 0; i < 16; i++) expect(feed(i, `id_${i}`)).toEqual([]); + expect(feed(16, "id_16")).toEqual([]); + expect(state.toolCallLimitExceeded).toBe(true); + expect(state.toolBlockStarts).toBe(16); + expect(state.openToolBlocks?.size).toBe(16); + }); + test("interleaved parallel tool_use blocks are serialized per block index", () => { const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record }, state); diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 51df4d0d89d..254a09f012d 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -66,8 +66,8 @@ function provider(): OcxProviderConfig { } as OcxProviderConfig; } -function incoming() { - return { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; +function incoming(translatorBudget = createTestTranslatorBudget()) { + return { headers: new Headers(), translatorBudget }; } async function run(adapter: ReturnType, p: OcxParsedRequest): Promise { @@ -837,4 +837,57 @@ describe("CodeBuddy capture-only tool bridge turn", () => { expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_limit", status: 502, retryable: false }); expect(events.some(e => e.type === "tool_call_start" || e.type === "done")).toBe(false); }); + + test("an empty-ID start does not consume a turn slot", async () => { + const p = parsed([tool("exec")]); + const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const frames: unknown[] = [INIT_OK, toolUseStart(cliName, "")]; + for (let i = 0; i < 16; i++) frames.push(toolUseStart(cliName, `valid_${i}`), inputJsonDelta("{}"), BLOCK_STOP); + frames.push(MESSAGE_STOP); + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines(frames)) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use" }); + expect(events.filter(e => e.type === "tool_call_start")).toHaveLength(16); + }); + + test("argument overflow fails before emission and releases the parser reservation", async () => { + const p = parsed([tool("exec")]); + const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const translatorBudget = createTestTranslatorBudget({ maxCallArgumentBytes: Buffer.byteLength(cliName) + 4 }); + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines([INIT_OK, toolUseStart(cliName), inputJsonDelta("1234"), inputJsonDelta("5")])) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, incoming(translatorBudget), e => events.push(e)); + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit", status: 502, retryable: false }); + expect(events.some(e => e.type === "tool_call_start" || e.type === "done")).toBe(false); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + }); + + test("aborting with an open block releases its identity and argument reservation", async () => { + const p = parsed([tool("exec")]); + const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const controller = new AbortController(); + const translatorBudget = createTestTranslatorBudget(); + const charge = translatorBudget.chargeRetained.bind(translatorBudget); + let charges = 0; + translatorBudget.chargeRetained = (bytes, scope) => { + charge(bytes, scope); + if (++charges === 2) queueMicrotask(() => controller.abort()); + }; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines([INIT_OK, toolUseStart(cliName), inputJsonDelta("x")])) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, { ...incoming(translatorBudget), abortSignal: controller.signal }, e => events.push(e)); + expect(events.at(-1)).toMatchObject({ type: "error", retryable: false }); + expect(controller.signal.aborted).toBe(true); + expect(charges).toBe(2); + expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); }); diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 5d0d1e7732a..908b173f8e7 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -161,4 +161,28 @@ describe("qoder adapter", () => { await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); expect(events.at(-1)).toMatchObject({ type: "error", status: 429, errorType: "insufficient_quota", code: "insufficient_quota", retryable: false }); }); + + test("shared parser refuses Qoder's seventeenth tool start", async () => { + const frames = Array.from({ length: 17 }, (_, index) => JSON.stringify({ + type: "stream_event", event: { type: "content_block_start", index, content_block: { type: "tool_use", id: `id_${index}`, name: "exec" } }, + }) + "\n"); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => fakeChild(frames) }); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: budget }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_limit", status: 502 }); + expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("Qoder tool identity alone is charged to the shared budget", async () => { + const frame = JSON.stringify({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "abcd", name: "exec" } } }) + "\n"; + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => fakeChild([frame]) }); + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: 7 }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: budget }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit", status: 502 }); + expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + }); }); From 9f50bddfe841dde9773b14afdd8d34ba09c0ff06 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:47:33 +0900 Subject: [PATCH 2/4] fix(link): invoke sh bare and surface non-UTF-8 ssh errors A Windows OpenSSH server whose DefaultShell is PowerShell parsed the quoted 'sh' command name as a string expression and failed at the next token, and the Child-side runner then rejected the PowerShell error bytes (often a legacy code page) with a generic "ssh output was not valid UTF-8" instead of the real reason. quoteRemote now accepts exactly sh in command position and emits it bare; every argument stays single-quoted, NUL stays rejected, and any other command name throws LinkSshArgumentError. The runner caps stderr bytes before a replacement UTF-8 decode, so sshFailureHint can redact and bound the real diagnostic. Structured stdout stays strict UTF-8. Fixes #6088. --- .../src/content/docs/guides/remote-link.md | 2 +- src/link/ssh-argv.ts | 9 +- src/link/ssh-runner.ts | 10 +-- structure/remote-link.md | 4 +- tests/clients/link-ssh-argv.test.ts | 88 +++++++++++++++++-- 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 17bf414b3cb..9cb6bf7d023 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -64,7 +64,7 @@ To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disc ## Troubleshooting -When a step fails, the dashboard shows the reason and, when SSH reported one, the last line of its error output under the message. +When a step fails, the dashboard shows the reason and, when SSH reported one, a short sanitized hint from its last non-empty error line under the message. Remote-shell errors can appear there even when the remote shell emits non-UTF-8 text; OpenCodex removes terminal controls, link keys and URL queries and limits the hint's length. - **Could not connect to the SSH host**: the host must accept your SSH key without a password prompt; `ssh -o BatchMode=yes true` must succeed from a terminal. A `ProxyCommand` helper such as `cloudflared` must be installed in `/opt/homebrew/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.local/bin` or another directory on the PATH OpenCodex runs with. - **ocx was not found on the remote computer**: OpenCodex looks for `ocx` on the PATH of a non-interactive SSH session first, then in `~/.bun/bin`, `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`. If it is installed elsewhere, add that directory to PATH in a file the remote shell reads for non-interactive sessions, such as `~/.zshenv` for zsh. diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index e9a82bc827c..5b779432556 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -114,7 +114,7 @@ export function buildTunnelArgv(options: TunnelArgvOptions): string[] { export interface ExecArgvOptions { alias: string; - /** Remote argv. Each element is quoted for the remote POSIX shell. */ + /** Remote argv. The command must be sh; its arguments are quoted for the remote shell. */ argv: readonly string[]; knownHostsFile: string; } @@ -177,10 +177,13 @@ export function buildResolveArgv(alias: string): string[] { return ["ssh", "-G", "--", assertSshAlias(alias)]; } -/** Quote argv for a POSIX remote shell: every element single-quoted, embedded quotes escaped. */ +/** Emit only sh in command position; single-quote every argument for the remote shell. */ export function quoteRemote(argv: readonly string[]): string { - return argv.map(arg => { + if (argv[0] !== "sh") throw new LinkSshArgumentError("remote command must be sh"); + return argv.map((arg, index) => { if (arg.includes("\0")) throw new LinkSshArgumentError("remote argument contains NUL"); + // PowerShell treats a leading quoted word as an expression, not a command invocation. + if (index === 0) return "sh"; return `'${arg.replaceAll("'", `'"'"'`)}'`; }).join(" "); } diff --git a/src/link/ssh-runner.ts b/src/link/ssh-runner.ts index 995de65ee99..4aa58ed4cd2 100644 --- a/src/link/ssh-runner.ts +++ b/src/link/ssh-runner.ts @@ -93,7 +93,7 @@ export class SshRunnerError extends Error { } } -async function readOutput(stream: ReadableStream, maxBytes: number, kill?: () => void): Promise { +async function readOutput(stream: ReadableStream, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let total = 0; @@ -118,7 +118,7 @@ async function readOutput(stream: ReadableStream, maxBytes: number, offset += chunk.byteLength; } try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return new TextDecoder("utf-8", { fatal: fatalUtf8 }).decode(bytes); } catch (error) { throw new SshRunnerError("decode", "ssh output was not valid UTF-8", { cause: error }); } @@ -150,7 +150,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error }); } const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM")); - const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM")); + const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false); void stdout.catch(() => {}); void stderr.catch(() => {}); return { @@ -181,8 +181,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error }); } - const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM")); - const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM")); + const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM")); + const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"), false); if (options.stdin !== undefined) { try { const input = child.stdin; diff --git a/structure/remote-link.md b/structure/remote-link.md index c2bc2f3b03b..cfa1e878c26 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -4,7 +4,7 @@ `src/link/ssh-argv.ts` builds every OpenSSH argument vector. All commands run with BatchMode and trust only the link known_hosts file, keyed by `HostKeyAlias=`: the global file is disabled with `GlobalKnownHostsFile=none`, and `KnownHostsCommand=none`, `VerifyHostKeyDNS=no` and `CheckHostIP=no` shut out every other source of host-key trust. Command-line options take precedence over `~/.ssh/config`, so a user config cannot re-enable them. Tunnel and exec commands use `StrictHostKeyChecking=yes`; only the probe uses `accept-new`, against an empty temporary file, so an offered key can be shown before it is trusted. The known_hosts path must be absolute and free of ssh expansion syntax. Forwards bind 127.0.0.1 on both ends, and aliases that could be parsed as options are refused. -Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a PATH prelude that appends `$HOME/.bun/bin`, `$HOME/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin` after the remote PATH and then `exec ocx "$@"`. A non-interactive ssh session reads no interactive profile, so without the fallbacks an ocx installed by Bun or Homebrew is not found; because they come last, an ocx the remote PATH already resolves keeps winning. `quoteRemote` single-quotes the script, so the login shell passes it through and `$HOME` and `$PATH` expand in the remote `sh`; the arguments reach ocx without another round of parsing. A remote exit status of 127 means ocx was not found and maps to `remote_ocx_missing`. +Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a PATH prelude that appends `$HOME/.bun/bin`, `$HOME/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin` after the remote PATH and then `exec ocx "$@"`. A non-interactive ssh session reads no interactive profile, so without the fallbacks an ocx installed by Bun or Homebrew is not found; because they come last, an ocx the remote PATH already resolves keeps winning. `quoteRemote` accepts only `sh` in command position and emits it bare, so a PowerShell SSH default shell parses a command invocation; any other command name gets `LinkSshArgumentError`. It single-quotes every argument, including the script, so `$HOME` and `$PATH` expand only in the invoked `sh` and the arguments reach ocx without another round of parsing. A remote exit status of 127 means ocx was not found and maps to `remote_ocx_missing`. `src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. @@ -38,7 +38,7 @@ A successful join restarts this proxy (a 503 drain of up to a minute while runni A connected Child answers `GET` and `HEAD /api/link/status` on its own listener for a GUI session: `src/client/link-status.ts` projects the client sidecar and the tunnel supervisor into the K16 document with `role: "child"`, the listener off, no links and the child row, plus `joinAvailable: false`. A Home-initiated Child has no sidecar and reports `child: null`. In link mode `/api/machine/status` advertises the machine origin as the shared plane, because the tunnel's hub-link ingress serves no `/api/*` and no session bootstrap. -`confirm-host` requires the remote `ocx --version` to print `opencodex ..` of at least 2.66.0, the first release with `ocx link`. The version is parsed to a bounded semver shape: each number has at most nine digits, an optional pre-release and build of at most 64 identifier characters each follow, and the token must end there. An older version answers `409 remote_ocx_outdated`, output that does not start with such a line (a usage banner, or a version with anything else attached) answers `502 remote_ocx_unrecognized`, and exit 127 answers `502 remote_ocx_missing`. Every refusal restores the link known_hosts file and keeps the pending probe, so a retry within the probe TTL needs no new probe. Link error bodies may carry `error.hint`, one line from one of three sources: the last non-empty ssh stderr line with terminal escapes removed, the ssh runner's own spawn, timeout or output-limit failure, or, for `remote_ocx_outdated`, `opencodex ` built only from the bounded version match. Every hint then has control and bidi characters removed, OpenCodex secrets and URL queries redacted, and is capped at 160 code points, cut between code points so a surrogate pair is never split. Hints never come from stdin and are never logged. +`confirm-host` requires the remote `ocx --version` to print `opencodex ..` of at least 2.66.0, the first release with `ocx link`. The version is parsed to a bounded semver shape: each number has at most nine digits, an optional pre-release and build of at most 64 identifier characters each follow, and the token must end there. An older version answers `409 remote_ocx_outdated`, output that does not start with such a line (a usage banner, or a version with anything else attached) answers `502 remote_ocx_unrecognized`, and exit 127 answers `502 remote_ocx_missing`. Every refusal restores the link known_hosts file and keeps the pending probe, so a retry within the probe TTL needs no new probe. Link error bodies may carry `error.hint`, one line from one of three sources: the last non-empty ssh stderr line with terminal escapes removed, the ssh runner's own spawn, timeout or output-limit failure, or, for `remote_ocx_outdated`, `opencodex ` built only from the bounded version match. Stderr bytes are capped before UTF-8 replacement decoding; structured stdout stays strict UTF-8. Every hint then has control and bidi characters removed, OpenCodex secrets and URL queries redacted, and is capped at 160 code points, cut between code points so a surrogate pair is never split. Hints never come from stdin and are never logged. ## Tunnels, management and CLI diff --git a/tests/clients/link-ssh-argv.test.ts b/tests/clients/link-ssh-argv.test.ts index 8d17fe617da..1cb14640700 100644 --- a/tests/clients/link-ssh-argv.test.ts +++ b/tests/clients/link-ssh-argv.test.ts @@ -64,11 +64,11 @@ test("tunnel argv uses a loopback forward and the confirmed host-key policy", () } }); -test("exec argv quotes the remote command and clears forwarding", () => { +test("exec argv emits only sh bare, quotes arguments, and clears forwarding", () => { const knownHostsFile = tempPath("exec"); const argv = buildExecArgv({ alias: "beta.example.test", - argv: ["printf", "it's ready"], + argv: ["sh", "it's ready"], knownHostsFile, }); expect(argv).toContain("-T"); @@ -76,7 +76,7 @@ test("exec argv quotes the remote command and clears forwarding", () => { expectCommonTrustOptions(argv, "yes", knownHostsFile); expect(argv).toContain("ClearAllForwardings=yes"); expect(argv.slice(-3, -1)).toEqual(["--", "beta.example.test"]); - expect(argv[argv.length - 1]).toBe(`'printf' 'it'"'"'s ready'`); + expect(argv[argv.length - 1]).toBe(`sh 'it'"'"'s ready'`); }); test("probe argv uses accept-new only with its temporary known_hosts file", () => { @@ -130,16 +130,36 @@ test("known_hosts option paths are absolute and safely quoted", () => { } }); -test("quoteRemote escapes single quotes and rejects NUL", () => { - expect(quoteRemote(["it's"])).toBe(`'it'"'"'s'`); - expect(() => quoteRemote(["bad\0argument"])).toThrow(LinkSshArgumentError); +test("quoteRemote allows only sh in command position and rejects NUL", () => { + expect(quoteRemote(["sh", "it's", "-c"])).toBe(`sh 'it'"'"'s' '-c'`); + for (const command of ["", "printf", "1", ".", "-x", "sh;echo bad", "sh\n", "sh\0bad"]) { + expect(() => quoteRemote([command, "safe"])).toThrow(LinkSshArgumentError); + } + expect(() => quoteRemote(["sh", "bad\0argument"])).toThrow(LinkSshArgumentError); }); test("remote ocx argv runs ocx through a single-quoted sh PATH prelude", () => { expect(REMOTE_OCX_SCRIPT).toBe('PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"'); expect(remoteOcxArgv(["link", "port"])).toEqual(["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", "link", "port"]); const argv = buildExecArgv({ alias: "delta.example.test", argv: remoteOcxArgv(["link", "issue", "--alias", "it's x", "--json"]), knownHostsFile: tempPath("remote-ocx") }); - expect(argv.at(-1)).toBe(`'sh' '-c' 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"' 'ocx' 'link' 'issue' '--alias' 'it'"'"'s x' '--json'`); + expect(argv.at(-1)).toBe(`sh '-c' 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"' 'ocx' 'link' 'issue' '--alias' 'it'"'"'s x' '--json'`); +}); + +test.skipIf(process.platform !== "win32")("PowerShell parses the remote command as sh invocation", () => { + const remote = quoteRemote(remoteOcxArgv(["link", "port"])); + const script = [ + "$tokens = $null; $errors = $null", + "$ast = [System.Management.Automation.Language.Parser]::ParseInput($env:OCX_REMOTE_COMMAND, [ref]$tokens, [ref]$errors)", + "if ($errors.Count -ne 0) { Write-Error ($errors | Out-String); exit 1 }", + "$commands = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true))", + "if ($commands.Count -ne 1 -or $commands[0].GetCommandName() -cne 'sh') { Write-Error 'remote command did not dispatch sh'; exit 1 }", + "Write-Output $commands[0].GetCommandName()", + ].join("; "); + const result = Bun.spawnSync(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], { + env: { ...process.env, OCX_REMOTE_COMMAND: remote }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe("sh"); }); function fakeOcx(dir: string, label: string): void { @@ -170,6 +190,19 @@ test.skipIf(process.platform === "win32")("an ocx the remote PATH already resolv expect(result.stdout.toString().split("\n")[0]).toBe("first"); }); +test.skipIf(process.platform === "win32")("the constructed remote command preserves POSIX argument bytes", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-link-remote-bytes-")); + roots.push(home); + const bin = join(home, ".bun", "bin"); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, "ocx"), "#!/bin/sh\nprintf '%s\\0' \"$@\"\n", { mode: 0o755 }); + const args = ["it's x", "two\nlines", "火🔥", "x;$(echo no)", ""]; + const remote = quoteRemote(remoteOcxArgv(args)); + const result = Bun.spawnSync(["/bin/sh", "-c", remote], { env: { HOME: home, PATH: "/usr/bin:/bin" } }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toEqual(new TextEncoder().encode(args.join("\0") + "\0")); +}); + test("ssh PATH appends helper directories once and leaves Windows untouched", () => { expect(linkSshPath({ PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: "/Users/test" }, "darwin")) .toBe("/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:/Users/test/.bun/bin:/Users/test/.local/bin"); @@ -190,6 +223,47 @@ function fakeSpawn(captured: Array>): typeof Bun.spawn { }) as unknown as typeof Bun.spawn; } +function byteSpawn(stdoutBytes: Uint8Array, stderrBytes: Uint8Array, capturedStdin: Uint8Array[] = []): typeof Bun.spawn { + const stream = (bytes: Uint8Array) => new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }); + return ((_argv: string[], _options: Record) => ({ + pid: 7, + stdout: stream(stdoutBytes), + stderr: stream(stderrBytes), + stdin: { async write(value: string | Uint8Array) { capturedStdin.push(typeof value === "string" ? new TextEncoder().encode(value) : value); }, async end() {} }, + exited: Promise.resolve(1), + kill() {}, + })) as unknown as typeof Bun.spawn; +} + +test("runner decodes capped non-UTF-8 stderr for a bounded redacted hint and keeps key stdin separate", async () => { + const secret = `ocx_data_${"a".repeat(40)}`; + const stderr = new Uint8Array([ + ...new TextEncoder().encode(`noise\nssh: ${secret} https://example.test/path?key=hidden `), + 0xa1, 0xad, + ...new TextEncoder().encode("\n"), + ]); + const stdin: Uint8Array[] = []; + const runner = createSshRunner({ spawn: byteSpawn(new TextEncoder().encode(""), stderr, stdin) }); + const result = await runner.run(["ssh"], { stdin: secret }); + expect(result.stderr).toContain("\ufffd"); + const hint = sshFailureHint(result.stderr); + expect(hint).toContain("ssh: ocx_data_[redacted] https://example.test/path"); + expect(hint).not.toContain(secret); + expect(hint).not.toContain("key=hidden"); + expect(Array.from(hint ?? "").length).toBeLessThanOrEqual(160); + expect(stdin).toEqual([new TextEncoder().encode(secret)]); +}); + +test("runner still rejects invalid UTF-8 stdout", async () => { + const runner = createSshRunner({ spawn: byteSpawn(new Uint8Array([0xa1, 0xad]), new TextEncoder().encode("diagnostic")) }); + await expect(runner.run(["ssh"])).rejects.toMatchObject({ code: "decode" }); +}); + +test("runner enforces stderr byte limit before replacement decoding", async () => { + const runner = createSshRunner({ spawn: byteSpawn(new Uint8Array(), new Uint8Array([0xa1, 0xad])) }); + await expect(runner.run(["ssh"], { maxOutputBytes: 1 })).rejects.toMatchObject({ code: "output_limit" }); +}); + test("the runner spawns commands and tunnels with the augmented environment", async () => { const captured: Array> = []; const runner = createSshRunner({ spawn: fakeSpawn(captured), env: () => linkSshSpawnEnv({ PATH: "/usr/bin:/bin", HOME: "/h" }, "darwin") }); From f1f903bf0dcc40b5eb9d2a138a8080a24aed4bab Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:47:33 +0900 Subject: [PATCH 3/4] fix(cli): keep a second-home proxy off a live proxy's client config Starting a second proxy with its own OPENCODEX_HOME while another proxy was running (for example the default home on 10100) rewrote the shared ~/.grok/config.toml opencodex base_url, and the same class of Codex and Claude startup sync, to the second proxy's port, then stripped or left it on exit. Owner discovery only read the second home's own records and configured port, so the existing one-way sibling mark was never set. ocx start now runs a cross-home owner check when same-home discovery finds no owner, before journal recovery and before any client write, and again under the bind lease. It reads the default home's runtime-port.json (only when the resolved home differs), the managed Grok base_url and every OpenCodex-owned Codex routing target (including the default Design B root openai_base_url), keeps loopback URLs with an explicit port, and identity probes each port. A live OpenCodex whose reported PID is a positive integer other than this process sets the existing sibling mark, so every writer that honors it stays off the shared files. A foreign, stale, remote or pid-less answer grants nothing, so a lone custom-home instance still syncs. Journal recovery now runs only after that decision and never for a sibling. The Claude agent roster startup sync and the ensure-time Grok fence, which did not consult the mark, now skip for a sibling. ocx ensure makes the same decision for its own process, because the mark set by the start child it spawns is process-local; an ensure that finds this home's own sibling proxy live honors the siblingOfPort that proxy published, even while the original owner is restarting. Hints are read the way their writers read them: Codex through the 1 MiB bounded reader, Grok through a 16 MiB bounded read. Explicit ocx sync and ocx grok apply are unchanged. --- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 7 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- scripts/test-layout/layout.json | 1 + src/cli/claude-agent-startup-sync.ts | 2 + src/cli/cross-home-owner.ts | 138 ++++++++ src/cli/ensure-desired-integrations.ts | 5 + src/cli/index.ts | 33 +- structure/clients/integrations.md | 2 +- structure/codex-home.md | 5 +- structure/runtime.md | 2 +- .../claude-agent-startup-sync.test.ts | 17 +- tests/cli/cli-dispatch.test.ts | 4 +- tests/cli/hub-gated-local-clients.test.ts | 14 + tests/cli/sibling-home-client-sync.test.ts | 332 ++++++++++++++++++ .../clients/sync-client-integrations.test.ts | 3 + tests/fixtures/test-layout-expected.json | 1 + 19 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 src/cli/cross-home-owner.ts create mode 100644 tests/cli/sibling-home-client-sync.test.ts diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index c56f93e2d4b..63e488efb9d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -15,7 +15,7 @@ description: セットアップ、開始、停止、サービス、診断、同 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -プロキシ サーバー (優先ポート `10100`) を起動します。PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。優先ポートが使用中の場合、`start` はそのポートを使用しているプロセスを確認して、どちらの場合も停止します。opencodex が応答していれば起動を拒否し、それ以外は使用しているプロセスを特定できないと報告します。最初のプロキシを実行したまま Codex を 2 番目のプロキシへ向けることになるため、自動でリスナーを別のポートへ移すことはありません。同じ `OPENCODEX_HOME` では別の `--port` を明示しても拒否されます。監視のみの構成も上限を適用する構成も同じ支出ジャーナルへ書き込むためです。独立した sibling には別の `OPENCODEX_HOME` を使用してください。`port: 0` はポートだけを OS に割り当てさせ、状態を分離しません。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。既に稼働中のプロキシの横で起動した sibling は、`ocx stop` やシグナルで停止した場合も含めてそのどちらも行わず、自身のポートで直接のリクエストを処理するだけで、Codex、Grok、Claude は既に稼働していたプロキシを指したままになります。 +プロキシ サーバー (優先ポート `10100`) を起動します。PID/ランタイムポートの状態を書き込み、2 番目のライブインスタンスの起動を拒否します。優先ポートが使用中の場合、`start` はそのポートを使用しているプロセスを確認して、どちらの場合も停止します。opencodex が応答していれば起動を拒否し、それ以外は使用しているプロセスを特定できないと報告します。最初のプロキシを実行したまま Codex を 2 番目のプロキシへ向けることになるため、自動でリスナーを別のポートへ移すことはありません。同じ `OPENCODEX_HOME` では別の `--port` を明示しても拒否されます。監視のみの構成も上限を適用する構成も同じ支出ジャーナルへ書き込むためです。独立した sibling には別の `OPENCODEX_HOME` を使用してください。`port: 0` はポートだけを OS に割り当てさせ、状態を分離しません。開始時に、各プロバイダーのモデルを Codex のカタログに同期します。マネージド サービス (`OCX_SERVICE=1`) として起動されていない限り、シャットダウン時にネイティブ Codex が復元されます。既に稼働中のプロキシの横で起動した sibling は、`ocx stop` やシグナルで停止した場合も含めてそのどちらも行わず、自身のポートで直接のリクエストを処理するだけで、Codex、Grok、Claude は既に稼働していたプロキシを指したままになります。 別の `OPENCODEX_HOME` からの起動時は、既定ホームのランタイム記録と管理対象の Grok・Codex ループバック接続先から稼働中の所有者を確認します。所有者のいない単独のカスタムホームは通常どおり同期し、明示的な `ocx sync` と `ocx grok apply` も利用できます。 `--socks5`(デフォルト `127.0.0.1:10808`)は SOCKS5 URL を `config.proxy` に保存し、送信 HTTP(S) リクエストを実際の SOCKS5 トンネル経由で送信します。`--socks5-off` は保存された SOCKS5 プロキシだけを削除し、HTTP プロキシは削除しません。値は設定に保存されるため、`ocx update` 後も保持されます。URL にユーザー名とパスワードを含めることはできますが、起動ログでは非表示になります。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 74e4abca98d..0d3f98f1010 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -28,7 +28,7 @@ Codex 자동 시작 shim도 설치합니다. 않습니다. 시작할 때는 각 공급자의 모델을 Codex 카탈로그로 동기화합니다. 종료할 때는 기본 Codex를 복원합니다. 단, 관리형 서비스로 실행한 경우(`OCX_SERVICE=1`)는 예외입니다. 이미 실행 중인 프록시 옆에서 시작한 형제 인스턴스는 `ocx stop`이나 시그널로 멈출 때도 동기화와 복원을 하지 않고 자신의 -포트에서 직접 요청만 처리하며, Codex, Grok, Claude는 원래 실행 중이던 프록시를 계속 가리킵니다. +포트에서 직접 요청만 처리하며, Codex, Grok, Claude는 원래 실행 중이던 프록시를 계속 가리킵니다. 별도의 `OPENCODEX_HOME`에서 시작할 때는 기본 홈의 런타임 기록과 OpenCodex가 관리하는 Grok·Codex 루프백 주소로 활성 프록시를 확인합니다. 활성 소유자가 없는 단독 사용자 지정 홈은 평소대로 동기화하며, 명시적인 `ocx sync`와 `ocx grok apply`도 계속 사용할 수 있습니다. `--socks5`(기본값 `127.0.0.1:10808`)는 SOCKS5 URL을 `config.proxy`에 저장하고 실제 SOCKS5 터널을 통해 송신 HTTP(S) 요청을 전달합니다. `--socks5-off`는 저장된 SOCKS5 프록시만 지우며 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index da90092f9c5..94e8e742c07 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -29,8 +29,11 @@ independent sibling; `port: 0` only asks the OS for that instance's port and doe state. On start it syncs each provider's models into Codex's catalog. On shutdown it restores native Codex — unless it was launched as a managed service (`OCX_SERVICE=1`). A sibling started beside a running proxy does neither: it serves direct requests on its own port only, and Codex, -Grok and Claude stay pointed at the proxy that was already running. Stopping that sibling, with -`ocx stop` or a signal, leaves their configuration alone as well. While it runs, the proxy also +Grok and Claude stay pointed at the proxy that was already running. With a separate +`OPENCODEX_HOME`, startup checks the default home's runtime record and managed Grok and +Codex loopback destinations for a live opencodex owner before syncing. A custom home with +no live owner still syncs normally; explicit `ocx sync` and `ocx grok apply` remain available. +Stopping that sibling with `ocx stop` or a signal leaves their configuration alone as well. While it runs, the proxy also keeps Codex pointed at itself: when the opencodex routing in `~/.codex/config.toml` names another local port where no opencodex has answered for about 20 seconds (an instance that re-pointed it and then died, for example), the proxy re-points Codex at its own port and prints one warning. Codex diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 6f4ab51582d..3807ea6e227 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -15,7 +15,7 @@ description: 安装、启动、停止、服务、诊断、同步和更新命令 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -启动代理服务器(首选端口 `10100`)。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。当首选端口已被占用时,`start` 会探测占用者,并且无论结果如何都会停止:如果那里响应的是 opencodex,它会直接拒绝启动;否则会报告无法识别的占用者。它绝不会自行把监听地址移到其他端口,因为那会让第一个代理继续运行,并将 Codex 重新指向第二个代理。即使显式指定不同的 `--port`,共用同一个 `OPENCODEX_HOME` 时也会拒绝启动,因为仅观察模式和启用上限的模式都会写入同一个支出日志。独立的同级实例必须使用单独的 `OPENCODEX_HOME`;`port: 0` 只让操作系统分配端口,并不会隔离状态。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。在已运行的代理旁启动的同级实例两者都不做,即使通过 `ocx stop` 或信号停止也是如此:它只在自己的端口上处理直接请求,Codex、Grok 和 Claude 仍指向原本已在运行的代理。 +启动代理服务器(首选端口 `10100`)。它会写入 PID/运行时端口状态,并拒绝启动第二个存活实例。当首选端口已被占用时,`start` 会探测占用者,并且无论结果如何都会停止:如果那里响应的是 opencodex,它会直接拒绝启动;否则会报告无法识别的占用者。它绝不会自行把监听地址移到其他端口,因为那会让第一个代理继续运行,并将 Codex 重新指向第二个代理。即使显式指定不同的 `--port`,共用同一个 `OPENCODEX_HOME` 时也会拒绝启动,因为仅观察模式和启用上限的模式都会写入同一个支出日志。独立的同级实例必须使用单独的 `OPENCODEX_HOME`;`port: 0` 只让操作系统分配端口,并不会隔离状态。启动时,它会把每个提供方的模型同步到 Codex 的目录中。关闭时,它会恢复原生 Codex,除非它是作为受管服务启动的(`OCX_SERVICE=1`)。在已运行的代理旁启动的同级实例两者都不做,即使通过 `ocx stop` 或信号停止也是如此:它只在自己的端口上处理直接请求,Codex、Grok 和 Claude 仍指向原本已在运行的代理。 从另一个 `OPENCODEX_HOME` 启动时,它会根据默认主目录的运行时记录及受管理的 Grok、Codex 本地回环地址查找存活的所有者。没有存活所有者的单独自定义主目录仍会正常同步;显式执行的 `ocx sync` 和 `ocx grok apply` 也保持可用。 `--socks5`(默认 `127.0.0.1:10808`)会将 SOCKS5 URL 保存到 `config.proxy`,并通过真正的 SOCKS5 隧道转发出站 HTTP(S) 请求。`--socks5-off` 只会清除已保存的 SOCKS5 代理,不会删除 HTTP 代理。该值保存在配置中,因此会在 `ocx update` 后保留。URL 可以包含用户名和密码,但启动日志会将其隐藏。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 6bd593004ae..667ca5019a3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -15,7 +15,7 @@ description: 安裝、啟動、停止、服務、診斷、同步與更新指令 ### `ocx start [--port ] [--socks5 [host:port] | --socks5-off]` -啟動代理伺服器(偏好連接埠 `10100`)。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。偏好連接埠被佔用時,`start` 會探測佔用者,且無論結果如何都會停止:若回應的是 opencodex,它會直接拒絕啟動;否則會回報無法識別的佔用者。它絕不會自行將監聽位置移到其他連接埠,因為這會讓第一個代理繼續執行,並將 Codex 重新指向第二個代理。即使明確指定不同的 `--port`,共用同一個 `OPENCODEX_HOME` 時仍會拒絕啟動,因為僅觀察模式和啟用上限的模式都會寫入同一份支出日誌。獨立的同層實例必須使用不同的 `OPENCODEX_HOME`;`port: 0` 只讓作業系統指派連接埠,不會隔離狀態。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。在已執行的代理旁啟動的同層實例兩者皆不做,即使透過 `ocx stop` 或訊號停止也一樣:它只在自己的連接埠上處理直接請求,Codex、Grok 和 Claude 仍指向原本已在執行的代理。 +啟動代理伺服器(偏好連接埠 `10100`)。它寫入 PID/runtime-port 狀態,並拒絕啟動第二個即時實例。偏好連接埠被佔用時,`start` 會探測佔用者,且無論結果如何都會停止:若回應的是 opencodex,它會直接拒絕啟動;否則會回報無法識別的佔用者。它絕不會自行將監聽位置移到其他連接埠,因為這會讓第一個代理繼續執行,並將 Codex 重新指向第二個代理。即使明確指定不同的 `--port`,共用同一個 `OPENCODEX_HOME` 時仍會拒絕啟動,因為僅觀察模式和啟用上限的模式都會寫入同一份支出日誌。獨立的同層實例必須使用不同的 `OPENCODEX_HOME`;`port: 0` 只讓作業系統指派連接埠,不會隔離狀態。啟動時它將每個供應商的模型同步到 Codex 目錄。關閉時它還原原生 Codex——除非它是作為受管服務啟動的(`OCX_SERVICE=1`)。在已執行的代理旁啟動的同層實例兩者皆不做,即使透過 `ocx stop` 或訊號停止也一樣:它只在自己的連接埠上處理直接請求,Codex、Grok 和 Claude 仍指向原本已在執行的代理。 從另一個 `OPENCODEX_HOME` 啟動時,會依預設主目錄的執行階段記錄及受管理的 Grok、Codex 本機迴環位址檢查存活的擁有者。沒有存活擁有者的單獨自訂主目錄仍正常同步;明確執行的 `ocx sync` 和 `ocx grok apply` 也維持可用。 `--socks5`(預設 `127.0.0.1:10808`)會將 SOCKS5 URL 儲存到 `config.proxy`,並透過真正的 SOCKS5 通道轉送對外 HTTP(S) 請求。`--socks5-off` 只會清除已儲存的 SOCKS5 代理,不會刪除 HTTP 代理。此值儲存在設定中,因此會在 `ocx update` 後保留。URL 可以包含使用者名稱和密碼,但啟動記錄會隱藏它們。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2c1bcdb5fe5..4bae502f37f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -549,6 +549,7 @@ "cli-restore-back.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", + "sibling-home-client-sync.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts index 8dbe158ca88..8962d6cf74b 100644 --- a/src/cli/claude-agent-startup-sync.ts +++ b/src/cli/claude-agent-startup-sync.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { siblingOfLivePort } from "../codex/sibling-start"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { readCachedHubState } from "../client/hub-state"; import { fetchClaudeContextWindows } from "./claude"; @@ -77,6 +78,7 @@ export async function syncClaudeAgentDefsAtProxyStartup( port: number, deps: ClaudeAgentStartupSyncDeps = {}, ): Promise { + if (siblingOfLivePort() !== null) return null; const inject = deps.injectAgentDefs ?? injectClaudeAgentDefs; const warn = deps.warn ?? (message => console.warn(message)); diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts new file mode 100644 index 00000000000..78555e3af25 --- /dev/null +++ b/src/cli/cross-home-owner.ts @@ -0,0 +1,138 @@ +/** Best-effort discovery of a live proxy named by shared, OpenCodex-managed client state. */ +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { readRuntimePort } from "../config/process-state"; +import { getCodexHome } from "../codex/paths"; +import { detectCodexRoutingDrift } from "../codex/routing-drift"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { currentExternalCodexModelProvider } from "../codex/inject"; +import { reconcileJournal } from "../codex/journal"; +import { markSiblingStart, siblingOfLivePort } from "../codex/sibling-start"; +import { readClientConnectionState } from "../client/state"; +import { findManagedRegion, resolveGrokHome } from "../grok/inject"; +import { providerTableString } from "../codex/injected-marker"; +import { probePortOwner, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness"; + +const MAX_HINT_BYTES = 256 * 1024; +// Far above any real Grok config; discovery must not stall startup when Grok sync is off. +const MAX_GROK_CONFIG_BYTES = 16 * 1024 * 1024; + +/** Nonblocking open (a FIFO cannot stall startup), regular files only, capped at maxBytes. */ +function readBoundedRegularFile(path: string, maxBytes: number): string | null { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > maxBytes) return null; + const bytes = Buffer.alloc(stat.size + 1); + let count = 0; + while (count < bytes.length) { + const next = readSync(fd, bytes, count, bytes.length - count, count); + if (next === 0) break; + count += next; + } + return count > maxBytes || count > stat.size ? null : bytes.toString("utf8", 0, count); + } catch { + return null; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +function validPort(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 65535; +} + +function loopbackPort(raw: string | null): number | null { + if (!raw) return null; + try { + const url = new URL(raw); + if (!url.port || !["http:", "https:"].includes(url.protocol)) return null; + const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (!["127.0.0.1", "localhost", "::1"].includes(host)) return null; + const port = Number(url.port); + return validPort(port) ? port : null; + } catch { + return null; + } +} + +/** Returns only a different process with an identity-checked /healthz response. */ +export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Promise { + const candidates = new Set(); + const defaultHome = join(options.homeDir ?? homedir(), ".opencodex"); + if (resolve(getConfigDir()) !== resolve(defaultHome)) { + const raw = readBoundedRegularFile(join(defaultHome, "runtime-port.json"), MAX_HINT_BYTES); + if (raw) { + try { + const record: unknown = JSON.parse(raw); + if (record && typeof record === "object" && validPort((record as { port?: unknown }).port)) { + candidates.add((record as { port: number }).port); + } + } catch { /* stale or malformed hint */ } + } + } + + // Grok's writer reads its config in full; cap discovery separately so startup stays bounded. + const grok = readBoundedRegularFile(join(resolveGrokHome(), "config.toml"), MAX_GROK_CONFIG_BYTES); + const region = grok === null ? null : findManagedRegion(grok); + if (grok && region && !region.orphaned) { + const port = loopbackPort(providerTableString(grok.slice(region.start, region.end), "opencodex", "base_url")); + if (port !== null) candidates.add(port); + } + + try { + const codex = readBoundedCodexConfig(join(getCodexHome(), "config.toml")); + if (codex) { + const drift = detectCodexRoutingDrift(codex, { ownPorts: [] }); + if (drift.kind === "foreign") { + for (const target of drift.targets) { + // Every drift target is owned, loopback, and has an explicit port (including Design B roots). + candidates.add(target.port); + } + } + } + } catch { /* an absent or invalid client home is not owner evidence */ } + + for (const port of candidates) { + const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS); + if (owner && Number.isSafeInteger(owner.pid) && owner.pid! > 0 && owner.pid !== process.pid) return port; + } + return null; +} + +/** Mark this process before any shared-client write when another home owns the clients. */ +export async function markCrossHomeSibling(): Promise { + const port = await findCrossHomeOwner(); + if (port === null) return false; + markSiblingStart(port); + return true; +} + +/** A live proxy in this home can publish its sibling owner even while that owner is down. */ +export async function markLiveHomeSibling(live: { pid: number | null; port: number }): Promise { + const runtime = readRuntimePort(); + if (live.pid !== null && runtime?.pid === live.pid && runtime.port === live.port + && runtime.siblingOfPort !== undefined && runtime.siblingOfPort !== live.port) { + markSiblingStart(runtime.siblingOfPort); + return true; + } + const otherPort = await findCrossHomeOwner(); + if (otherPort === null || otherPort === live.port) return false; + markSiblingStart(otherPort); + return true; +} + +/** + * Recovery follows the full owner decision; a sibling never replays another home's journal. + * A marked sibling's owner can be down mid-restart; its journal is still not ours to replay. + */ +export function reconcileStartupJournal(): void { + if (currentExternalCodexModelProvider() || siblingOfLivePort() !== null) return; + const clientState = readClientConnectionState(); + reconcileJournal(clientState.kind === "connected" + ? { activeClientApiKeyId: clientState.value.apiKeyId } + : undefined); +} diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index 6129410b427..9cff9cf11f5 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -30,6 +30,7 @@ import { shouldSyncGrokOnStart, } from "../codex/desired-state"; import type { OcxConfig } from "../types"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; export function grokSyncFailureMessage(err: unknown): string { const detail = err instanceof Error ? err.message : String(err); @@ -101,6 +102,10 @@ export async function ensureGrokFenceMatchesDesired( ): Promise { const config = deps.loadConfig(); const { log, error } = io(deps); + if (siblingOfLivePort() !== null) { + log(` ${siblingSkipMessage()} ~/.grok/config.toml was left exactly as it is.`); + return; + } // A hub-gated skip is NOT "the user turned Grok off" (#4236). Stripping the managed block // there deleted a fence the operator still wants — and `ocx ensure` reported it as the // Grok toggle doing its job. Only an explicit OFF authorizes the strip; the gate just diff --git a/src/cli/index.ts b/src/cli/index.ts index e6f0bbdbe65..57225f9fcea 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,7 +38,7 @@ import { resolveCodexHistoryJobTarget, runCodexHistoryJob, } from "../codex/history-job"; -import { reconcileJournal } from "../codex/journal"; +import { findCrossHomeOwner, markCrossHomeSibling, markLiveHomeSibling, reconcileStartupJournal } from "./cross-home-owner"; import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state"; import { codexAutoStartEnabled, @@ -355,24 +355,15 @@ async function findProxyOwnerBeforeJournalRecovery( const pidSnapshot = readPidFileValue(); const hasRuntimeOwner = readRuntimePort() !== null; const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true; - // A negative answer here is acted on twice over: the caller walks past a proxy it was - // supposed to find, and the lines below delete this home's pid record and reconcile the - // journal. One 750ms probe is not enough evidence for either (#5004) — a transport + // A negative answer lets the caller walk past a proxy it was supposed to find and + // deletes this home's stale pid record. Journal recovery follows cross-home discovery. + // One 750ms probe is not enough evidence for that (#5004) — a transport // failure is indistinguishable from an empty port, and the reported Windows duplicate // came from exactly that answer on a proxy the previous command had just found healthy. const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null; if (live) return { live, pidSnapshot }; - // The probe established that the snapshotted owner is stale. Compare before - // deleting so a concurrent start that rewrote the PID file keeps its state. removePidIfValueIs(pidSnapshot); - // A marked sibling's owner can be down mid-restart; its journal is still not ours to replay. - if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) { - const clientState = readClientConnectionState(); - reconcileJournal(clientState.kind === "connected" - ? { activeClientApiKeyId: clientState.value.apiKeyId } - : undefined); - } return { live: null, pidSnapshot }; } @@ -413,8 +404,7 @@ async function handleStart(options: { block?: boolean } = {}) { } } const requestedPort = startOpts.port; - // Probe the configured port even without state files: a fallback sibling can remove them, - // and an unprobed start could shadow the owner and reroute Codex to a short-lived port. + // Probe the configured port even without state files: a fallback sibling can remove them. // Consume a sibling replacement's handoff before probing, even if its owner is momentarily down. let siblingStart = honorSiblingMarker(process.env, consumeSiblingHandoff) !== null; // A restart replacement waits out its draining parent instead of refusing it, and bounds its handoff log (restart-handoff.ts). @@ -448,6 +438,8 @@ async function handleStart(options: { block?: boolean } = {}) { + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`, ); } + if (!owner.live && !siblingStart) siblingStart = await markCrossHomeSibling(); + if (!siblingStart) reconcileStartupJournal(); const clientState = readClientConnectionState(); if (clientState.kind === "invalid" || clientState.kind === "mismatched") { @@ -504,6 +496,7 @@ async function handleStart(options: { block?: boolean } = {}) { siblingStart = true; markSiblingStart(fencedLive.port); } + if (!fencedLive && !siblingStart) siblingStart = await markCrossHomeSibling(); // Port selection is check-then-bind. The lease prevents every cooperating start or // updater from turning that check into a different ownership decision. @@ -733,6 +726,7 @@ async function handleStart(options: { block?: boolean } = {}) { // to explain it. Name the failure and the one command that repairs it. console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + console.log("Client startup work complete."); if (options.block ?? true) { setInterval(() => {}, 60_000); await new Promise(() => {}); @@ -749,6 +743,9 @@ function detachedStartEnvironment(): NodeJS.ProcessEnv { async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise { const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true }); + if (!owner.live && !(await markCrossHomeSibling())) reconcileStartupJournal(); + // A later ensure can find this home's sibling alive; the other port still owns shared clients. + if (owner.live) await markLiveHomeSibling(owner.live); const config = loadConfig(); if (!codexAutoStartEnabled(config)) { console.log("Codex autostart is disabled."); @@ -772,13 +769,13 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom // owns catalog refresh; ensure must not overwrite a working destination. // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); - reportShellHookFailure(reconcileShellHook(systemEnv.injected)); + if (siblingOfLivePort() === null) reportShellHookFailure(reconcileShellHook(systemEnv.injected)); if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, live.port); // Refresh the Grok Build fence too (same contract as start). live.hostname is the // hostname the running proxy actually bound — config.hostname may have drifted. // The reconciler re-reads immediately before each client-file mutation; only // the live proxy's observed bind host is safe to carry across this boundary. - await reconcileEnsureDesiredIntegrations( + if (siblingOfLivePort() === null) await reconcileEnsureDesiredIntegrations( live.port, { kind: "live", hostname: live.hostname }, ); @@ -806,7 +803,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom // responds — align here too so `ocx ensure` never returns with a stale ON/OFF mismatch. // Persisted state is loaded inside each mutation after waitForProxy, so a // toggle while the child starts wins over the pre-spawn snapshot. - await reconcileEnsureDesiredIntegrations(port, { kind: "spawned" }); + if (siblingOfLivePort() === null) await reconcileEnsureDesiredIntegrations(port, { kind: "spawned" }); // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. const synced = await syncModelsToCodex(port).catch(e => { diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 9a07703610f..9bc33663d3c 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -143,7 +143,7 @@ fan-out loads the filtered roster lazily once, leaves unowned clients alone, and refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy rather than joining a write of a different catalog and reporting false success. -On a sibling instance ([Codex home](../codex-home.md#codex-home)) `src/integrations/catalog-refresh.ts` +On a sibling instance ([Codex home](../codex-home.md#codex-home)), including one identified from another home's managed client destination, `src/integrations/catalog-refresh.ts` and `syncEnabledClientIntegrations` in `src/server/management/config-routes.ts` refresh nothing: the client files name the live owner's port, and a refresh from the sibling would re-point them at its own. diff --git a/structure/codex-home.md b/structure/codex-home.md index f6ea5be2253..e396045da40 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -151,12 +151,11 @@ writes are ignored. Recovery-completion provenance is separate from the convergence promise: release retains the owner through the pending recovery and its following stage sweep. `tests/codex-integration/native-profile-startup-release.test.ts` pins that ordering. -A sibling instance — `ocx start --port ` while a live proxy serves the configured port, the -`"sibling"` outcome of `decideStartWithLiveOwner` in `src/cli/dispatch.ts` — gets past the spend-ledger +A sibling instance is `ocx start --port ` while a live proxy serves the configured port, or a start where the cross-home owner check proves a different live proxy at a managed client destination. It gets past the spend-ledger lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and the launchd domain with the live owner. `handleStart` marks the process through `src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's -lifetime. It closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason +lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's runtime record only for a custom home, plus managed Grok and Codex loopback URLs. It accepts only an identity-checked positive PID different from this process; a sole custom-home start still syncs. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason `sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease; its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level diff --git a/structure/runtime.md b/structure/runtime.md index 0b710812245..1a197584da0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -187,7 +187,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves -until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. Service mode sets +until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. The package-tree integrity fence for live package replacement follows the diff --git a/tests/claude-integration/claude-agent-startup-sync.test.ts b/tests/claude-integration/claude-agent-startup-sync.test.ts index f41bef5219a..ee2769db57a 100644 --- a/tests/claude-integration/claude-agent-startup-sync.test.ts +++ b/tests/claude-integration/claude-agent-startup-sync.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { mkdtempSync, readdirSync, readFileSync} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -16,7 +17,21 @@ const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({ claudeCode, } as OcxConfig); +afterEach(() => resetSiblingStartForTests()); + describe("Claude agent roster proxy-start synchronization (#2200)", () => { + test("a sibling skips both roster injection and OFF pruning", async () => { + markSiblingStart(10101); + for (const claudeCode of [{}, { injectAgents: false }]) { + let writes = 0; + const result = await syncClaudeAgentDefsAtProxyStartup(config(claudeCode), 10102, { + fetchContextWindows: async () => { throw new Error("sibling fetched roster"); }, + injectAgentDefs: () => { writes++; return []; }, + }); + expect(result).toBeNull(); + expect(writes).toBe(0); + } + }); test("keeps readiness pending until the fourth registry callback settles", async () => { const gate = createReadinessGate(); let releaseRegistry!: () => void; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 4c093eccff7..d038b85ee71 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -654,7 +654,9 @@ describe("a sibling start leaves shared client routing to the live owner", () => expect(honorAt).toBeGreaterThan(-1); expect(honorAt).toBeLessThan(start.indexOf("findProxyOwnerBeforeJournalRecovery(")); const owner = slice("async function findProxyOwnerBeforeJournalRecovery(", "async function handleStart("); - expect(owner).toContain("if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) {"); + expect(owner).not.toContain("reconcileJournal("); + expect(start.indexOf("markCrossHomeSibling()")).toBeGreaterThan(-1); + expect(start.indexOf("markCrossHomeSibling()")).toBeLessThan(start.indexOf("reconcileStartupJournal()")); expect(slice("function detachedStartEnvironment(", "async function handleEnsure(")) .toContain("const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"); expect(cliSource).toContain("env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)),"); diff --git a/tests/cli/hub-gated-local-clients.test.ts b/tests/cli/hub-gated-local-clients.test.ts index 6bf4f147024..d262ff26855 100644 --- a/tests/cli/hub-gated-local-clients.test.ts +++ b/tests/cli/hub-gated-local-clients.test.ts @@ -135,6 +135,20 @@ describe("ocx ensure does not strip a Grok block the operator still wants", () = return { actions, logs, deps }; } + test("a sibling skips both Grok ON and OFF ensure writes with its own reason", async () => { + markSiblingStart(10101); + try { + for (const grok of [true, false]) { + const h = harness(hubConfig({ runtimeRole: undefined, clientIntegrations: { grok } })); + await ensureGrokFenceMatchesDesired(10102, {}, h.deps); + expect(h.actions).toEqual([]); + expect(h.logs.join("\n")).toContain(siblingSkipMessage()); + } + } finally { + resetSiblingStartForTests(); + } + }); + test("a hub-gated skip leaves ~/.grok/config.toml untouched and says why", async () => { // The operator never turned Grok off. Deleting their fence and reporting it as the toggle // working is the defect: it destroys a working config on every `ocx ensure`. diff --git a/tests/cli/sibling-home-client-sync.test.ts b/tests/cli/sibling-home-client-sync.test.ts new file mode 100644 index 00000000000..163861d20f6 --- /dev/null +++ b/tests/cli/sibling-home-client-sync.test.ts @@ -0,0 +1,332 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findCrossHomeOwner, markLiveHomeSibling } from "../../src/cli/cross-home-owner"; +import { resetSiblingStartForTests, siblingOfLivePort } from "../../src/codex/sibling-start"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const originalEnv = { ...process.env }; +const roots: string[] = []; +const servers: Array> = []; +const children: Array> = []; +const detachedPids: number[] = []; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-cross-home-")); + roots.push(root); + const home = join(root, "home"); + const ocx = join(root, "secondary"); + const codex = join(root, "codex"); + const grok = join(root, "grok"); + const claude = join(root, "claude"); + for (const dir of [home, ocx, codex, grok, claude, join(home, ".opencodex"), join(claude, "agents")]) { + mkdirSync(dir, { recursive: true }); + } + Object.assign(process.env, { + HOME: home, USERPROFILE: home, OPENCODEX_HOME: ocx, CODEX_HOME: codex, + GROK_HOME: grok, CLAUDE_CONFIG_DIR: claude, + }); + return { root, home, ocx, codex, grok, claude }; +} + +function healthServer(pid: number | null, service = "opencodex") { + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: () => Response.json({ service, status: "ok", version: "0.0.0", uptime: 1, pid }), + }); + servers.push(server); + return server.port; +} + +function grokFence(port: number | string) { + return `# user content\n# >>> opencodex managed block — do not edit (removed by \`ocx stop\`) >>>\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:${port}/v1"\n# <<< opencodex managed block <<<\n`; +} + +function codexRouting(port: number | string) { + return `model_provider = "opencodex"\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:${port}/v1"\n`; +} + +async function waitForRuntime(path: string, child: ReturnType) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (existsSync(path)) { + try { + const record = JSON.parse(readFileSync(path, "utf8")) as { pid: number; port: number; siblingOfPort?: number }; + if (record.pid === child.pid) return record; + } catch { /* publication in progress */ } + } + if (child.exitCode !== null) throw new Error(`secondary exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); + await Bun.sleep(20); + } + throw new Error("timed out waiting for secondary runtime record"); +} + +async function waitForClientStartup(child: ReturnType): Promise { + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timed out waiting for client startup")), 15_000); + }); + try { + while (!output.includes("Client startup work complete.")) { + const chunk = await Promise.race([reader.read(), timeout]); + if (chunk.done) throw new Error(`secondary exited before client startup: ${output}`); + output += decoder.decode(chunk.value, { stream: true }); + } + } finally { + clearTimeout(timer); + reader.releaseLock(); + } +} + +afterEach(async () => { + resetSiblingStartForTests(); + for (const child of children) if (child.exitCode === null) child.kill("SIGTERM"); + for (const child of children.splice(0)) await child.exited; + for (const pid of detachedPids.splice(0)) { + try { process.kill(pid, "SIGTERM"); } catch { /* already exited */ } + } + for (const server of servers.splice(0)) server.stop(true); + for (const root of roots.splice(0)) removeTreeWithRetry(root); + for (const key of ["HOME", "USERPROFILE", "OPENCODEX_HOME", "CODEX_HOME", "GROK_HOME", "CLAUDE_CONFIG_DIR"]) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } +}); + +test("cross-home discovery marks only a live other-process owner", async () => { + const fx = fixture(); + const path = join(fx.grok, "config.toml"); + const probe = async () => { + // A fresh process makes node:os resolve the fixture HOME before importing the CLI. + const script = `const { markCrossHomeSibling } = await import(${JSON.stringify(repoPath("src/cli/cross-home-owner.ts"))}); + const { siblingOfLivePort } = await import(${JSON.stringify(repoPath("src/codex/sibling-start.ts"))}); + console.log(JSON.stringify({ marked: await markCrossHomeSibling(), port: siblingOfLivePort() }));`; + const child = Bun.spawn([process.execPath, "-e", script], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + const output = await new Response(child.stdout).text(); + expect(await child.exited).toBe(0); + return JSON.parse(output.trim()) as { marked: boolean; port: number | null }; + }; + expect(await probe()).toEqual({ marked: false, port: null }); + const ownerPort = healthServer(process.pid); + writeFileSync(path, grokFence(ownerPort)); + expect(await probe()).toEqual({ marked: true, port: ownerPort }); +}); + +test("large managed Grok and Codex configs still reveal their owner", async () => { + const fx = fixture(); + const port = healthServer(process.pid + 1); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + writeFileSync(grokPath, `${"# padding\n".repeat(30_000)}${grokFence(port)}`); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); + writeFileSync(grokPath, `${grokFence(port)}${"#".repeat(16 * 1024 * 1024)}`); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + writeFileSync(grokPath, "# no managed fence\n"); + writeFileSync(codexPath, `${"# padding\n".repeat(30_000)}${codexRouting(port)}`); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); +}); + +test("Design B marker-owned root routing reveals the owner port", async () => { + const fx = fixture(); + const port = healthServer(process.pid + 1); + writeFileSync(join(fx.codex, "config.toml"), [ + OCX_ROUTING_MARKER_LINE, + `openai_base_url = "http://127.0.0.1:${port}/v1"`, + OCX_ROUTING_MARKER_LINE, + `experimental_realtime_ws_base_url = "http://127.0.0.1:${port}/v1"`, + 'model = "gpt-5.5"', + "", + ].join("\n")); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); +}); + +test("live local sibling record marks a fresh ensure decision during primary downtime", async () => { + const fx = fixture(); + const secondaryPort = healthServer(process.pid + 1); + const primaryPort = secondaryPort === 10100 ? 10101 : 10100; + writeFileSync(join(fx.ocx, "runtime-port.json"), JSON.stringify({ + pid: process.pid + 1, port: secondaryPort, siblingOfPort: primaryPort, + })); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + expect(await markLiveHomeSibling({ pid: process.pid + 1, port: secondaryPort })).toBe(true); + expect(siblingOfLivePort()).toBe(primaryPort); +}); + +test("only a distinct live identity in the default-home record counts", async () => { + const fx = fixture(); + const port = healthServer(process.pid + 1); + const record = join(fx.home, ".opencodex", "runtime-port.json"); + writeFileSync(record, JSON.stringify({ pid: process.pid + 1, port })); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); + writeFileSync(record, JSON.stringify({ pid: process.pid, port })); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); // the responder, not a stale record, owns the port +}); + +test.skipIf(process.platform === "win32")("a FIFO in place of a hint file cannot stall discovery", async () => { + const fx = fixture(); + const record = join(fx.home, ".opencodex", "runtime-port.json"); + expect(Bun.spawnSync(["mkfifo", record]).exitCode).toBe(0); + expect(Bun.spawnSync(["mkfifo", join(fx.grok, "config.toml")]).exitCode).toBe(0); + const started = performance.now(); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + expect(performance.now() - started).toBeLessThan(2_000); +}, 5_000); + +test("managed Grok and Codex hints accept only a different positive PID", async () => { + const fx = fixture(); + const port = healthServer(process.pid + 1); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + writeFileSync(grokPath, grokFence(port)); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); + writeFileSync(grokPath, "# no managed fence\n"); + writeFileSync(codexPath, codexRouting(port)); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBe(port); + writeFileSync(codexPath, codexRouting("invalid")); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); +}); + +test("same PID, null PID, foreign, stale and remote hints grant no sibling ownership", async () => { + const fx = fixture(); + const grokPath = join(fx.grok, "config.toml"); + for (const pid of [process.pid, null]) { + const port = healthServer(pid); + writeFileSync(grokPath, grokFence(port)); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + } + const foreign = healthServer(process.pid + 1, "another-service"); + writeFileSync(grokPath, grokFence(foreign)); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + const closed = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("closed") }); + const stale = closed.port; + closed.stop(true); + writeFileSync(grokPath, grokFence(stale)); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + writeFileSync(grokPath, grokFence("not-a-port")); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + writeFileSync(grokPath, grokFence(foreign).replace("127.0.0.1", "example.com")); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); + writeFileSync(join(fx.codex, "config.toml"), codexRouting(foreign).replace("127.0.0.1", "example.com")); + expect(await findCrossHomeOwner({ homeDir: fx.home })).toBeNull(); +}); + +test("a secondary start preserves shared client bytes and records the sibling owner", async () => { + const fx = fixture(); + const fakeOwnerPid = 1_000_000_000; + const ownerPort = healthServer(fakeOwnerPid); + const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); + const secondaryPort = reservation.port; + reservation.stop(true); + writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ pid: fakeOwnerPid, port: ownerPort })); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + const claudePath = join(fx.claude, "agents", "ocx-existing.md"); + writeFileSync(grokPath, grokFence(ownerPort)); + writeFileSync(codexPath, codexRouting(ownerPort)); + writeFileSync(claudePath, "owned roster bytes\n"); + const before = [grokPath, codexPath, claudePath].map(path => readFileSync(path)); + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify({ + port: secondaryPort, hostname: "127.0.0.1", codexAutoStart: false, syncResumeHistory: false, + checkForUpdates: false, clientIntegrations: { codex: true, grok: true, "claude-desktop": false }, + claudeCode: { injectAgents: false, systemEnv: false }, providers: {}, defaultProvider: "openai", + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(secondaryPort)], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(child); + const runtime = await waitForRuntime(join(fx.ocx, "runtime-port.json"), child); + expect(runtime.siblingOfPort).toBe(ownerPort); + await waitForClientStartup(child); + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); + child.kill("SIGTERM"); + await child.exited; + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); +}, 30_000); + +test("a secondary ensure parent preserves shared Grok, Codex and Claude agent bytes", async () => { + const fx = fixture(); + const ownerPort = healthServer(process.pid); + const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); + const secondaryPort = reservation.port; + reservation.stop(true); + const grokPath = join(fx.grok, "config.toml"); + const codexPath = join(fx.codex, "config.toml"); + const claudePath = join(fx.claude, "agents", "ocx-existing.md"); + writeFileSync(grokPath, grokFence(ownerPort)); + writeFileSync(codexPath, codexRouting(ownerPort)); + writeFileSync(claudePath, "---\ngenerated-by: opencodex\n---\nowner roster\n"); + const before = [grokPath, codexPath, claudePath].map(path => readFileSync(path)); + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify({ + port: secondaryPort, hostname: "127.0.0.1", codexAutoStart: true, syncResumeHistory: false, + checkForUpdates: false, clientIntegrations: { codex: true, grok: true, "claude-desktop": false }, + claudeCode: { injectAgents: false, systemEnv: false }, providers: {}, defaultProvider: "openai", + })); + const ensure = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "ensure"], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(ensure); + const output = await new Response(ensure.stdout).text(); + const error = await new Response(ensure.stderr).text(); + expect(await ensure.exited).toBe(0); + expect(output + error).toContain(`Proxy running on port ${secondaryPort}`); + const runtime = JSON.parse(readFileSync(join(fx.ocx, "runtime-port.json"), "utf8")) as { + pid: number; port: number; siblingOfPort?: number; + }; + detachedPids.push(runtime.pid); + expect(runtime.siblingOfPort).toBe(ownerPort); + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); + + // A fresh ensure parent sees this home's live sibling after the primary goes down. + servers.pop()?.stop(true); + const again = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "ensure"], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(again); + await new Response(again.stdout).text(); + await new Response(again.stderr).text(); + expect(await again.exited).toBe(0); + expect([grokPath, codexPath, claudePath].map(path => readFileSync(path))).toEqual(before); +}, 30_000); + +test("a lone custom-home start still syncs Grok and prunes its own Claude roster", async () => { + const fx = fixture(); + const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("reserved") }); + const port = reservation.port; + reservation.stop(true); + const grokPath = join(fx.grok, "config.toml"); + const claudePath = join(fx.claude, "agents", "ocx-existing.md"); + writeFileSync(grokPath, grokFence(12345)); + writeFileSync(claudePath, "---\ngenerated-by: opencodex\n---\nold roster\n"); + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify({ + port, hostname: "127.0.0.1", codexAutoStart: true, syncResumeHistory: false, + checkForUpdates: false, clientIntegrations: { codex: false, grok: true, "claude-desktop": false }, + claudeCode: { injectAgents: false, systemEnv: false }, providers: {}, defaultProvider: "openai", + })); + const child = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "start", "--port", String(port)], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(child); + const runtime = await waitForRuntime(join(fx.ocx, "runtime-port.json"), child); + expect(runtime.siblingOfPort).toBeUndefined(); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline && readFileSync(grokPath, "utf8").includes("127.0.0.1:12345")) await Bun.sleep(20); + expect(readFileSync(grokPath, "utf8")).toContain(`127.0.0.1:${port}`); + expect(existsSync(claudePath)).toBe(false); + writeFileSync(claudePath, "---\ngenerated-by: opencodex\n---\nstale roster\n"); + const ensure = Bun.spawn([process.execPath, repoPath("src/cli/index.ts"), "ensure"], { + cwd: fx.root, env: { ...process.env, NO_PROXY: "127.0.0.1,localhost" }, stdout: "pipe", stderr: "pipe", + }); + children.push(ensure); + await new Response(ensure.stdout).text(); + await new Response(ensure.stderr).text(); + expect(await ensure.exited).toBe(0); + expect(existsSync(claudePath)).toBe(false); +}, 30_000); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 874d760f052..3d5b4eadb59 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -942,6 +942,9 @@ test("already-running ensure leaves Raycast untouched when saved host and listen let refreshCalls = 0; const deps = { findProxyOwnerBeforeJournalRecovery: async () => ({ live: { hostname: "127.0.0.1", port: 10237 } }), + // Cross-home ownership: this harness models a lone owner, so nothing marks it a sibling. + markLiveHomeSibling: async () => false, + siblingOfLivePort: () => null, loadConfig: () => savedConfig, codexAutoStartEnabled: () => true, syncModelsToCodex: async () => ({ status: "skipped" }), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d8e629850e4..4fe30686d8b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -392,6 +392,7 @@ "cli-restore-back.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", + "sibling-home-client-sync.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", From 87afa8601f70d652d408be7fe5c82cd3990a590e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 02:47:33 +0900 Subject: [PATCH 4/4] docs(devlog): record release train 4 bug hardening lane Roadmap, per-slice diff-level plans with their audit folds, and the evidence ledger for the train 4 bug-hardening lane. --- .../bug-hardening/000_plan.md | 59 +++++++++++++++++++ .../bug-hardening/010_adapter_bounds.md | 39 ++++++++++++ .../bug-hardening/020_native_quota.md | 19 ++++++ .../bug-hardening/030_restart_transaction.md | 19 ++++++ .../bug-hardening/040_link_ssh.md | 25 ++++++++ .../bug-hardening/050_disposition_and_ci.md | 28 +++++++++ .../060_sibling_home_client_sync.md | 52 ++++++++++++++++ .../bug-hardening/_handoff.md | 15 +++++ 8 files changed, 256 insertions(+) create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/000_plan.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/010_adapter_bounds.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/020_native_quota.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/030_restart_transaction.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/040_link_ssh.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/050_disposition_and_ci.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/060_sibling_home_client_sync.md create mode 100644 devlog/_plan/260927_release_train_4/bug-hardening/_handoff.md diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/000_plan.md b/devlog/_plan/260927_release_train_4/bug-hardening/000_plan.md new file mode 100644 index 00000000000..0240e8f7960 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/000_plan.md @@ -0,0 +1,59 @@ +# Release train 4 — bug hardening lane + +At the 2026-09-27 inventory, `origin/dev` was `24b2f39b77`. This lane will carry small, testable crash and resource bounds and make SSH Link failures diagnosable. It will keep policy-changing, unreachable or unsafe restart proposals open with concrete reasons. Every adopted change is rebased onto the then-current `dev`, reviewed, run through focused regressions and exact-head CI, and merged through a lane-owned PR. + +## Loop specification + +- Archetype and trigger: satisfy-spec release integration for the assigned PRs and issues, requested by the train coordinator. +- Goal and stop: record a decision for every assigned item; land only safe, validated changes; close solved issues and superseded PRs with links; check the resulting `dev` CI. Stop only when those actions and the evidence ledger in this directory are complete, or report a concrete external blocker. +- Scope: this worktree and `codex/t4-bug-hardening-*` branches; `devlog/_plan/260927_release_train_4/bug-hardening/` for plans; the exact source, tests, contracts and user docs in the phase files. GitHub writes are limited to this lane's branches and PRs, dispositions on assigned issues and candidate PRs, and permitted merges to `dev`. +- Non-goals: main/preview, releases, tags, deployment, version changes, contributor-fork pushes, picker CA/`src/claude/intercept`, account-pool changes, GUI PRs, provider-compatibility PRs, client-integration PRs, and other lanes' worktrees. +- Verifiers: focused commands in 010–040 directly name the changed test files; `bun run test:changed` traverses imports from changed TS files; `bun run typecheck` checks TS; `bun run privacy:scan` reads tracked devlog and source; `bun run structure:check` checks structure links and ownership. Run `test:changed`/full in a same-commit throwaway checkout under `/private/tmp/t4-bug-hardening-verify` because this source checkout under `~/.codex` triggers protected-cleanup failures; never bypass the guard. The PR's pull_request CI must complete every requested job at its exact head; after landing, dispatch and inspect Cross-platform CI on exact `dev`. The local full suite is omitted only under the seven-lane resource exception, with commands/results and CI coverage documented in each PR. +- Memory artifact: this numbered roadmap and phase documents, each batch PR Verification section, CI run links, and the final outcome in `050_disposition_and_ci.md`. +- Terminal outcomes: DONE means the adopted changes, dispositions, closures and dev CI are evidenced; NOOP means a candidate is already present with source proof; NEEDS_HUMAN means an owner contract or security decision is absent; BLOCKED means an external condition repeatedly prevents useful progress; UNSAFE means a candidate's regression/security cost exceeds its validated benefit. No time or token limit was set by the coordinator. +- Escalation: preserve an explicit maintainer objection or unsafe auth/credential contract instead of integrating it. No local test can substitute for an upstream WHAM window-completeness decision. GitHub writes and merge authority are the coordinator's explicit lane grant, bounded by current-head CI and `MAINTAINERS.md`. +- Tool, credential and write bounds: local Bun/Swift and `gh` with the signed-in maintainer account; no new credential acquisition. Source writes stay in this worktree, and sensitive unpublished triage stays in `.tmp/`. Wall-clock and token budgets are unbounded by request; CI and risk gates still stop unsafe integration. + +## Candidate decisions + +The disposition is a plan, not a merge claim. Current PR pages and source were inspected against `24b2f39b77`; every adoption is rechecked against the later integration head. + +| Item | Decision | Grounded reason and next proof | +| --- | --- | --- | +| [#6081](https://github.com/lidge-jun/opencodex/pull/6081) | Carry current head `8a4399f` with a single-parser admission fix in B1 | `src/adapters/coding-agent/protocol.ts` retains unbounded argument fragments until close; the proposal charges the shared translator budget and releases it. The new head removed an inconsistent double raw-start counter, but the existing parser-owned cap still runs after block insertion. Move that one cap before insertion for nonempty IDs, preserving the existing error and empty-ID semantics. Check interleaving, index reuse, abort cleanup and Qoder's shared parser. | +| [#6083](https://github.com/lidge-jun/opencodex/pull/6083) | Reimplement in B1, with author credit | Current `src/adapters/openai-chat/tool-call-id-remint.ts:28-39` probes from 2 on each repeat. The proposed 62-character key restarts searches after the suffix widens at `-10`; add a cursor for the actual truncation domain and a cross-width regression. | +| [#6082](https://github.com/lidge-jun/opencodex/pull/6082) | Carry as-is in B2 | `app/Sources/NativeTray/Models.swift:160-171` can convert a finite oversized percentage to `Int` and trap. The two guarded conversions and test are small and independent of GUI PRs. | +| [#6085](https://github.com/lidge-jun/opencodex/pull/6085) | Hold, leave open; B3 disposition | The proposal's start retries and post-deadline recovery can race a late replacement. A narrowed carry also needs production phase evidence to separate pre-launch refusal, late health, and post-health integration failure; the current boolean collapses them. Preserve existing once-only/fail-closed behavior and comment with the reconsideration gate. | +| [#6076](https://github.com/lidge-jun/opencodex/pull/6076) | Hold, leave open | Pairing grants are hub-only (`src/server/gui-session.ts:263,319`), while Child join requires standalone. The proposed gate makes actual Child join unreachable; old local dashboards would also receive 403 without an upgrade path. A separately reviewed standalone operator credential and GUI denial reason are required. Keep unpublished security reasoning in scratch. | +| [#5964](https://github.com/lidge-jun/opencodex/pull/5964) | Hold, leave open | Its markup rule reverses the observed prose-prefixed text-only MiMo tool call in `tests/providers/command-code-tool-text-prose-split.test.ts:91-122`. An explicit compatibility/security decision is needed. | +| [#6030](https://github.com/lidge-jun/opencodex/pull/6030) | Hold, leave open | The 17-path draft conflicts with current `dev`; its launchd rewrite can change the plist without reloading the live job and must preserve package-local runtime and non-PATH environment. A narrowed service-owner reimplementation needs Linux/macOS lifecycle proof. | +| [#5831](https://github.com/lidge-jun/opencodex/pull/5831) | Hold, leave open | The two-window WHAM exception is plausible, but a current maintainer explicitly withheld approval until the provider/owner confirms that omitted tertiary means no governing short window. It also changes credential-generation publication and has no complete current-head CI. | +| [#5539](https://github.com/lidge-jun/opencodex/pull/5539) | Hold, leave open | The broad mapper change reverses `tests/responses/openai-responses-passthrough.test.ts:867` for unconfigured providers; no current-dev request reproduces the 400. Retain the intent for a provider-scoped repro, not the stale branch. | +| [#6088](https://github.com/lidge-jun/opencodex/issues/6088) | Implement in B4 | `src/link/ssh-argv.ts:180-185` quotes the command name as data, which PowerShell parses differently. `src/link/ssh-runner.ts:96-124` fatal-decodes stderr before a sanitized hint can describe the real error. Keep stdout strict and bound/redact diagnostic stderr. | +| [#4956](https://github.com/lidge-jun/opencodex/issues/4956) | Keep open, comment partial scope | #5014 and later isolated fixture/CI changes address concrete hangs, but do not prove the Bun child-process failure family solved. A silent cancelled CI leg is not green. | +| [#4761](https://github.com/lidge-jun/opencodex/issues/4761) | Keep open, comment scope decision | `src/codex/app-server-restart-service.ts:149` still invokes desktop-shell restart and warns about unsaved state. App-server-only restart does not currently refresh the picker; changing consent/UI belongs to the other lane. | + +## Dependency-ordered work phases + +The roadmap is this docs-only PABCD cycle. Production work starts after its audit and Check. Independent source slices are integrated serially so each next branch starts at current `origin/dev` and inherits any merged contract changes. + +| Cycle | Decade doc | Deliverable | +| --- | --- | --- | +| B1 | [010_adapter_bounds.md](010_adapter_bounds.md) | CodeBuddy retained-argument cap and linear tool-call-ID reminting, one reviewable adapter hardening PR. | +| B2 | [020_native_quota.md](020_native_quota.md) | NativeTray percentage conversion guard, separate Swift PR. | +| B3 | [030_restart_transaction.md](030_restart_transaction.md) | Record the restart safety audit and comment on #6085; no source PR from this cycle. | +| B4 | [040_link_ssh.md](040_link_ssh.md) | SSH argv and diagnostic-error fix for #6088, separate Link PR. | +| B6 | 060_sibling_home_client_sync.md (written in its own P) | Coordinator-added bug: an instance started with an independent `OPENCODEX_HOME` while another proxy runs must not rewrite global client configs (`~/.grok/config.toml` and the same class of Codex/Claude sync), separate PR. | +| Closure | [050_disposition_and_ci.md](050_disposition_and_ci.md) | Candidate comments/closures, exact merged SHAs, and final `dev` CI. | + +### Resumption amendment (session `01a0e37e`, 2026-09-28) + +The previous session stopped after this roadmap and the B1 plan; no source had been edited and `origin/dev` was still `24b2f39b77` with unchanged candidate heads, so every disposition above still stands. The resumed goalplan runs three work-phases. wp1 builds B1, B2 and B4 in one PABCD cycle because their write sets are disjoint (`src/adapters/**` + CodeBuddy/remint tests; `app/Sources/NativeTray*`; `src/link/ssh-*` + Link tests), then publishes them as three separate lane PRs cut from `origin/dev`, so each keeps its own review surface, security note and exact-head CI. Each PR is rebased onto whatever `dev` is when it merges. wp2 is B6 with its own 060 decade doc. wp3 is B3's #6085 comment plus the Closure row. The serial-integration rule above still holds at merge time: a later PR is rebased and re-verified after an earlier one lands. + +## Architecture consultation + +Read-only architect proposal handle `01a0e33e-d6cf-75f1-8873-e262e9c7d68e`, current-dev source anchors in its report. A1 accepted: `devlog/` holds open decisions; `structure/` changes only when its present-tense contract changes. A2 amended: #5964 is held because it reverses a live regression, and #6081/#6083 share one bounded adapter-hardening PR; #6082 stays separate for the Swift gate. A3 amended after independent audit: #6085 is held because safe recovery requires phase evidence across the real CLI start adapters; #6030 remains held until its loaded-launchd behavior and environment preservation can be proved. A4 accepted: hold unreachable #6076 and keep #6088 as its own SSH change. A5 accepted: retain current quota and effort contracts until the named missing evidence arrives. A6 accepted: #4956 cannot be closed from partial fixture fixes, and #4761 requires a separate desktop/GUI scope decision. The earlier B4 verifier corrections are in 040. Final architect reflection and independent audit are recorded before this roadmap is locked. + +## Roadmap audit outcome + +The independent reviewer `01a0e34b-f829-7c50-9eac-c12b93690513` gave a final `VERDICT: PASS` after the restart race, changed #6081 head, and B4 dependency findings were folded into the staged plan. Its final pre-scan found no blocking issue; `git diff --cached --check` and `bun run privacy:scan` passed. The architect's final reflection was `ALIGNED`. This locks decisions for the first implementation pass; each later P rechecks its decade doc against current `dev`. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/010_adapter_bounds.md b/devlog/_plan/260927_release_train_4/bug-hardening/010_adapter_bounds.md new file mode 100644 index 00000000000..64262504374 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/010_adapter_bounds.md @@ -0,0 +1,39 @@ +# B1 — bounded adapter state + +Depends on the locked roadmap. The prior D closed at roadmap commit `1c2ac3cb79` and directed this B1 adapter-bound phase. Fresh `origin/dev` remains `24b2f39b77`; #6081 is now `8a4399f` and #6083 is `ccfb8e63`. Carry #6081 with the single-parser admission repair and reimplement #6083's cross-width cursor on one lane-owned branch. The combined thesis is that untrusted upstream tool-call identifiers and argument fragments use bounded time and memory before client emission. Do not change provider routing or command-code markup. + +## Exact file map + +| Path | Change from current behavior | +| --- | --- | +| `src/adapters/coding-agent/protocol.ts` | MODIFY: `OpenToolBlock` retains a budget identity; opening, each argument delta, replacement and close charge/release the existing `TranslatorBudget`. Add one cleanup function for blocks left open at turn end. Current `argParts.push` at the input delta has no retained-byte charge. Preserve ordered event emission. Put the existing turn-call ceiling in the parser's `content_block_start` path, after confirming a nonempty ID but **before** inserting a block, and expose a process-local limit-exceeded signal to the caller. | +| `src/adapters/coding-agent/turn.ts` | MODIFY: pass the incoming budget and bridge call ceiling to parse state; map budget overflow to `translation_buffer_limit`, parser call-limit signal to the established `tool_call_limit`, and release reservations in `finally`. Remove the old post-map call-limit check so there is one authoritative count. Do not restore #6081's removed double raw-start counter, which counted empty-ID frames inconsistently. | +| `src/adapters/openai-chat/tool-call-id-remint.ts` | MODIFY: preserve first occurrences and the `-` family. Replace per-repeat probing from 2 with a next-suffix cursor keyed by `(suffix digit width, sanitized base prefix retained under 64 characters)`; when `-9` becomes `-10`, resume in the 61-character collision group instead of restarting a 62-character group. A probe advances that group's cursor before a later repeat can retry it, and an emitted candidate is reserved immediately. Do not adopt #6083's fixed 62-character key unchanged or change unrelated IDs with one global suffix counter. | +| `tests/providers/codebuddy-protocol.test.ts`, `tests/providers/codebuddy-tool-bridge-turn.test.ts` | MODIFY: before/after tests for over-budget fragments, interleaved blocks, same-index reuse and abort cleanup. A seventeenth valid start is refused before another block is inserted and emits no tool event; an empty-ID frame does not consume a slot. | +| `tests/providers/qoder-adapter.test.ts` | MODIFY only if a shared-parser regression needs an explicit Qoder assertion; otherwise run the existing file and record no change. | +| `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts` | MODIFY: instrument occupied-set probes in a synchronous `try/finally`; cover thousands of repeats and distinct 62-character sibling IDs that converge when the suffix widens. Assert uniqueness, length <= 64 and preserved first occurrence. The cross-width case must fail on #6083's current head. | +| `structure/providers-and-adapters.md` | MODIFY the CodeBuddy buffer and unique-ID paragraphs to state the new present-tense budget and width-aware collision cursor. Correct #6081's stale raw-start wording to describe one parser-owned valid-ID admission check before allocation. Review other mapped adapter docs for contradictions; do not copy the pending plan into structure. | + +No new test file is expected. If one is needed to avoid the file-size ratchet, register it in both `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; never raise a cap. + +## Audit and activation + +The main audit reads the complete #6081 diff and its budget owner, verifies that reservations release on close, malformed index reuse, thrown decode and abort, and confirms the existing 8 MiB JSONL ceiling remains separate. For #6083, use candidate-width groups rather than the original ID as the cursor key; assert that no occupied candidate is probed again under converging prefixes. A rewritten nonconforming collision becomes conforming; an unoccupied first occurrence, even if nonconforming, retains the existing byte-identical fast path. + +Trigger tests: a fifth byte after a four-byte retained budget emits `translation_buffer_limit` with no tool-call event and a zero active reservation; a seventeenth valid start triggers `tool_call_limit` before block allocation; an empty-ID start does not consume a slot; sibling IDs at `-9`/`-10` keep probe count proportional to emissions. The new buffer and remint regressions run red on the prior behavior and green on the patch. The new parser limit and exceeded signal are in-memory: `turn.ts` creates the limit in parse state, `protocol.ts` sets the signal, `turn.ts` consumes it for the error response; serialization and deserialization are N/A. + +Security review records assets (process memory and client tool-call identity), entrypoint (untrusted upstream stream), trust boundary (provider output to adapter state), attacker capability (repeated IDs/fragments), and controls (per-turn/per-call budget, monotone cursors, cleanup). This is input/resource hardening; no auth or credential check is relaxed. Review the PR for raw argument or token logging. + +## Verification and delivery + +Before planning, the baseline command `bun test tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts tests/providers/codebuddy-protocol.test.ts tests/providers/codebuddy-tool-bridge-turn.test.ts tests/providers/qoder-adapter.test.ts` ran after a frozen install: 88 pass, 0 fail. It reads every listed test target directly. During B1 run those focused files in the dedicated worktree. Run `bun run test:changed` and any full suite in a throwaway `/private/tmp/t4-bug-hardening-verify` checkout pinned to the exact B1 commit, because this checkout under `~/.codex` triggers protected-cleanup failures; do not bypass those guards. Run `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan`, file-size and test-layout guards. Record exact commands/results, the seven-lane local full-suite exception, and exact-head hosted CI in the PR. + +## B1 architect decisions + +Read-only architect handle `01a0e33e-d6cf-75f1-8873-e262e9c7d68e` proposed B1-A1 through B1-A5 against #6081 `8a4399f`, #6083 `ccfb8e63` and `dev` `24b2f39b77`. Main accepts B1-A1 single valid-ID parser admission owner; B1-A2 reuse of the shared budget and separate JSONL line ceiling; B1-A3 width-aware prefix cursors rather than one global counter or hash; B1-A4 activation tests for the 17th valid block, empty ID, fragment overflow, cleanup and cross-width siblings; and B1-A5 structure-contract and exact-head gates. The file map and tests above encode those decisions. Reflection on this plan revision precedes independent A audit. + +The PR body carries `Co-authored-by` for #6081/#6083 contributors. Merge only when current `origin/dev` is an ancestor of the PR head, every required current-head check is successful, and correct review findings are resolved. After merge, thank and close both source PRs with the merged PR/SHA. + +## A-phase fold (session `01a0e37e`, auditor `01a0e381-33d6`, NEAR-PASS) + +Qoder runs the same parser through the shared turn runner without a tool bridge, so a bridge-supplied call ceiling would leave it unbounded, and an opened block retains its ID and name even with zero argument bytes. The fold: the parser owns a block-count ceiling that applies whether or not a bridge is present (the bridge ceiling, when present, is the tighter of the two), and the retained-byte charge covers the block's ID and name as well as argument fragments. Add a Qoder regression in `tests/providers/qoder-adapter.test.ts` that drives more valid starts than the parser ceiling and asserts the terminal error with no further block allocation, and one that proves ID/name bytes count against the budget. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/020_native_quota.md b/devlog/_plan/260927_release_train_4/bug-hardening/020_native_quota.md new file mode 100644 index 00000000000..2163ac2ffed --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/020_native_quota.md @@ -0,0 +1,19 @@ +# B2 — NativeTray percentage conversion + +Depends on B1 integration into `dev` so this PR starts at the fresh tip. Carry #6082 as a minimal Swift change with original author credit. It touches the native tray, not a GUI PR or dashboard component. + +## Exact file map + +| Path | Change from current behavior | +| --- | --- | +| `app/Sources/NativeTray/Models.swift` | MODIFY `percentText` and `percentDescription`: after `number` accepts a finite nonnegative `Double`, reject values at or above the representable `Int` upper bound before `Int(percent.rounded(.down))`. Preserve ordinary flooring and visible/spoken fallback strings. Keep `number` itself unchanged because other percentage consumers need their own policy. | +| `app/Sources/NativeTrayTests/main.swift` | MODIFY: assert a finite `1e20` produces “—” and “Unavailable” rather than trapping; retain 89.9 flooring and nil checks. | +| `structure/companion.md` | REVIEW the native tray formatting contract; MODIFY only if it describes which raw values render. `structure/overview.md` is also reviewed as a mapped app owner. | + +## Audit and activation + +The failure trigger is a finite percentage larger than `Int.max`, which `NativeTrayFormat.number` currently permits. Verify both visible and VoiceOver calls actually execute their `Int` conversion; the regression test executable must cover each. Check the boundary around `Double(Int.max)` without assuming the floating representation equals the integer exactly. No account selection or quota threshold behavior changes. + +## Verification and delivery + +`swift run --package-path app NativeTrayTests` is the direct verifier (same executable as the widget CI job); baseline at `24b2f39b77` passed 53 assertions, and the patched result belongs in the phase outcome. Run applicable file-size/format checks and inspect native macOS CI on the exact PR head. The test suite for TS imports does not observe the Swift conversion and is not used as proof of this fix. Merge through a lane-owned PR, then link and close #6082 with thanks. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/030_restart_transaction.md b/devlog/_plan/260927_release_train_4/bug-hardening/030_restart_transaction.md new file mode 100644 index 00000000000..877839c9d55 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/030_restart_transaction.md @@ -0,0 +1,19 @@ +# B3 — restart proposal hold + +Depends on B2 integration into current `dev`. This is a disposition cycle for [#6085](https://github.com/lidge-jun/opencodex/pull/6085), not a source patch. Independent review found that its restart retries and post-deadline recovery can report success or launch a second proxy without proof the first attempt ended. A safe narrowed carry would need a production test seam that distinguishes pre-launch refusal, health-publication lag, and a later integration failure. That expansion is outside this train's bounded restart change. Keep the candidate open and comment with the concrete blockers. + +## Exact action map + +| Path or surface | Change | +| --- | --- | +| `src/cli/tray-proxy.ts`, `src/cli/index.ts`, `src/cli/dispatch.ts` | NO CODE CHANGE in B3. Preserve current once-only in-place restart, identity and replacement deadline. | +| `tests/windows/tray-proxy.test.ts`, `tests/cli/cli-restart-health.test.ts` | NO TEST CHANGE in B3. Existing coverage remains the baseline; no passing test is described as proof of the candidate. | +| `docs-site/src/content/docs/reference/cli/lifecycle.md`, `structure/runtime.md` | NO CONTRACT CHANGE in B3. Current warning and fail-closed behavior remain documented. | +| `000_plan.md`, `050_disposition_and_ci.md` | MODIFY candidate disposition, record this audit and the URL of the English PR comment. | +| [PR #6085](https://github.com/lidge-jun/opencodex/pull/6085) | COMMENT in English, then leave open. Explain the accepted-restart handoff race, start-return ambiguity, and exact tests needed for reconsideration. No merge or close. | + +## Evidence and reconsideration gate + +At `dev` `24b2f39b77`, `runProxyRestart` in `src/cli/tray-proxy.ts:149-198` starts only after confirmed absence and never replays a possibly accepted request. The candidate adds three start attempts and starts again after a post-deadline empty re-observation. An accepted restart may still publish its replacement after that empty observation. The `startWhenStopped` boolean can mean no start was attempted (`src/cli/index.ts:757`, `src/cli/tray-proxy.ts:110`) or a detached child was launched but has not become healthy (`src/cli/index.ts:789-803`). A healthy proxy can also precede a later integration exception (`src/cli/index.ts:809`), so a generic "marked start failed" recovery would hide that error. The service path can refuse before `ops.start()` (`src/service/cli.ts:345`). These cases need a typed production outcome or equivalent phase evidence plus tests that reach both real CLI start adapters; coordinator-only injected callbacks are insufficient. + +Baseline verifier: `bun test tests/windows/tray-proxy.test.ts tests/windows/tray-proxy-deadline.test.ts tests/cli/cli-restart-health.test.ts tests/cli/cli-restart-handoff.test.ts` ran on `24b2f39b77`: 45 pass, 0 fail. No patch was tested or landed. The gate for reconsideration is a current-dev diff with observable tests for pre-launch refusal, late health after a launched child, post-health reconciliation failure, service preflight refusal, accepted-restart late replacement, and an actual production adapter invocation, followed by exact-head CI. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/040_link_ssh.md b/devlog/_plan/260927_release_train_4/bug-hardening/040_link_ssh.md new file mode 100644 index 00000000000..482b941be66 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/040_link_ssh.md @@ -0,0 +1,25 @@ +# B4 — SSH Link command and diagnostic boundary + +Depends on B2 integration into current `dev` and completion of B3's hold/comment disposition; B3 has no source PR to integrate. Resolve issue #6088 in a lane-owned PR. This is an SSH transport fix, not a redesign of Link join admission (#6076 stays open). + +## Exact file map + +| Path | Change from current behavior | +| --- | --- | +| `src/link/ssh-argv.ts` | MODIFY `quoteRemote`: validate the first argv token as a conservative command name and emit that name bare so a PowerShell OpenSSH `DefaultShell` can invoke `sh`; continue single-quoting every argument. Reject unsafe command names rather than interpolating them. Preserve NUL rejection and POSIX argument bytes. | +| `src/link/ssh-runner.ts` | MODIFY output decoding by channel: structured stdout stays strict UTF-8, while stderr diagnostic bytes use a replacement decode after the byte cap so `sshFailureHint` can redact/bound the real error. Never log raw stderr, key material or undecoded bytes. | +| `tests/clients/link-ssh-argv.test.ts` | MODIFY: bare validated command name, quoted arguments and metacharacter/NUL rejection; POSIX command execution retains the PATH prelude and arguments. Add direct runner tests for non-UTF-8 stderr, strict stdout rejection, output limit and secret/query redaction. On Windows, a non-skipped case invokes the installed `powershell.exe` parser and asserts command dispatch and arguments. | +| `structure/remote-link.md` | MODIFY the command quoting and diagnostic contract in present tense. Retain host-key, key-stdin and trust-store rules. | +| `docs-site/src/content/docs/guides/remote-link.md` | MODIFY only the troubleshooting text to explain that remote-shell errors surface as sanitized hints. Do not claim general Windows Link support from a local parser test; review maintained locales for contradictory support claims. | + +## Security audit and activation + +Assets are link keys, host identity and error diagnostics. Entrypoints are locally constructed remote argv plus untrusted SSH stderr. The first command token must remain an allowlisted executable name, while all arguments stay data; reject shell metacharacters and NUL in the command position. A remote PowerShell default shell receiving bare `sh` must dispatch it; arguments are still parsed by the invoked POSIX `sh`. A CP936-like invalid UTF-8 stderr must yield a bounded sanitized hint rather than the runner's generic decode failure; an invalid UTF-8 stdout must still fail. Test `--key-stdin` delivery and no credential disclosure. + +The guide currently excludes Windows from the supported end-to-end flow. Confirm any support wording against a real Windows CI parser case and avoid declaring all Windows Link paths supported. Review #6076 separately for old Link/dashboard upgrade compatibility; this PR must not change admission. + +## Verification and delivery + +A-phase fold (session `01a0e37e`, auditor `01a0e381-33d6`, NEAR-PASS): the command position accepts exactly the constructed `sh` (an allowlist of one), not a character-class grammar. A class such as `[A-Za-z0-9._-]+` admits `1`, `.` and `-x`, which PowerShell does not treat as a command. Any other first token is rejected with a typed error, and the tests cover rejected forms. The exact quoted-output assertions in `tests/clients/link-ssh-argv.test.ts` change from `'sh'` to bare `sh`. Stdout stays strict because callers parse version and link data from it; stderr is decoded leniently only after the byte cap and reaches the user only through the existing bounded redaction in `src/link/ssh-runner.ts`. + +Run `bun test tests/clients/link-ssh-argv.test.ts tests/server/link-management-routes.test.ts` (`link-ssh-argv.test.ts` already owns the runner cases; baseline at `24b2f39b77`: 36 pass, 0 fail), `bun run test:changed`, `bun run typecheck`, `bun run privacy:scan`, `bun run structure:check`, and the docs-site build if the guide changes. The PR records an explicit credential/diagnostic security review and exact-head pull-request CI. Before claiming #6088 fixed on Windows, dispatch the Cross-platform CI `all` lane on the B4 branch's exact head, verify the Windows shard completed successfully, and read its test log to confirm the named PowerShell parser assertion actually ran and passed rather than skipped; ordinary PR CI alone does not provide that full Windows proof. Close #6088 only after the merged SHA is on `dev` and post a link plus the observed Windows/POSIX verification scope. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/050_disposition_and_ci.md b/devlog/_plan/260927_release_train_4/bug-hardening/050_disposition_and_ci.md new file mode 100644 index 00000000000..569348b296a --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/050_disposition_and_ci.md @@ -0,0 +1,28 @@ +# Closure — assigned-item disposition and dev CI + +Depends on B1–B4 or an evidenced decision to hold a batch. This document becomes the lane ledger; it is not a release or deployment instruction. Update its tables as merges and GitHub actions actually happen. Do not mark an unrun check successful. + +## Exact action map + +| Item | Required action after implementation decisions | +| --- | --- | +| Carried #6081, #6083, #6082 | After the corresponding lane-owned PR merges, comment in English on each source PR with thanks, the merged PR URL and `dev` merge SHA, then close it as superseded. Verify coauthor trailer survived in the lane PR. | +| #6088 | After B4 merges and exact `dev` SHA is observed, comment with PR/commit link and the verification scope, then close. | +| Held #6076, #5964, #6030, #5831, #5539, #6085 | Post one precise English comment per PR naming the current blocker and evidence; leave open unless an exact duplicate/supersession is subsequently proved. Do not imply the proposal was merged. | +| Open #4956, #4761 | Comment in English with the partial-fix and remaining-contract evidence; leave open. Do not close from a warning or fixture-only change. | +| Lane-owned PRs | Each PR uses the repository Summary, Verification and Checklist template; includes focused command output, full-suite contention exception, security review where applicable, correct coauthor trailers, and current-head CI links. Resolve valid Codex/CodeRabbit findings before merge. | +| `dev` integration | Fetch `origin/dev` before every merge; prove ancestry and combined file-size/union/doc gates. Record merge SHAs. Since Cross-platform CI does not trigger on `dev` push, dispatch `workflow_dispatch` on exact `dev` and inspect expected jobs, event, head SHA, attempt and conclusions. Repair a lane-caused failure. | + +## Evidence ledger + +| Batch | Lane PR | Head and merged `dev` SHA | Local commands and result | Exact-head CI run | Source PR/issue action | +| --- | --- | --- | --- | --- | --- | +| B1 | #6101, then the batch PR | reviewed head `3d74920bfd` (source identical to `7663693894`); merge pending | adapter-focused 100 pass / 0 fail (88 before); red before fix | #6101 pull_request CI | #6081, #6083: close with thanks after merge | +| B2 | [#6099](https://github.com/lidge-jun/opencodex/pull/6099) | head `8812bb5dfc`, merged `6d64ea26a7` (squash, Co-authored-by luvs01 kept) | NativeTrayTests 59 assertions (53 before); trapped (exit 133) on old formatter | [36330651498](https://github.com/lidge-jun/opencodex/actions/runs/36330651498), every executed job success | #6082 closed as carried | +| B3 | no source PR planned | no merge | restart-focused baseline: 45 pass / 0 fail on `24b2f39b77`; safety audit holds #6085 | N/A | pending English PR comment | +| B4 | #6102, then the batch PR | reviewed head `a08c375912`; merge pending | Link-focused 40 pass / 1 win32 skip / 0 fail; 4 red before fix; `test:changed` 6007 pass / 46 fail, all 46 in three Claude integration files that pass 123/0 in isolation on the same commit (parallel contention, no import of `src/link`) | #6102 CI; Windows proof: dispatch [36331108394](https://github.com/lidge-jun/opencodex/actions/runs/36331108394) | #6088: close after merge with Windows log evidence | +| B6 | #6108, then the batch PR | reviewed head `4bfae6f055` plus review fixes (ensure parent marks itself; hint reads match writer caps); merge pending | sibling-home focused 161–174 pass / 0 fail; writer guards red before fix | #6108 CI | coordinator bug; hand off Claude intercept settings migration to the picker CA lane | + +After #6099 merged, the three remaining slices were one commit behind `dev`. Rebasing and re-running each PR in turn would cost three serial CI cycles, so they are integrated as one batch PR on the new `dev`. The batch keeps one commit per slice with its trailers, and its exact-head CI is the union proof. The per-slice PRs are closed in favor of the batch once it merges. + +Final `dev` CI run, expected jobs, failure repair, remaining risk and overlapping files: pending evidence. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/060_sibling_home_client_sync.md b/devlog/_plan/260927_release_train_4/bug-hardening/060_sibling_home_client_sync.md new file mode 100644 index 00000000000..eeb69f4f220 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/060_sibling_home_client_sync.md @@ -0,0 +1,52 @@ +# B6 — cross-home client sync ownership + +## Main decision (session `01a0e37e`) + +The map below is the investigator's full inventory. This train lands the narrow core only. One early cross-home owner check runs in `ocx start` before journal reconciliation and before the protected client writers run. If the same-home discovery finds nothing, the check reads two hint sources. The first is the default home's runtime record, and only when the resolved `OPENCODEX_HOME` differs from `/.opencodex`. The second is the loopback ports referenced by the OpenCodex-managed Grok `base_url` and the managed Codex provider `base_url`. It identity-probes each distinct candidate port with the existing `probePortOwner`. A live OpenCodex that is not this process sets the existing one-way sibling mark. The writers that already honor the mark (Codex sync/inject, Grok startup sync and exit strip, catalog refresh, launchd env injection, start's shell-hook reconciliation) then stay off shared client files. Two writers that do not honor it today get guards in this PR: the Claude agent roster startup sync and the Grok branch of ensure-desired-integrations (see the folds below). An unidentified, foreign, remote or stale reference grants nothing, so a lone custom-home instance still syncs. Explicit `ocx sync` / `ocx grok apply` are unchanged. + +The new logic lives in a small sibling module (for example `src/cli/cross-home-owner.ts`) because `src/cli/index.ts` is 1,999 lines against the 2,000-line ratchet threshold. The regression test is a new `tests/cli/sibling-home-client-sync.test.ts`, registered in both layout files. It drives the discovery and start-path decision with an isolated `HOME`, `GROK_HOME` and `CODEX_HOME`, and asserts that the shared Grok file is byte-identical through a secondary start and exit and that a lone custom home still injects. Any writer found not to honor the mark gets a mark check only if the change is small, and otherwise is listed as a residual. + +The promise is limited to those protected writers. It is not "no shared write at all". Out of this PR: the Claude intercept settings migration (`src/claude/intercept/runtime.ts:154`, run during `startServer` when intercept is enabled) can still rewrite an owned `~/.claude/settings.json` env from a sibling. That code is in the picker CA lane's area, so it is recorded as a remaining risk and handed off in the lane report. The Codex shim auto-restore preflight is a launcher repair rather than a port rewrite and is recorded as a residual. The investigator inventory's trigger-test list below is superseded by the test list in the folds, which covers only the protected writers. + +### Audit folds (auditor `01a0e389-ffc4`, FAIL → folded) + +- **Placement.** Split `findProxyOwnerBeforeJournalRecovery` (`src/cli/index.ts:352-376`) so its probe and its journal reconciliation are separate steps. In `handleStart` (`:419`), the order is: consume the handoff marker, probe the same-home owner, and then, only if none is found, run the cross-home check. On a hit, call `markSiblingStart(ownerPort)` (`src/codex/sibling-start.ts:42`) and set `siblingStart = true` exactly as the same-home path does at `:444`. Journal reconciliation runs only after that decision, and only for a non-sibling. Recheck under the bind lease at `:483`, which the same-home path already does. +- **Writers that do not honor the mark today.** `syncClaudeAgentDefsAtProxyStartup` (`src/cli/claude-agent-startup-sync.ts:75`) writes and prunes the shared roster, including on its OFF path, and needs an entry guard. `ensureGrokFenceMatchesDesired` (`src/cli/ensure-desired-integrations.ts:108`) strips in its OFF branch (`:114`) and needs a sibling check before both branches that reports a sibling skip. Codex sync/inject, Grok startup sync and exit strip, catalog refresh, launchd env injection and start's shell-hook reconciliation already honor it. +- **Identity.** `probePortOwner` (`src/server/proxy-liveness.ts:564`) rejects a plain foreign `/healthz`, but it does not say whether the responder is this process. A hit counts only when the reported PID is a positive integer different from `process.pid`. A legacy `pid: null` response or any uncertain identity grants nothing, so the instance falls back to today's behavior. The check stays best-effort and never claims certainty it lacks. +- **Hints.** The default-home record is `/.opencodex/runtime-port.json`, read by explicit path, because `readRuntimePort()` resolves the current home. The Grok hint is the fenced `[model_providers.opencodex].base_url`, read from bounded bytes via `resolveGrokHome` and `findManagedRegion` (`src/grok/inject.ts:55,69`). The Codex hint comes from the managed provider in `getCodexHome()/config.toml`, reusing the routing-drift or journal helpers with `{ readOnly: true }`. Only a valid loopback URL with an explicit port qualifies. Remote, malformed and user-owned URLs are ignored. +- **Known limits (recorded, not solved).** If two homes start before either publishes a record or a client URL, the check finds nothing. The same gap exists while the primary is briefly down during its own restart. This PR narrows the observed failure (a running primary plus a later sibling); it does not add a cross-home lock. +- **Tests.** In the new `tests/cli/sibling-home-client-sync.test.ts`: a fake loopback `/healthz` identity server covers a live other-PID owner (sibling), the same PID (not a sibling), `pid: null`, a foreign service, a stale/closed port, and a remote or malformed URL. Isolated homes with pinned `HOME`, `GROK_HOME`, `CODEX_HOME` and `CLAUDE_CONFIG_DIR` cover the decision driving the start path, with the Grok, Codex and Claude roster files byte-identical for a sibling and a lone custom home still syncing. Add guard tests for the two newly guarded writers. + +## Investigator inventory + +Depends on the release-train roadmap. A second proxy with its own `OPENCODEX_HOME` can miss the foreground owner because `findLiveProxy` reads only the second home's records and configured port. It then starts unmarked, changes shared client routing to its own port, and on exit strips or restores state it never owned. Repair startup ownership discovery from the existing managed client destinations before any shared client write, while preserving automatic sync for a lone custom-home installation. This is a read-only plan; no source patch or regression run is claimed here. + +## Exact file map + +| Path | Change from current behavior | +| --- | --- | +| `src/config/paths.ts` | REUSE `getConfigDir` (`:19-24`): a nonempty trimmed `OPENCODEX_HOME` is `resolve(expandUserPath(raw))`; otherwise the default is `/.opencodex`. Compare resolved paths, not raw env spelling. Do not make a custom home by itself mean “sibling.” | +| `src/cli/index.ts`, `src/server/proxy-liveness.ts` | MODIFY startup before `findProxyOwnerBeforeJournalRecovery`, `startServer`, and exit-teardown ownership are chosen (`index.ts:352-376, 415-445, 564-665`). The current owner probe sees only this home's record/configured port and can call `reconcileJournal` before the sibling mark (`index.ts:370-374`); defer that reconciliation until cross-home ownership is decided. For an independent home, inspect bounded, local managed client destinations and identity-probe their referenced public ports with `probePortOwner` (`proxy-liveness.ts:554-574`); if a different live opencodex owns a shared destination, set the existing one-way sibling mark before any client mutation or binding. Do not treat any 200 response, foreign service, remote URL, or this process as owner. Carry the decision through restart/handoff and exit teardown. Avoid adding lines to the already 1,999-line CLI file: move discovery to a small sibling module. | +| `src/codex/sibling-start.ts`, `src/codex/desired-state.ts` | MODIFY the owner reason and diagnostic so a cross-home client-reference discovery is covered as well as the existing same-home configured-port discovery. Current guards (`sibling-start.ts:41-66`, `desired-state.ts:80-85, 111-148, 256-268, 304-305`) depend on the mark; `clientIntegrations.codex/grok === false` and hub-without-loopback already disable auto-sync. Preserve these controls and the one-way mark. | +| `src/grok/inject.ts`, `src/grok/sync.ts`, `src/cli/ensure-desired-integrations.ts` | READ the OpenCodex-owned Grok provider `base_url` from `GROK_HOME` or `~/.grok/config.toml` (`inject.ts:55-56, 1092-1127`) without changing the file. Startup sync (`index.ts:715-735`, `sync.ts:31-65`) writes the managed block; normal shutdown/stop strips it (`index.ts:610-616`). It does not restore the previous owner's port. Ensure's ON path injects and OFF path strips (`ensure-desired-integrations.ts:97-133`); apply the same owner check there, and distinguish an owner skip from a hub skip or explicit OFF. An explicit `ocx grok apply` (`cli/integrations.ts:142-157`) remains an operator-requested action. | +| `src/codex/sync.ts`, `src/codex/inject.ts`, `src/codex/catalog/retained-sync.ts`, `src/server/index.ts` | READ the managed routing target in effective `CODEX_HOME/config.toml` (`codex/paths.ts:6-29`) as a second owner hint. Startup Codex sync (`sync.ts:99-133`) changes config, profile, catalog, cache, and sometimes history; the injector only refuses a sibling once marked (`inject.ts:185-201`). Server startup can invalidate the Codex cache and start native-main lifecycle before CLI post-bind sync (`server/index.ts:273-277, 600`). Keep these behind the early mark. Normal shutdown restores native routing, but a missed sibling can replay its own journal over the primary's state. Explicit `ocx sync` (`cli/dispatch.ts:433-499`) remains usable from a custom home and retains its existing desired-state and service-ownership checks. | +| `src/cli/claude-agent-startup-sync.ts`, `src/claude/agents-inject.ts`, `src/server/system-env.ts`, `src/server/system-env-shell.ts` | Startup can write/prune marked `~/.claude/agents/ocx-*.md` (`agents-inject.ts:244-298`); shutdown does not restore the roster. `claudeCode.enabled === false` or `injectAgents === false` prunes, and hub role skips the startup helper (`claude-agent-startup-sync.ts:75-98`). macOS `claudeCode.systemEnv === true` can write launchd `ANTHROPIC_*` values and this home's `claude-env.sh` (`system-env.ts:195-258`); start reconciles an owned `~/.zshrc` hook even when injection is false (`cli/index.ts:657-664`, `system-env-shell.ts:149-195, 229-238`). Exit reverts tracked env but cannot recover another home's overwritten values (`system-env.ts:384-415`). Include these in the early owner veto and skip shell-hook reconciliation for that owner. | +| `src/claude/intercept/runtime.ts`, `src/claude/intercept/settings.ts` | FIX a separate startup gap: an enabled Claude intercept calls `migrateClaudeInterceptSettings` during `startServer` (`runtime.ts:135-155`), rewriting an owned `~/.claude/settings.json` proxy/CA env (`settings.ts:173-190`) without consulting the sibling mark. Guard the global migration while allowing this proxy's own isolated listener and `/claude-intercept` assets to start. Do not remove an absent or foreign env. Shutdown stops the listener; it does not restore the settings migration. | +| `src/integrations/catalog-refresh.ts`, `src/cli/index.ts` | The startup Raycast refresh (`index.ts:172-189, 700`) can rewrite the owned provider in `~/.config/raycast/ai/providers.yaml`; `catalog-refresh.ts:12-19` currently skips only marked siblings. The early mark must cover this path; no shutdown restore exists. Other pi/Aside/omo refresh callers use the same helper and must remain protected. | +| `src/cli/root.ts`, `src/cli/codex-shim-autorestore.ts`, `src/codex/shim.ts`, `src/service/launchd.ts` | AUDIT separately from client routing. `runCli` invokes shim auto-restore before `handleStart` can mark a sibling (`root.ts:68-112`, `codex-shim-autorestore.ts:35-45`); with an existing shim and `codexShimAutoRestoreEnabled`, it can replace tracked Codex launcher paths (`shim.ts:1079-1176`). That is an enabled repair, not a port rewrite, and shutdown does not undo it. If the intended policy is literally no global startup mutation by a secondary, defer only this preflight on `start` until owner classification while retaining explicit `ocx codex-shim install`. Service plist installation and launchctl bootstrap are explicit `ocx service install/repair` operations (`service/launchd.ts:462-478`); ordinary `ocx start` only observes the manager and must not rewrite its definition. | +| `tests/cli/cli-start-journal-order.test.ts`, `tests/cli/hub-gated-local-clients.test.ts`, `tests/providers/xai/grok-lifecycle.test.ts`, `tests/claude-integration/claude-agent-startup-sync.test.ts` | MODIFY focused regressions using two isolated OpenCodex homes but one isolated `HOME`, `CODEX_HOME`, `GROK_HOME`, and `CLAUDE_CONFIG_DIR`. Start primary on an arbitrary nondefault port with its home/configured port; start secondary with a different configured port so current discovery misses it. Assert byte-identical shared Grok/Codex/Claude/Raycast destinations through secondary start, stop and hard-kill recovery; assert primary remains usable. Also prove lone custom-home startup still syncs and explicit sync/apply work. Add a dedicated `tests/cli/sibling-home-client-sync.test.ts` if the existing large lifecycle test would grow materially. | +| `structure/runtime.md`, `structure/codex-home.md`, `structure/clients/integrations.md`, `docs-site/src/content/docs/reference/cli/lifecycle.md`, `docs-site/src/content/docs/guides/grok-build.md` | UPDATE present-tense ownership and startup behavior. `lifecycle.md:19-36` promises an independent sibling leaves Codex/Grok/Claude on the live proxy; `grok-build.md:3-8` promises automatic registration. Document the live-owner condition, custom-home sole-instance behavior, and explicit sync actions. Review maintained translations for contradictory wording. | + +## Audit and activation + +Recommend **port-reference identity probing (option b)**, with a single early owner decision shared by all startup writers. A custom `OPENCODEX_HOME` alone is not enough evidence: `docs-site/.../reference/configuration.md:6-8, 95-97` documents it as the normal config-location override, and the lifecycle and Grok guides promise startup sync. A sole custom-home user, including portable/container installations, must continue to get it. Probe only validated local OpenCodex-managed destinations (and the default home's runtime record as a corroborating hint); a current owner proven by identity at a different port closes the existing sibling gate. A client may reference the data-only loopback listener, where `/healthz` is absent; resolve its public port through a corroborating runtime record or classify ownership as uncertain, never as proven absent. Bound reads, ports, retries and timeouts; do not follow arbitrary remote URLs, expose tokens, or accept a forged generic health page. Recheck immediately before shared writes where async discovery creates a race. + +Option (a), unconditional custom-home auto-sync suppression with a new opt-in such as `OCX_SYNC_GLOBAL_CLIENTS_ON_START=1`, is smaller but changes documented behavior for the only instance using a custom home and for isolated test fixtures. Keep it as a clearly documented fallback only if owner detection cannot be made sound. Option (c), record-and-restore, does not prevent the outage while the second proxy runs and cannot repair SIGKILL, concurrent updates, or a primary changing its own config meanwhile. No new opt-in is required under option (b): `ocx sync` and `ocx grok apply` are already explicit operator actions; do not route them through the unattended startup veto. There is no `--no-sync` option on the documented `ocx start` syntax (`reference/cli/lifecycle.md:19`). + +Trigger tests must go red before the repair and green after: primary at an arbitrary port, second home configured for a different port, shared Grok `base_url` unchanged after secondary start and exit; Codex config/catalog, Claude agent roster, intercept settings, launchd/shell hook and owned Raycast contribution unchanged; no primary teardown from `ocx stop` of the secondary. Include a custom-home-only case that still injects, a foreign or stale referenced port that grants no sibling status, and a data-loopback-reference case. The existing same-home sibling and hub/OFF tests must stay green. `tests/preload.ts:29-49, 74-82` installs the test-home guard and replaces `HOME`, `OPENCODEX_HOME`, and `CODEX_HOME`; subprocess fixtures must also pin `GROK_HOME` and `CLAUDE_CONFIG_DIR` and never inspect the operator's real files. Assets are user client routing, credentials inside Claude settings/env, and provider catalogs; entrypoint is a second local `ocx start`/ensure; trust boundary is one isolated proxy home reaching machine/user-global client state. Treat client-file URLs as untrusted input, require OpenCodex identity, and preserve the service-home and sibling mutation guards. + +## Verification and delivery + +Implementation should run `bun test tests/cli/sibling-home-client-sync.test.ts tests/cli/cli-start-journal-order.test.ts tests/cli/hub-gated-local-clients.test.ts tests/providers/xai/grok-lifecycle.test.ts tests/claude-integration/claude-agent-startup-sync.test.ts tests/codex-integration/codex-desired-state.test.ts`, omitting the new file until created. A new test in `tests/cli/` requires entries in **both** `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`; the existing named files are registered already. Run `bun run test:changed`, the direct subprocess/source-oracle files that graph selection misses, `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan`, and the file-size and test-layout guards. Full `bun run test` is the default before review readiness; if contention makes it impractical, record exact focused commands/results and the CI coverage left. No tests were run for this read-only plan. + +The file-size baseline caps `src/server/index.ts` at 893 lines (891 now), `src/codex/inject.ts` at 987 (931 now), and `src/codex/catalog/sync.ts` at 52 (52 now). Do not add a line to the last file or raise any cap; move cases to siblings. `src/cli/index.ts` is 1,999 lines, so keep new logic out of it. Update the owning structure documents and translated user guides with the code change, then inspect exact-head CI and perform the repository's required security review for credentials/client files before the PR is marked ready. diff --git a/devlog/_plan/260927_release_train_4/bug-hardening/_handoff.md b/devlog/_plan/260927_release_train_4/bug-hardening/_handoff.md new file mode 100644 index 00000000000..18dd11d88db --- /dev/null +++ b/devlog/_plan/260927_release_train_4/bug-hardening/_handoff.md @@ -0,0 +1,15 @@ +# Bug-hardening lane handoff + +Historical (2026-09-27): this is the interrupted session's handoff. Current status and merged SHAs are in [050_disposition_and_ci.md](050_disposition_and_ci.md). + +Stopped on coordinator request. All repository changes are local to `/Users/jun/.codex/worktrees/t4-bug-hardening/opencodex`; no branch was pushed, no lane PR was opened or merged, and no issue or source PR was commented on or closed. The working branch is `codex/t4-bug-hardening-roadmap`, based on last observed `origin/dev` `24b2f39b77`. The docs-first roadmap was committed as `1c2ac3cb79`; the local WIP commit containing this handoff preserves the subsequent P-phase document edits. + +FSM session `01a0e337-e190-7d11-a658-2e9bf733c52f`: `wp0` docs-first cycle closed with reviewer PASS, privacy-scan receipt and criterion `c-1` met. `wp1` B1 adapter bounds is in P; `wp2` NativeTray, `wp3` restart disposition, `wp4` SSH Link, `wp5` assigned-item disposition remain. Coordinator-added `wp6` sibling-home client-sync isolation and `wp7` final dev CI are registered in the goalplan; their 060/070 decade docs have not been written. The original goal remains incomplete. + +Last observed candidate states: #6081 head `8a4399f` is planned as a carry with one parser-owned pre-allocation call cap; #6083 head `ccfb8e63` needs a suffix-width-aware remint cursor. #6082 is planned for a separate Swift carry. #6085 is held after independent review found unsafe restart retry and ambiguous start outcomes. #6076, #5964, #6030, #5831 and #5539 are held for the reasons in `000_plan.md`. #6088 is planned as an SSH argv/diagnostic fix; #4956 and #4761 remain open. These are dispositions, not merged outcomes. + +Local baseline on `24b2f39b77`: adapter focused tests 88 pass; NativeTray executable 53 assertions pass; restart focused tests 45 pass; Link focused tests 36 pass; typecheck and privacy scan exit 0. No production source has been edited. The B1 architect proposed decisions B1-A1–A5 and reflected ALIGNED on the revised `010_adapter_bounds.md`; B1 has not entered independent A audit. + +New sibling-home assignment: read-only trace found current startup discovery uses the second `OPENCODEX_HOME`'s records/configured port, so a second home on a different port can miss the primary and leave the existing sibling write guards unarmed. Another read-only audit found no authoritative cross-home registry for an arbitrary-port foreground primary. No local proxy was started by this lane and no real home file was changed. The coordinator requires any future proxy QA to set temporary `HOME` as well as `OPENCODEX_HOME`/`CODEX_HOME`, preferably disabling client sync. Run `test:changed` or the full suite from a same-commit verification worktree at `/private/tmp/t4-bug-hardening-verify`; do not bypass protected-cleanup guards in the source worktree. Every subagent used in this lane was explicitly `gpt-6-sol`; all are closed. + +Next owner: refresh `origin/dev` and candidate heads; complete the new sibling-home decade docs and choose a safe explicit-home policy, then audit B1, implement adopted fixes in this dedicated worktree, verify from the temporary checkout, and use exact-head CI before any authorized merge. Keep all security pre-disclosure notes in ignored scratch space. Review `000_plan.md`, `010_adapter_bounds.md`, `050_disposition_and_ci.md`, and the other numbered phase docs before acting.