From 9e9490b721e11496472bb5a8a592cae07e2d380c Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 02:06:30 +0900 Subject: [PATCH 01/16] feat: add Factory Droid client integration --- .../skills/verify-droid-integration/SKILL.md | 90 ++++ .../features/README.md | 12 + .../features/catalog-export.md | 24 + .../features/image-input.md | 22 + .../features/long-context.md | 21 + .../features/text-stream-reasoning.md | 22 + .../features/tool-roundtrip.md | 23 + .../verify-droid-integration/scripts/run.ts | 479 ++++++++++++++++++ assets/factory-droid-integration.png | Bin 0 -> 44812 bytes design-debt.md | 8 + docs-site/astro.config.mjs | 2 +- .../content/docs/fr/guides/integrations.md | 13 +- .../src/content/docs/guides/factory-droid.md | 50 +- .../src/content/docs/guides/integrations.md | 14 +- .../content/docs/ko/guides/factory-droid.md | 48 +- .../content/docs/tr/guides/integrations.md | 13 +- .../content/docs/zh-tw/guides/integrations.md | 12 +- gui/public/provider-icons/factory-droid.svg | 5 + gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 4 +- gui/src/components/integration-marks.ts | 1 + gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/vi.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Integrations.tsx | 4 +- .../integrations/FileIntegrationPage.tsx | 2 + .../integrations/IntegrationsOverview.tsx | 2 +- gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/integration-tabs.ts | 2 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 5 +- gui/tests/fr-localization.test.ts | 2 + gui/tests/integrations-api.test.ts | 4 +- gui/tests/integrations-overview-rows.test.ts | 3 +- gui/tests/integrations-surfaces.test.tsx | 2 +- gui/tests/locale-parity.test.ts | 2 + scripts/test-layout/layout.json | 1 + src/cli/dispatch.ts | 2 +- src/cli/export-command.ts | 8 +- src/cli/help.ts | 2 +- src/cli/registry.ts | 4 +- src/clients/config-export.ts | 23 + src/clients/config-export/contracts.ts | 3 +- src/clients/config-export/droid.ts | 76 +++ src/integrations/catalog-refresh.ts | 2 +- src/integrations/registry.ts | 7 + src/server/management/config-routes.ts | 2 +- structure/clients/claude-desktop.md | 2 +- structure/clients/integrations.md | 10 + structure/config.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/runtime.md | 2 +- tests/cli/cli-export-command.test.ts | 6 + tests/clients/droid-client.test.ts | 213 ++++++++ tests/clients/integrations-state.test.ts | 2 +- .../clients/sync-client-integrations.test.ts | 6 +- .../client-config-export-new-clients.test.ts | 2 +- tests/config/client-config-export.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 1 + tests/gui/integrations-invariants.test.ts | 4 +- 67 files changed, 1258 insertions(+), 52 deletions(-) create mode 100644 .agents/skills/verify-droid-integration/SKILL.md create mode 100644 .agents/skills/verify-droid-integration/features/README.md create mode 100644 .agents/skills/verify-droid-integration/features/catalog-export.md create mode 100644 .agents/skills/verify-droid-integration/features/image-input.md create mode 100644 .agents/skills/verify-droid-integration/features/long-context.md create mode 100644 .agents/skills/verify-droid-integration/features/text-stream-reasoning.md create mode 100644 .agents/skills/verify-droid-integration/features/tool-roundtrip.md create mode 100755 .agents/skills/verify-droid-integration/scripts/run.ts create mode 100644 assets/factory-droid-integration.png create mode 100644 design-debt.md create mode 100644 gui/public/provider-icons/factory-droid.svg create mode 100644 src/clients/config-export/droid.ts create mode 100644 tests/clients/droid-client.test.ts diff --git a/.agents/skills/verify-droid-integration/SKILL.md b/.agents/skills/verify-droid-integration/SKILL.md new file mode 100644 index 00000000000..2787457aae2 --- /dev/null +++ b/.agents/skills/verify-droid-integration/SKILL.md @@ -0,0 +1,90 @@ +--- +name: verify-droid-integration +description: Verify the Factory Droid CLI outbound integration against a live OpenCodex proxy, including every active model, streaming, reasoning, tools, images, and long context. +--- + +# Verify Factory Droid integration + +Use this skill after changing Droid export, client integration ownership, model metadata, or the +OpenAI-compatible chat path. It drives the real `droid exec` CLI with a process-only settings file. +It never writes `~/.factory/settings.json`. + +## Launch + +Use the user's existing signed-in OpenCodex instance when `curl -fsS +http://127.0.0.1:10100/healthz` succeeds. The provider credentials live in that instance's +`OPENCODEX_HOME`, so do not start a second proxy against the same home. + +If no proxy is running, start this checkout and keep its PID: + +```bash +node_modules/bun/bin/bun.exe run src/cli/index.ts start --port 10100 +``` + +Wait for `http://127.0.0.1:10100/healthz`. Stop only the PID you started. + +## Doctor + +Run the no-inference pass first: + +```bash +node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts --dry-run +``` + +It exports the live active catalog through `ocx export --client droid`, checks proxy health and +Droid version, validates unique generic-provider rows, and asks Droid to resolve every custom model +ID with `--list-tools`. A failure makes the instance unfit for the paid live pass. + +## Drive + +Run the full matrix: + +```bash +node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts \ + --concurrency 3 \ + --timeout-ms 180000 \ + --long-words 20000 +``` + +The active model set is frozen from the exported config at run start. Each model gets catalog, +normal text, stream JSON, reasoning when advertised, Read tool call/result, image Read/OCR when +advertised, and long-context coverage. Use `--models ` or `--cases ` only for diagnosis; +the PR proof is the unfiltered run. + +## Evidence + +Each run writes `.tmp/verify-droid-integration//`. The directory contains the generated +settings, export and health evidence, one command/result JSON per model and case, `summary.json`, +and `failures.json`. The command, exit code, stdout, stderr, timeout state, and duration preserve +both the action and result. Keep the full directory through review. + +Passing means no `fail` result. `unsupported` is accepted only when the exported model lacks the +corresponding reasoning or image capability. The generated settings must remain free of real keys. + +## Cleanup + +The helper starts no proxy and changes no persistent Factory settings. Delete diagnostic prompt and +marker files only by deleting a run directory after its evidence is no longer needed. Do not remove +the proof directory during verification. If this skill started a proxy, stop that exact PID after +the final drive. Droid records its own exec sessions in the signed-in user's normal history; the +helper records their IDs in command output and does not delete unrelated history. + +## Helpers + +`scripts/run.ts` is executable and self-documenting: + +```bash +node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts --help +``` + +## Failure recovery + +Read `failures.json`, rerun only the affected model and case, and preserve the new run beside the +original. After a timeout or transport failure, repeat the Doctor pass before another paid request. +If health is green but Droid remains wedged, end that `droid exec` process and start a fresh run; +never reuse a failed session. + +## Feature map + +Read [features/README.md](features/README.md), then maintain it with +`/maintain-verification-skill` after user-facing integration changes. diff --git a/.agents/skills/verify-droid-integration/features/README.md b/.agents/skills/verify-droid-integration/features/README.md new file mode 100644 index 00000000000..40d76af531d --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/README.md @@ -0,0 +1,12 @@ +# Factory Droid verification feature map + +| Feature | Proof | +| --- | --- | +| [Catalog export](catalog-export.md) | Active models become stable, keyless Factory custom rows | +| [Text, stream, and reasoning](text-stream-reasoning.md) | Droid completes normal and stream JSON requests and applies advertised effort | +| [Tool round trip](tool-roundtrip.md) | The model emits a Read call, receives the result, and uses it | +| [Image input](image-input.md) | An image-capable model reads pixels through Droid and returns the expected headline | +| [Long context](long-context.md) | Both boundary markers survive a deterministic long prompt | + +The full verifier drives every exported active model. A filtered rerun is diagnostic evidence, not +a replacement for the complete matrix. diff --git a/.agents/skills/verify-droid-integration/features/catalog-export.md b/.agents/skills/verify-droid-integration/features/catalog-export.md new file mode 100644 index 00000000000..775551e2d78 --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/catalog-export.md @@ -0,0 +1,24 @@ +# Catalog export + +## Sub-features + +- Live active-model filtering +- Stable `custom:opencodex:` IDs +- Exact generic Chat provider and loopback URL +- A 16,384-token response ceiling instead of the full context window +- Context, image, and reasoning metadata +- Keyless config and per-row ownership + +## How to get to it (user POV) + +Open **Integrations → Factory Droid**, or run `ocx integration client enable --client droid`. +For a non-persistent preview, run `ocx export --client droid --out --force`. + +## Driving it with Droid CLI + +Run the verifier with `--dry-run`. Inspect `settings.json` and each model's `catalog.json`. + +## Gotchas + +The export includes hub-approved Fast rows as active selectors. Do not compare it with a raw provider +count. The inbound Factory bridge in the docs is a separate direction. diff --git a/.agents/skills/verify-droid-integration/features/image-input.md b/.agents/skills/verify-droid-integration/features/image-input.md new file mode 100644 index 00000000000..f2f2d3ac67d --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/image-input.md @@ -0,0 +1,22 @@ +# Image input + +## Sub-features + +- Catalog image capability maps to `noImageSupport: false` +- Droid Read loads the image +- The routed model reads visible pixels + +## How to get to it (user POV) + +Select an image-capable OpenCodex custom model and ask Droid to inspect an image file. + +## Driving it with Droid CLI + +Run `--cases image`. The helper asks Droid to read `assets/pr-gate-screenshot-required.png` without +putting the headline in the prompt, then requires the exact pixel-only answer plus a matching Read +call and result. + +## Gotchas + +Models exported with `noImageSupport: true` are `unsupported`. Filename or alt-text inference is not +enough; the expected headline exists only in the image pixels. diff --git a/.agents/skills/verify-droid-integration/features/long-context.md b/.agents/skills/verify-droid-integration/features/long-context.md new file mode 100644 index 00000000000..d7cf6e7b0c0 --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/long-context.md @@ -0,0 +1,21 @@ +# Long context + +## Sub-features + +- Prompt-file ingestion +- Deterministic context padding +- Recall of markers at both boundaries + +## How to get to it (user POV) + +Pass a long prompt file to a selected OpenCodex custom model with `droid exec --file `. + +## Driving it with Droid CLI + +Run `--cases long-context --long-words 20000`. The helper generates model-specific start and end +markers and requires both in the exact final response. + +## Gotchas + +`--long-words` is a deterministic stress size, not an exact tokenizer count. A timeout is a failure, +not an unsupported result; rerun after Doctor before attributing it to the model. diff --git a/.agents/skills/verify-droid-integration/features/text-stream-reasoning.md b/.agents/skills/verify-droid-integration/features/text-stream-reasoning.md new file mode 100644 index 00000000000..a5d9e8f9d80 --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/text-stream-reasoning.md @@ -0,0 +1,22 @@ +# Text, stream, and reasoning + +## Sub-features + +- Normal JSON completion +- Stream JSON lifecycle with a final completion +- Advertised reasoning-effort selection + +## How to get to it (user POV) + +Select an `OpenCodex: ...` custom model in Droid, then run a prompt normally or pass +`--output-format stream-json` and `--reasoning-effort ` to `droid exec`. + +## Driving it with Droid CLI + +Run the full verifier or `--cases text,stream,reasoning`. Proof requires exact marker text, a stream +`system` event, a `completion` event, and the requested effort in the init event. + +## Gotchas + +A model with no exported effort ladder is `unsupported` for the reasoning case. A successful final +line alone does not prove the stream lifecycle. diff --git a/.agents/skills/verify-droid-integration/features/tool-roundtrip.md b/.agents/skills/verify-droid-integration/features/tool-roundtrip.md new file mode 100644 index 00000000000..04fb8272cee --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/tool-roundtrip.md @@ -0,0 +1,23 @@ +# Tool round trip + +## Sub-features + +- Read tool definition reaches the model +- Tool call arguments select the marker file +- Droid returns a successful tool result +- The final answer includes content from that result + +## How to get to it (user POV) + +Run Droid in a repository and ask the selected OpenCodex model to read a file. + +## Driving it with Droid CLI + +Run `--cases tool`. The helper creates a unique marker file inside the evidence directory and +requires a Read call for that exact path, its matching successful result containing the marker, +and an exact final completion. + +## Gotchas + +Prompt compliance without a tool event is a failure. The verifier limits available tools to Read so +the proof remains read-only. diff --git a/.agents/skills/verify-droid-integration/scripts/run.ts b/.agents/skills/verify-droid-integration/scripts/run.ts new file mode 100755 index 00000000000..8773612b2c0 --- /dev/null +++ b/.agents/skills/verify-droid-integration/scripts/run.ts @@ -0,0 +1,479 @@ +#!/usr/bin/env bun +import { mkdir } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; + +type Status = "pass" | "fail" | "unsupported"; + +interface DroidModel { + id: string; + model: string; + displayName: string; + baseUrl: string; + provider: string; + noImageSupport?: boolean; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string; +} + +interface DroidSettings { + customModels: DroidModel[]; +} + +interface CommandResult { + command: string[]; + exitCode: number; + stdout: string; + stderr: string; + durationMs: number; + timedOut: boolean; +} + +interface CaseResult { + status: Status; + detail: string; + evidence?: string; + durationMs?: number; +} + +interface ModelResult { + id: string; + model: string; + cases: Record; +} + +const args = process.argv.slice(2); + +function option(name: string, fallback?: string): string | undefined { + const index = args.indexOf(name); + if (index < 0) return fallback; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(name + " requires a value"); + return value; +} + +function positiveInt(name: string, fallback: number): number { + const value = Number(option(name, String(fallback))); + if (!Number.isInteger(value) || value < 1) throw new Error(name + " must be a positive integer"); + return value; +} + +if (args.includes("--help")) { + console.log([ + "Usage: bun .agents/skills/verify-droid-integration/scripts/run.ts [options]", + "", + "Options:", + " --dry-run Export and validate every active Droid model without model requests", + " --run-dir Evidence directory (default: .tmp/verify-droid-integration/)", + " --concurrency Models tested concurrently (default: 3)", + " --timeout-ms Per-request timeout (default: 180000)", + " --long-words Deterministic long-context padding words (default: 20000)", + " --models Limit to exact custom model IDs or upstream selectors", + " --cases Limit full runs to text,stream,reasoning,tool,image,long-context", + ].join("\n")); + process.exit(0); +} + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const runId = new Date().toISOString().replaceAll(":", "-") + "-" + process.pid; +const requestedRunDir = option("--run-dir"); +const runDir = requestedRunDir + ? (isAbsolute(requestedRunDir) ? requestedRunDir : resolve(repoRoot, requestedRunDir)) + : join(repoRoot, ".tmp", "verify-droid-integration", runId); +const settingsPath = join(runDir, "settings.json"); +const concurrency = positiveInt("--concurrency", 3); +const timeoutMs = positiveInt("--timeout-ms", 180_000); +const longWords = positiveInt("--long-words", 20_000); +const dryRun = args.includes("--dry-run"); +const selectedModels = new Set((option("--models", "") ?? "").split(",").filter(Boolean)); +const selectedCases = new Set((option("--cases", "") ?? "").split(",").filter(Boolean)); +const allCases = ["text", "stream", "reasoning", "tool", "image", "long-context"]; +for (const item of selectedCases) { + if (!allCases.includes(item)) throw new Error("unknown case: " + item); +} + +await mkdir(runDir, { recursive: true }); + +async function runCommand(command: string[], cwd: string, timeout: number): Promise { + const started = Date.now(); + const proc = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe", env: process.env }); + const stdoutPromise = new Response(proc.stdout).text(); + const stderrPromise = new Response(proc.stderr).text(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + }, timeout); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + stdoutPromise, + stderrPromise, + ]); + clearTimeout(timer); + return { command, exitCode, stdout, stderr, durationMs: Date.now() - started, timedOut }; +} + +async function writeEvidence( + modelDir: string, + name: string, + result: CommandResult, +): Promise { + const path = join(modelDir, name + ".json"); + await Bun.write(path, JSON.stringify(result, null, 2) + "\n"); + return path; +} + +function events(stdout: string): Record[] { + return stdout.split(/\r?\n/).filter(Boolean).flatMap(line => { + try { + const value = JSON.parse(line); + return value && typeof value === "object" ? [value as Record] : []; + } catch { + return []; + } + }); +} + +function completionText(output: string): string { + const parsed = events(output); + const completion = parsed.find(event => event.type === "completion"); + if (completion && typeof completion.finalText === "string") return completion.finalText; + try { + const value = JSON.parse(output) as Record; + for (const key of ["finalText", "result", "text", "output"]) { + if (typeof value[key] === "string") return value[key] as string; + } + } catch { + // Plain text and mixed diagnostic output are checked below. + } + return output; +} + +function verdict(result: CommandResult, marker: string): CaseResult { + const ok = !result.timedOut && result.exitCode === 0 && completionText(result.stdout).trim() === marker; + return { + status: ok ? "pass" : "fail", + detail: result.timedOut + ? "timed out" + : ok + ? "observed " + marker + : "exit " + result.exitCode + "; exact response mismatch", + durationMs: result.durationMs, + }; +} + +function readRoundTrip( + parsed: Record[], + filePath: string, +): { called: boolean; returned: boolean; value?: unknown } { + const call = parsed.find(event => { + if (event.type !== "tool_call" || event.toolName !== "Read") return false; + const parameters = event.parameters; + return parameters && typeof parameters === "object" + && (parameters as Record).file_path === filePath; + }); + if (!call || typeof call.id !== "string") return { called: false, returned: false }; + const result = parsed.find(event => + event.type === "tool_result" && event.id === call.id && event.isError === false); + return { called: true, returned: result !== undefined, value: result?.value }; +} + +function safeDirName(model: DroidModel): string { + const slug = model.model.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80); + return slug + "-" + Bun.hash(model.id).toString(16); +} + +const exportCommand = [ + process.execPath, + "run", + "src/cli/index.ts", + "export", + "--client", + "droid", + "--out", + settingsPath, + "--force", +]; +const exportResult = await runCommand(exportCommand, repoRoot, timeoutMs); +await Bun.write(join(runDir, "export.json"), JSON.stringify(exportResult, null, 2) + "\n"); +if (exportResult.exitCode !== 0 || exportResult.timedOut) { + throw new Error("Droid config export failed; see " + join(runDir, "export.json")); +} + +const settings = await Bun.file(settingsPath).json() as DroidSettings; +if (!Array.isArray(settings.customModels) || settings.customModels.length === 0) { + throw new Error("exported settings contain no customModels"); +} +const ids = settings.customModels.map(model => model.id); +if (new Set(ids).size !== ids.length) throw new Error("exported custom model IDs are not unique"); +if (settings.customModels.some(model => model.provider !== "generic-chat-completion-api")) { + throw new Error("exported settings contain a non-generic Droid provider"); +} +let models = settings.customModels; +if (selectedModels.size > 0) { + models = models.filter(model => selectedModels.has(model.id) || selectedModels.has(model.model)); + if (models.length !== selectedModels.size) { + throw new Error("one or more --models selectors did not match the exported active catalog"); + } +} + +const healthUrl = new URL(models[0]!.baseUrl); +healthUrl.pathname = "/healthz"; +const healthStarted = Date.now(); +let health: { ok: boolean; status?: number; body?: string; error?: string; durationMs: number }; +try { + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(10_000) }); + health = { + ok: response.ok, + status: response.status, + body: (await response.text()).slice(0, 2_000), + durationMs: Date.now() - healthStarted, + }; +} catch (error) { + health = { + ok: false, + error: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - healthStarted, + }; +} +await Bun.write(join(runDir, "health.json"), JSON.stringify({ url: healthUrl.toString(), ...health }, null, 2) + "\n"); +if (!health.ok) throw new Error("OpenCodex health check failed; see " + join(runDir, "health.json")); + +const versionResult = await runCommand(["droid", "--version"], repoRoot, 10_000); +await Bun.write(join(runDir, "droid-version.json"), JSON.stringify(versionResult, null, 2) + "\n"); +if (versionResult.exitCode !== 0) throw new Error("Droid CLI is unavailable"); + +const summary: { + runId: string; + startedAt: string; + dryRun: boolean; + settingsPath: string; + activeModelCount: number; + selectedModelCount: number; + results: ModelResult[]; + finishedAt?: string; +} = { + runId, + startedAt: new Date().toISOString(), + dryRun, + settingsPath, + activeModelCount: settings.customModels.length, + selectedModelCount: models.length, + results: [], +}; +const summaryPath = join(runDir, "summary.json"); + +async function droidCase( + model: DroidModel, + modelDir: string, + name: string, + extra: string[], + marker: string, + timeout: number = timeoutMs, +): Promise { + const result = await runCommand([ + "droid", + "exec", + "--settings", + settingsPath, + "--model", + model.id, + "--cwd", + repoRoot, + ...extra, + ], repoRoot, timeout); + const evidence = await writeEvidence(modelDir, name, result); + return { ...verdict(result, marker), evidence }; +} + +async function verifyModel(model: DroidModel): Promise { + const modelDir = join(runDir, "models", safeDirName(model)); + await mkdir(modelDir, { recursive: true }); + await Bun.write(join(modelDir, "model.json"), JSON.stringify(model, null, 2) + "\n"); + const cases: Record = {}; + + const catalog = await runCommand([ + "droid", + "exec", + "--settings", + settingsPath, + "--model", + model.id, + "--list-tools", + "--output-format", + "json", + ], repoRoot, 30_000); + const catalogEvidence = await writeEvidence(modelDir, "catalog", catalog); + let advertisesRead = false; + try { + const tools = JSON.parse(catalog.stdout) as Record[]; + advertisesRead = Array.isArray(tools) + && tools.some(tool => tool.id === "read-file-cli" || tool.llmId === "Read"); + } catch { + advertisesRead = false; + } + cases.catalog = { + status: !catalog.timedOut && catalog.exitCode === 0 && advertisesRead ? "pass" : "fail", + detail: catalog.timedOut + ? "timed out" + : "exit " + catalog.exitCode + "; Read advertised=" + advertisesRead, + evidence: catalogEvidence, + durationMs: catalog.durationMs, + }; + if (dryRun) return { id: model.id, model: model.model, cases }; + + const requested = (name: string) => selectedCases.size === 0 || selectedCases.has(name); + if (requested("text")) { + cases.text = await droidCase( + model, + modelDir, + "text", + ["--output-format", "json", "Reply with exactly TEXT_OK and nothing else."], + "TEXT_OK", + ); + } + if (requested("stream")) { + const result = await runCommand([ + "droid", "exec", "--settings", settingsPath, "--model", model.id, + "--cwd", repoRoot, "--output-format", "stream-json", + "Reply with exactly STREAM_OK and nothing else.", + ], repoRoot, timeoutMs); + const parsed = events(result.stdout); + const evidence = await writeEvidence(modelDir, "stream", result); + const hasLifecycle = parsed.some(event => event.type === "system") + && parsed.some(event => event.type === "completion"); + const base = verdict(result, "STREAM_OK"); + cases.stream = { + ...base, + status: base.status === "pass" && hasLifecycle ? "pass" : "fail", + detail: hasLifecycle ? base.detail : base.detail + "; stream lifecycle missing", + evidence, + }; + } + if (requested("reasoning")) { + const efforts = model.supportedReasoningEfforts ?? []; + if (efforts.length === 0) { + cases.reasoning = { status: "unsupported", detail: "exported model has no reasoning ladder" }; + } else { + const effort = model.defaultReasoningEffort && efforts.includes(model.defaultReasoningEffort) + ? model.defaultReasoningEffort + : efforts[0]!; + const result = await runCommand([ + "droid", "exec", "--settings", settingsPath, "--model", model.id, + "--cwd", repoRoot, "--reasoning-effort", effort, + "--output-format", "stream-json", + "Reply with exactly REASONING_OK and nothing else.", + ], repoRoot, timeoutMs); + const parsed = events(result.stdout); + const init = parsed.find(event => event.type === "system" && event.subtype === "init"); + const evidence = await writeEvidence(modelDir, "reasoning", result); + const base = verdict(result, "REASONING_OK"); + const selected = init?.reasoning_effort === effort; + cases.reasoning = { + ...base, + status: base.status === "pass" && selected ? "pass" : "fail", + detail: selected ? base.detail + "; effort " + effort : base.detail + "; requested effort not observed", + evidence, + }; + } + } + if (requested("tool")) { + const marker = "DROID_TOOL_MARKER_" + Bun.hash(model.id).toString(16); + const markerPath = join(modelDir, "tool-marker.txt"); + await Bun.write(markerPath, marker + "\n"); + const result = await runCommand([ + "droid", "exec", "--settings", settingsPath, "--model", model.id, + "--cwd", repoRoot, "--only-tools", "Read", "--output-format", "stream-json", + "Call Read once with " + markerPath + ". After the result, reply with exactly TOOL_OK:" + + marker + " and no other text.", + ], repoRoot, timeoutMs); + const parsed = events(result.stdout); + const roundTrip = readRoundTrip(parsed, markerPath); + const resultContainsMarker = typeof roundTrip.value === "string" && roundTrip.value.trim() === marker; + const evidence = await writeEvidence(modelDir, "tool", result); + const base = verdict(result, "TOOL_OK:" + marker); + cases.tool = { + ...base, + status: base.status === "pass" && roundTrip.called && roundTrip.returned && resultContainsMarker + ? "pass" + : "fail", + detail: base.detail + "; Read call=" + roundTrip.called + "; result=" + roundTrip.returned + + "; result marker=" + resultContainsMarker, + evidence, + }; + } + if (requested("image")) { + if (model.noImageSupport === true) { + cases.image = { status: "unsupported", detail: "exported model disables image input" }; + } else { + const imagePath = join(repoRoot, "assets", "pr-gate-screenshot-required.png"); + const result = await runCommand([ + "droid", "exec", "--settings", settingsPath, "--model", model.id, + "--cwd", repoRoot, "--only-tools", "Read", "--output-format", "stream-json", + "Use the Read tool on " + imagePath + + ". Read the image pixels and reply with the exact prefix IMAGE_OK: followed by one space" + + " and the exact red headline only.", + ], repoRoot, timeoutMs); + const parsed = events(result.stdout); + const roundTrip = readRoundTrip(parsed, imagePath); + const evidence = await writeEvidence(modelDir, "image", result); + const base = verdict(result, "IMAGE_OK: UI screenshot required"); + cases.image = { + ...base, + status: base.status === "pass" && roundTrip.called && roundTrip.returned ? "pass" : "fail", + detail: base.detail + "; image Read call=" + roundTrip.called + "; result=" + roundTrip.returned, + evidence, + }; + } + } + if (requested("long-context")) { + const hash = Bun.hash(model.id).toString(16); + const startMarker = "LONG_START_" + hash; + const endMarker = "LONG_END_" + hash; + const promptPath = join(modelDir, "long-context-prompt.txt"); + const padding = Array.from({ length: longWords }, (_, index) => "context" + (index % 97)).join(" "); + await Bun.write( + promptPath, + [ + "Remember both markers and ignore the padding.", + startMarker, + padding, + endMarker, + "Reply with exactly LONG_OK:" + startMarker + ":" + endMarker, + ].join("\n"), + ); + cases["long-context"] = await droidCase( + model, + modelDir, + "long-context", + ["--output-format", "json", "--file", promptPath], + "LONG_OK:" + startMarker + ":" + endMarker, + timeoutMs * 2, + ); + } + return { id: model.id, model: model.model, cases }; +} + +let cursor = 0; +const workers = Array.from({ length: Math.min(concurrency, models.length) }, async () => { + while (cursor < models.length) { + const model = models[cursor++]!; + const result = await verifyModel(model); + summary.results.push(result); + summary.results.sort((a, b) => a.model.localeCompare(b.model)); + await Bun.write(summaryPath, JSON.stringify(summary, null, 2) + "\n"); + const failed = Object.values(result.cases).filter(item => item.status === "fail").length; + console.log(model.model + ": " + (failed === 0 ? "PASS" : "FAIL (" + failed + ")")); + } +}); +await Promise.all(workers); + +summary.finishedAt = new Date().toISOString(); +await Bun.write(summaryPath, JSON.stringify(summary, null, 2) + "\n"); +const failures = summary.results.flatMap(model => + Object.entries(model.cases) + .filter(([, result]) => result.status === "fail") + .map(([name, result]) => ({ model: model.model, case: name, detail: result.detail }))); +await Bun.write(join(runDir, "failures.json"), JSON.stringify(failures, null, 2) + "\n"); +console.log("Evidence: " + runDir); +console.log("Models: " + summary.results.length + "; failures: " + failures.length); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/assets/factory-droid-integration.png b/assets/factory-droid-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..252bfa024edbdd33536a0082f8ecaf73d4782af8 GIT binary patch literal 44812 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

q1Os%@(7zE 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⪙)F>__@$diNA) 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 literal 0 HcmV?d00001 diff --git a/design-debt.md b/design-debt.md new file mode 100644 index 00000000000..290a8d639da --- /dev/null +++ b/design-debt.md @@ -0,0 +1,8 @@ +# Design debt + +The changed-area APOSD audit found no open design debt for the Factory Droid integration. + +- The export builder is isolated behind the existing client-config registry. +- Managed writes reuse the existing exact-fragment ownership and restore journal. +- Catalog refresh uses the shared owned-integration path. +- The loopback policy is enforced at the shared CLI export boundary. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index df02daf48bc..aed13d70a5b 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -89,7 +89,7 @@ export default defineConfig({ { label: "Response Inspection", slug: "guides/response-inspection" }, { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, - { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, + { label: "Factory Droid", translations: { fr: "Factory Droid", ko: "Factory Droid" }, slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, { label: "Model Routing", translations: { fr: "Routage des modèles", ko: "모델 라우팅", "zh-CN": "模型路由", "zh-TW": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング", tr: "Model Yönlendirme" }, slug: "guides/model-routing" }, { label: "Codex Integration", translations: { fr: "Intégration de Codex", ko: "Codex 통합", "zh-CN": "Codex 集成", "zh-TW": "Codex 整合", ru: "Интеграция с Codex", ja: "Codex 連携", tr: "Codex Entegrasyonu" }, slug: "guides/codex-integration" }, diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 3162e0f7559..0b7a8a92d8f 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast et omo depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI et Factory Droid depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Quinze clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Seize clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -23,6 +23,11 @@ puis peut le retirer. Quinze clients fonctionnent ainsi, chacun avec son propre | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | | omo | `~/.omo/agent/models.json` | JSON | nouvelles sessions | espace réservé de bouclage | | Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | après arrêt et redémarrage | bouclage uniquement | +| Factory Droid | `~/.factory/settings.json` | JSON | nouvelles sessions et actualisation du sélecteur de modèles | aucun — bouclage uniquement | + +Factory Droid reçoit une entrée `customModels` par modèle OpenCodex actif via le dialecte +`generic-chat-completion-api` de Factory. L'intégration ne possède que ses identifiants stables, +préserve les modèles de l'utilisateur et ne fonctionne qu'en bouclage. La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment `llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne @@ -207,8 +212,8 @@ ocx mcode ``` Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, -Pi, Aside, Raycast et omo gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. -Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside, Raycast et omo. +Pi, Aside, Raycast, omo et Factory Droid gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside, Raycast, omo et Factory Droid. Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. diff --git a/docs-site/src/content/docs/guides/factory-droid.md b/docs-site/src/content/docs/guides/factory-droid.md index cca156892cb..318e5d44a71 100644 --- a/docs-site/src/content/docs/guides/factory-droid.md +++ b/docs-site/src/content/docs/guides/factory-droid.md @@ -1,8 +1,54 @@ --- -title: Factory Droid bridge -description: Connect Factory Droid models to opencodex through a local Responses-compatible bridge. +title: Factory Droid integrations +description: Use OpenCodex models in Droid, or connect Factory models to OpenCodex through a local bridge. --- +Factory Droid and OpenCodex can connect in two directions. The managed integration below is the +normal choice when you want to run OpenCodex models inside Droid. The bridge section is for the +opposite direction, where OpenCodex calls a model supplied by Factory. + +## Use OpenCodex models in Droid + +Install and sign in to the [Droid CLI](https://docs.factory.ai/droid-cli/quickstart), start OpenCodex +on loopback, then enable **Factory Droid** on the OpenCodex **Integrations** page. The equivalent CLI +commands are: + +```bash +ocx integration client status --client droid +ocx integration client enable --client droid +``` + +OpenCodex adds one row per active model to `~/.factory/settings.json` under `customModels`. Each row +uses a stable `custom:opencodex:` ID, the OpenCodex `/v1` base URL, and Factory's +`generic-chat-completion-api` provider. Context, image, and reasoning metadata come from the live +OpenCodex catalog. The response ceiling is 16,384 tokens so Droid does not request the full context +window as output. OpenCodex does not write a real API key. + +The integration owns only those exact OpenCodex rows. Existing Factory settings and custom models +remain untouched. A model selection change or `ocx sync` refreshes an already-owned catalog. +Disabling removes only the OpenCodex rows, and restore puts back the exact file snapshot recorded +for the selected operation: + +```bash +ocx integration client disable --client droid +ocx integration client restore --op [--confirm-drift] +``` + +For a process-only trial that does not modify `~/.factory/settings.json`, export to a temporary file +and pass it to Droid: + +```bash +ocx export --client droid --out /tmp/opencodex-droid-settings.json --force +droid exec --settings /tmp/opencodex-droid-settings.json \ + --model custom:opencodex:gpt-5.6-luna \ + "Reply with DROID_OK only." +``` + +This integration is loopback-only. Factory's custom model schema cannot carry OpenCodex's dedicated +remote admission header without persisting a credential, so OpenCodex refuses a non-loopback bind. + +## Use Factory models in OpenCodex + Factory Droid is an agent runtime, not a documented OpenAI-compatible inference endpoint. If a custom provider pointed at an internal Factory LLM URL returns `403 Forbidden`, changing only the opencodex adapter or adding provider headers does not make that private route a supported public API. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index a62114e8f32..c5cf42cac31 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo and Cline CLI from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI and Factory Droid from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Fifteen clients work this way, each with a switch: +file, and removes it again. Sixteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -23,11 +23,17 @@ file, and removes it again. Fifteen clients work this way, each with a switch: | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | | omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | +| Factory Droid | `~/.factory/settings.json` | JSON | new sessions and model picker refresh | none — loopback only | Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows the full roster so you can enable additional models. +Factory Droid receives one `customModels` row per active OpenCodex model through Factory's +`generic-chat-completion-api` dialect. Exact stable-ID ownership preserves user models and every +other setting. See [Factory Droid integrations](./factory-droid/) for the managed outbound path and +the separate inbound bridge design. + For Gajae built-in presets, keep the routing choice in `~/.gjc/agent/config.yml`: ```yaml @@ -274,10 +280,10 @@ ocx mcode ``` Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, -Raycast, and omo catalogs with the current model selection, context windows, and +Raycast, omo, and Factory Droid catalogs with the current model selection, context windows, and reasoning-effort ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, provider selection, or presets also refresh connected Pi, Aside, -Raycast, and omo catalogs. +Raycast, omo, and Factory Droid catalogs. Missing, foreign-edited, or unsafe blocks stay untouched, as do previously owned blocks you removed manually. An enabled Aside profile is an exception to the usual owned-only refresh: if its account diff --git a/docs-site/src/content/docs/ko/guides/factory-droid.md b/docs-site/src/content/docs/ko/guides/factory-droid.md index 43e13b4180a..4b7382adbc6 100644 --- a/docs-site/src/content/docs/ko/guides/factory-droid.md +++ b/docs-site/src/content/docs/ko/guides/factory-droid.md @@ -1,8 +1,52 @@ --- -title: Factory Droid 브리지 -description: 로컬 Responses 호환 브리지를 통해 Factory Droid 모델을 opencodex에 연결합니다. +title: Factory Droid 연동 +description: Droid에서 OpenCodex 모델을 사용하거나 로컬 브리지로 Factory 모델을 OpenCodex에 연결합니다. --- +Factory Droid와 OpenCodex는 두 방향으로 연결할 수 있습니다. Droid 안에서 OpenCodex 모델을 +사용하려면 아래의 관리형 연동을 사용합니다. 뒤의 브리지 절은 반대 방향, 즉 OpenCodex에서 +Factory가 제공하는 모델을 호출할 때 사용합니다. + +## Droid에서 OpenCodex 모델 사용 + +[Droid CLI](https://docs.factory.ai/droid-cli/quickstart)를 설치하고 로그인한 뒤, OpenCodex를 +루프백에서 실행하고 OpenCodex **연동** 화면에서 **Factory Droid**를 활성화합니다. 같은 작업을 +CLI로 실행할 수도 있습니다. + +```bash +ocx integration client status --client droid +ocx integration client enable --client droid +``` + +OpenCodex는 활성 모델마다 하나의 항목을 `~/.factory/settings.json`의 `customModels`에 추가합니다. +각 항목은 안정적인 `custom:opencodex:` ID, OpenCodex `/v1` Base URL, +Factory의 `generic-chat-completion-api` 프로바이더를 사용합니다. 컨텍스트, 이미지, reasoning +메타데이터는 실제 OpenCodex 카탈로그에서 가져옵니다. Droid가 전체 컨텍스트 창을 출력으로 +요청하지 않도록 응답 한도는 16,384 토큰으로 설정합니다. 실제 API 키는 파일에 기록하지 않습니다. + +이 연동은 정확히 OpenCodex가 추가한 항목만 소유합니다. 기존 Factory 설정과 사용자 지정 모델은 +그대로 유지됩니다. 모델 선택 변경이나 `ocx sync`는 이미 소유한 카탈로그를 갱신합니다. +비활성화는 OpenCodex 항목만 제거하고, 되돌리기는 선택한 작업 직전의 파일 스냅샷을 복원합니다. + +```bash +ocx integration client disable --client droid +ocx integration client restore --op [--confirm-drift] +``` + +`~/.factory/settings.json`을 바꾸지 않고 시험하려면 임시 파일로 내보내 `--settings`로 전달합니다. + +```bash +ocx export --client droid --out /tmp/opencodex-droid-settings.json --force +droid exec --settings /tmp/opencodex-droid-settings.json \ + --model custom:opencodex:gpt-5.6-luna \ + "DROID_OK만 답하세요." +``` + +이 연동은 루프백 전용입니다. Factory 사용자 지정 모델 스키마로는 자격 증명을 저장하지 않고 +OpenCodex의 전용 원격 인증 헤더를 전달할 수 없으므로, OpenCodex는 비루프백 바인드를 거부합니다. + +## OpenCodex에서 Factory 모델 사용 + Factory Droid는 에이전트 런타임이며, 문서화된 OpenAI 호환 추론 엔드포인트가 아닙니다. 내부 Factory LLM URL을 사용자 지정 프로바이더로 등록했을 때 `403 Forbidden`이 발생한다면, opencodex 어댑터나 프로바이더 헤더만 바꿔도 그 비공개 경로가 지원되는 공개 API로 바뀌지는 diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 1ea3cd065b9..dabe1fd5431 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast ve omo'yu opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI ve Factory Droid'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. On beş istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On altı istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -24,6 +24,11 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On beş istemci bu şekild | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | | omo | `~/.omo/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu | | Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | kapatıp yeniden başlattıktan sonra | yalnızca loopback | +| Factory Droid | `~/.factory/settings.json` | JSON | yeni oturumlarda ve model seçici yenilendiğinde | yok — yalnızca loopback | + +Factory Droid, Factory'nin `generic-chat-completion-api` lehçesi üzerinden her etkin OpenCodex modeli +için bir `customModels` girdisi alır. Entegrasyon yalnızca kendi kararlı kimliklerini yönetir, +kullanıcı modellerini korur ve yalnızca loopback üzerinde çalışır. Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -232,9 +237,9 @@ ocx mcode ``` Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside, -Raycast ve omo kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast +Raycast, omo ve Factory Droid kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, -Aside, Raycast ve omo kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan +Aside, Raycast, omo ve Factory Droid kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde entegrasyonu açıkça etkinleştirin. diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 0d5fdecd138..a05df8107a7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast 與 omo——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI 與 Factory Droid——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十五個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十六個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -22,6 +22,10 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | | omo | `~/.omo/agent/models.json` | JSON | 新工作階段 | loopback 佔位符 | | Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | 結束並重新啟動後 | 僅限 loopback | +| Factory Droid | `~/.factory/settings.json` | JSON | 新工作階段與重新整理模型選擇器後 | 無——僅限 loopback | + +Factory Droid 透過 Factory 的 `generic-chat-completion-api` 方言,為每個啟用中的 OpenCodex +模型取得一筆 `customModels` 項目。整合只管理自己的穩定 ID、保留使用者模型,且僅支援 loopback。 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -129,8 +133,8 @@ ocx mcode ``` 完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 -MCode、Pi、Aside、Raycast 與 omo 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 -模型可見性、provider 或 preset 變更會更新 Pi、Aside、Raycast 與 omo。若區塊已刪除、 +MCode、Pi、Aside、Raycast、omo 與 Factory Droid 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside、Raycast、omo 與 Factory Droid。若區塊已刪除、 遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 連接時才再次執行 enable。 diff --git a/gui/public/provider-icons/factory-droid.svg b/gui/public/provider-icons/factory-droid.svg new file mode 100644 index 00000000000..14d3255d601 --- /dev/null +++ b/gui/public/provider-icons/factory-droid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 9dbf4d28bc5..45d20a4411f 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -105,6 +105,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/raycast", "integrations/omo", "integrations/cline", + "integrations/droid", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index e164810fbb7..6997ed35129 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -27,6 +27,7 @@ export const CLIENT_LABEL_KEYS = { raycast: "api.clientConfig.clientRaycast", omo: "api.clientConfig.clientOmo", cline: "api.clientConfig.clientCline", + droid: "api.clientConfig.clientFactoryDroid", } as const; /** @@ -79,6 +80,7 @@ export const CLIENT_MARKS: Partial> = { // so it would paint the plate and throw the face away — see the README. omo: "/provider-icons/omo.svg", cline: "/provider-icons/cline-color.svg", + droid: "/provider-icons/factory-droid.svg", }; /** diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index 58873cb1cb0..f3bb4100b6b 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -60,6 +60,7 @@ export const INTEGRATION_MARKS: Record = { raycast: CLIENT_MARKS.raycast ?? null, omo: CLIENT_MARKS.omo ?? null, cline: CLIENT_MARKS.cline ?? null, + droid: CLIENT_MARKS.droid ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3c12b24a2d8..bbc4cb53ae9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2867,4 +2867,7 @@ export const de: Record = { "remote.event.status": "Status", "remote.event.tool": "Remote-Werkzeug", "remote.event.error": "Fehler", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Verwaltet aktive OpenCodex-Modelle in der settings.json von Factory Droid. Synchronisieren ändert nur OpenCodex-Einträge, Deaktivieren entfernt sie und Rückgängig stellt die vorige Datei wieder her.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c66a5e993be..83b7d88d302 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2901,6 +2901,9 @@ export const en = { "remote.event.status": "Status", "remote.event.tool": "Remote tool", "remote.event.error": "Error", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Manages OpenCodex active models in Factory Droid settings.json. Sync updates only OpenCodex rows, disable removes them, and undo restores the prior file.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6af22a0e9dc..233dc560b3b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2855,4 +2855,7 @@ export const fr: Record = { "remote.event.status": "État", "remote.event.tool": "Outil distant", "remote.event.error": "Erreur", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Gère les modèles OpenCodex actifs dans le fichier settings.json de Factory Droid. La synchronisation ne met à jour que les entrées OpenCodex, la désactivation les supprime et l’annulation restaure le fichier précédent.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 19ee0ddc00d..669c9a0ee9b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2888,4 +2888,7 @@ export const ja: Record = { "remote.event.status": "状態", "remote.event.tool": "リモートツール", "remote.event.error": "エラー", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Factory Droid の settings.json で OpenCodex の有効なモデルを管理します。同期は OpenCodex の項目だけを更新し、無効化はそれらを削除し、元に戻すと以前のファイルを復元します。", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f71338a352a..81148f15019 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2889,4 +2889,7 @@ export const ko: Record = { "remote.event.status": "상태", "remote.event.tool": "원격 도구", "remote.event.error": "오류", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Factory Droid settings.json에서 OpenCodex 활성 모델을 관리합니다. 동기화는 OpenCodex 항목만 갱신하고, 비활성화는 해당 항목을 제거하며, 되돌리기는 이전 파일을 복원합니다.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a7e402cdf0d..421f57f71b6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2890,4 +2890,7 @@ export const ru: Record = { "remote.event.status": "Состояние", "remote.event.tool": "Удалённый инструмент", "remote.event.error": "Ошибка", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Управляет активными моделями OpenCodex в settings.json Factory Droid. Синхронизация обновляет только записи OpenCodex, отключение удаляет их, а отмена восстанавливает предыдущий файл.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f491c7f5277..60ce79805ce 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2890,4 +2890,7 @@ export const tr: Record = { "remote.event.status": "Durum", "remote.event.tool": "Uzak araç", "remote.event.error": "Hata", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Factory Droid settings.json dosyasındaki etkin OpenCodex modellerini yönetir. Eşitleme yalnızca OpenCodex girdilerini günceller, devre dışı bırakma bunları kaldırır ve geri alma önceki dosyayı geri yükler.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index b26ec1ea95a..c32889aab0b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2826,6 +2826,9 @@ export const vi: Record = { "remote.event.status": "Trạng thái", "remote.event.tool": "Công cụ từ xa", "remote.event.error": "Lỗi", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "Quản lý các mô hình OpenCodex đang hoạt động trong settings.json của Factory Droid. Đồng bộ chỉ cập nhật các mục OpenCodex, tắt sẽ xóa chúng và hoàn tác khôi phục tệp trước đó.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", "models.newPolicyGlobal": "Model mới mặc định bị tắt", "models.newPolicyProvider": "Chính sách model mới", "models.newPolicy_inherit": "Kế thừa", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f23791fee87..d43d6aa16aa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2853,4 +2853,7 @@ export const zhTW: Record = { "remote.event.status": "狀態", "remote.event.tool": "遠端工具", "remote.event.error": "錯誤", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "管理 Factory Droid settings.json 中啟用的 OpenCodex 模型。同步只更新 OpenCodex 項目,停用會移除這些項目,復原會還原先前檔案。", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4a8dcbf7274..a3f574fa52e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2888,4 +2888,7 @@ export const zh: Record = { "remote.event.status": "状态", "remote.event.tool": "远程工具", "remote.event.error": "错误", + "integrations.tab.factoryDroid": "Factory Droid", + "integrations.semantics.factoryDroid": "管理 Factory Droid settings.json 中启用的 OpenCodex 模型。同步仅更新 OpenCodex 条目,禁用会移除这些条目,撤销会恢复先前文件。", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index b7d7d86fc23..65bfe7ff443 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -30,8 +30,8 @@ function panelDomId(tab: IntegrationTab): string { } /* - * The strip carries 18 tabs on one row, which is precisely where a mark earns - * its place: the eye finds a logo faster than it reads the tenth label. Two + * The crowded strip is where a mark earns its place: the eye finds a logo + * faster than it reads the tenth label. Two * tabs have no client behind them -- `overview` is the page itself and `keys` * is a credential surface, not an integration -- so they stay text-only rather * than borrowing a mark that would imply a client. diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index b1f8ec37374..ea46965097c 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -61,6 +61,7 @@ const SEMANTICS_KEY: Record = { raycast: "integrations.semantics.raycast", omo: "integrations.semantics.omo", cline: "integrations.semantics.cline", + droid: "integrations.semantics.factoryDroid", }; const TAB_LABEL_KEY: Record = { @@ -79,6 +80,7 @@ const TAB_LABEL_KEY: Record = { raycast: "integrations.tab.raycast", omo: "integrations.tab.omo", cline: "integrations.tab.cline", + droid: "integrations.tab.factoryDroid", }; export default function FileIntegrationPage({ diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index a0e4b1d0bd0..4ddf8dd1371 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -633,7 +633,7 @@ export default function IntegrationsOverview({

{t("integrations.rollback.title")}

{/* The newest operation stays visible and the rest collapse. This page - already carries a summary, an API row and fifteen cards, so fifty + already carries a summary, an API row and sixteen cards, so fifty bordered rows below them buried the one control a user wants after a mistake. The older rows are kept rather than dropped: this is the only place showing one chronology ACROSS clients, since each client tab reads diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index ad50a781fd5..bb89291fd6c 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -17,6 +17,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "raycast", "omo", "cline", + "droid", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index a1810763ddb..f0968279e93 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -49,6 +49,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, { id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" }, { id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" }, + { id: "droid", hash: "integrations/droid", labelKey: "integrations.tab.factoryDroid" }, ] as const; export const FILE_CLIENTS = new Set([ @@ -67,4 +68,5 @@ export const FILE_CLIENTS = new Set([ "raycast", "omo", "cline", + "droid", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index d54d99d0057..cc95220eb42 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -155,6 +155,7 @@ const FILE_LABEL_KEY: Record = { raycast: "integrations.tab.raycast", omo: "integrations.tab.omo", cline: "integrations.tab.cline", + droid: "integrations.tab.factoryDroid", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index dffaf4d0994..8c7028abf65 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,13 +170,14 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]); +test("the API download surface includes every registered file client", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); expect(CLIENT_LABEL_KEYS.omo).toBe("api.clientConfig.clientOmo"); + expect(CLIENT_LABEL_KEYS.droid).toBe("api.clientConfig.clientFactoryDroid"); }); test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 689a1b8672c..1aef7704b41 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -132,6 +132,8 @@ const INTENTIONAL_ENGLISH = new Set([ // Cline product name and CLI acronym are intentionally preserved. "integrations.tab.cline", "api.clientConfig.clientCline", + "integrations.tab.factoryDroid", + "api.clientConfig.clientFactoryDroid", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 8a647943dfb..f5e90485e19 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("all registered export clients include Cline in file integrations", () => { +test("all registered export clients appear in file integrations", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 4e44e8d674b..2668a4bc847 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -290,8 +290,9 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(20); + expect(built.rows).toHaveLength(21); expect(rowById(built, "cline")).toMatchObject({ hash: "integrations/cline", labelKey: "integrations.tab.cline", state: "unknown" }); + expect(rowById(built, "droid")).toMatchObject({ hash: "integrations/droid", labelKey: "integrations.tab.factoryDroid", state: "unknown" }); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index a2015ec0caf..bff00a8075e 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -1097,7 +1097,7 @@ test("a loopback-only refusal is localized, not the server's English message", a }); test("a populated overview journal collapses instead of flooding the page", async () => { /* - * The overview already carries a summary strip, a credential row and fifteen + * The overview already carries a summary strip, a credential row and sixteen * cards. It also rendered every row the journal returned — up to the route's * fifty — as individually bordered strips below them, which is what buried * the one control a user reaches for after a mistake. diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index cc602a8a9ce..43e2d1cadbc 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -102,6 +102,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ // Cline CLI is a product name, not untranslated interface copy. "integrations.tab.cline", "api.clientConfig.clientCline", + "integrations.tab.factoryDroid", + "api.clientConfig.clientFactoryDroid", "api.clientConfig.clientPi", "api.clientConfig.clientOmp", "api.clientConfig.clientHermes", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ac1a90a8c17..e5a1512586c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -436,6 +436,7 @@ "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "cline-writer.test.ts": "clients", + "droid-client.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 029336f66c9..a17e76ef87a 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -471,7 +471,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast", "omo", "cline"])); + }, ["mcode", "pi", "raycast", "omo", "cline", "droid"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 21d62f59c77..272db7a2908 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -1,8 +1,8 @@ /** * `ocx export --client ` — print a client config for the live proxy. * - * Fourteen clients, five formats. The accepted list is `EXPORT_CLIENT_IDS`, not - * this comment: OpenCode, Pi, Prime, Aside, ZCode and omo are JSON; OMP, + * The accepted client list is `EXPORT_CLIENT_IDS`, not this comment: OpenCode, + * Pi, Prime, Aside, ZCode, omo, Cline and Droid are JSON; OMP, * Hermes, gjc, DSH, MiniMax Code and Raycast are YAML; OpenClaw is JSON5; Kimi * is TOML. * @@ -38,6 +38,7 @@ import { type ExportModel, } from "../clients/config-export"; import { opencodeCatalogFromProxyRows, type OpencodeProxyModelRow } from "./opencode"; +import { isLoopbackHostname } from "../server/auth-cors"; import type { OcxConfig } from "../types"; import { CliUsageError, @@ -158,6 +159,9 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); + if (spec.loopbackOnly && !isLoopbackHostname(new URL(root).hostname)) { + throw new CliUsageError(`${client} export is loopback-only because its config cannot carry OpenCodex's remote-admission header.`, USAGE); + } let built: { document: unknown; text: string }; if (client === "raycast") { // The dial address alone cannot distinguish a wildcard authenticated bind diff --git a/src/cli/help.ts b/src/cli/help.ts index 0be199f1a5a..4717d7ac5ba 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -84,7 +84,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (15 clients) + ocx export --client Print a client config wired to the running proxy (16 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f9b9d95d969..b9ae762d084 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -368,8 +368,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline, Factory Droid) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 13dc2c2964a..13df931c8f9 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -39,6 +39,8 @@ export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProv export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast"; export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; +export type { DroidModelEntry, DroidGeneratedConfig } from "./config-export/droid"; +export { buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid"; import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; @@ -49,6 +51,7 @@ import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from " import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; import { buildClineClientConfig, summarizeCline, buildClineContribution } from "./config-export/cline"; import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; +import { buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid"; @@ -1232,6 +1235,14 @@ export function clineSettingsDir(env: OpencodeLaunchEnv = process.env, home: str return dirname(clineConfigPath(env, home)); } +export function droidHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".factory"); +} + +export function droidConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(droidHomeDir(env, home), "settings.json"); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1463,6 +1474,18 @@ export const EXPORT_CLIENTS: Record = { buildContribution: buildClineContribution, loopbackOnly: true, }, + droid: { + id: "droid", + filename: "factory-settings.json", + destination: env => droidConfigPath(env), + apiKeyEnv: "", + exportHint: "Factory Droid reads a loopback-only custom model catalog from settings.json.", + build: buildDroidClientConfig, + format: "json", + summarize: summarizeDroid, + buildContribution: buildDroidContribution, + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 5ed26261805..5fcd4a9d617 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -103,7 +103,8 @@ export type ExportClientId = | "aside" | "raycast" | "omo" - | "cline"; + | "cline" + | "droid"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/clients/config-export/droid.ts b/src/clients/config-export/droid.ts new file mode 100644 index 00000000000..89a82b5dd1a --- /dev/null +++ b/src/clients/config-export/droid.ts @@ -0,0 +1,76 @@ +import { exportPresentationLabel } from "../model-presentation"; +import type { ExportContext, ExportModel, ManagedContribution } from "./contracts"; +import { authoritativeContextWindow, normalizeExportModels } from "./model-metadata"; + +export interface DroidModelEntry { + id: string; + model: string; + displayName: string; + baseUrl: string; + provider: "generic-chat-completion-api"; + maxOutputTokens: 16_384; + maxContextLimit?: number; + noImageSupport: boolean; + enableThinking?: true; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string; + reasoningEffort?: string; +} + +export interface DroidGeneratedConfig { + customModels: DroidModelEntry[]; +} + +function buildDroidModel(model: ExportModel, baseUrl: string): DroidModelEntry { + const contextWindow = authoritativeContextWindow(model.contextWindow); + const reasoningEfforts = model.reasoningEfforts && model.reasoningEfforts.length > 0 + ? [...new Set(model.reasoningEfforts)] + : undefined; + const entry: DroidModelEntry = { + id: `custom:opencodex:${model.namespaced}`, + model: model.namespaced, + displayName: `OpenCodex: ${exportPresentationLabel(model)}`, + baseUrl, + provider: "generic-chat-completion-api", + maxOutputTokens: 16_384, + noImageSupport: !(model.inputModalities?.includes("image") ?? false), + ...(contextWindow !== undefined + ? { maxContextLimit: contextWindow } + : {}), + }; + if (reasoningEfforts) { + entry.enableThinking = true; + entry.supportedReasoningEfforts = reasoningEfforts; + if (model.defaultReasoningEffort && reasoningEfforts.includes(model.defaultReasoningEffort)) { + entry.defaultReasoningEffort = model.defaultReasoningEffort; + entry.reasoningEffort = model.defaultReasoningEffort; + } + } + return entry; +} + +export function buildDroidClientConfig(ctx: ExportContext): DroidGeneratedConfig { + return { + customModels: normalizeExportModels(ctx.models).map(model => buildDroidModel(model, ctx.baseUrl)), + }; +} + +export function summarizeDroid(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = document && typeof document === "object" && Array.isArray((document as DroidGeneratedConfig).customModels) + ? (document as DroidGeneratedConfig).customModels + : []; + return { + modelCount: models.length, + modelsWithoutLimits: models.filter(model => typeof model.maxContextLimit !== "number").length, + }; +} + +export function buildDroidContribution(ctx: ExportContext): ManagedContribution { + return { + clientId: "droid", + fragments: buildDroidClientConfig(ctx).customModels.map(model => ({ + path: ["customModels", `[id=${model.id}]`], + value: model, + })), + }; +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 45e9b7a97b5..07dbb70da17 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -10,7 +10,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"], ): Promise { let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 41813162074..2dedddb2394 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -20,6 +20,8 @@ import { asideHomeDir, dshConfigPath, dshHomeDir, + droidConfigPath, + droidHomeDir, gajaeConfigPath, gajaeHomeDir, hermesConfigPath, @@ -307,6 +309,11 @@ export const INTEGRATION_CLIENTS: Record clineSettingsDir(env, home), writerLock: { suffix: ".lock" }, }, + droid: { + id: "droid", + configPath: (env = process.env, home = homedir()) => droidConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => droidHomeDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 52363af8361..f9f47163f5c 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -244,7 +244,7 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside", "raycast", "omo", "cline"])); + }, ["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"])); return out; } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e7edb67892c..ee11e278c05 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -161,7 +161,7 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. -The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. +The lightweight top-level CLI help counts Factory Droid among the sixteen registered export clients; registry parity remains covered by the client help and integration tests. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index bbdd23646d4..75ee1f44929 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -7,6 +7,15 @@ third-party client's existing config without taking ownership of the rest of tha promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, and refuses refresh, disable, or restore when the current file cannot be classified safely. +Factory Droid is the row-oriented case: its contribution contains one exact +`customModels[id=custom:opencodex:]` fragment per active model. The shared selector merge +preserves sibling rows and top-level settings; disable removes only recorded rows and restore uses +the same snapshot transaction as every other JSON client. Droid reaches the loopback +`/v1/chat/completions` surface through `generic-chat-completion-api`. The export stays loopback-only +because its schema cannot carry the dedicated remote admission header without persisting a secret. +Each row uses Factory's documented 16,384-token response ceiling rather than turning the context +window into the request's output budget. + Shared response support has a separate [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. The OrcaRouter login ceiling applies to its key exchange; client configuration files retain the @@ -95,6 +104,7 @@ All registered integrations consume the shared catalog, including [Anthropic see | Kimi Code | `capabilities: ["image_in"]` only for declared image input; omitted for unknown/text-only models | | MiniMax Code | No per-model image capability field emitted | | Raycast | `abilities.vision.supported` | +| Factory Droid | `noImageSupport` (inverse of declared image input) | No exporter infers image support from a model name. Existing client eligibility filters and ownership/refresh rules remain unchanged; exports do not add fields to schemas without a supported mapping. diff --git a/structure/config.md b/structure/config.md index 7972ed9a21b..d8f3b80af93 100644 --- a/structure/config.md +++ b/structure/config.md @@ -454,7 +454,7 @@ The Cline client keeps connection settings and models in a separate native file [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. -The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. +The lightweight top-level CLI help counts Factory Droid among the sixteen registered export clients; registry parity remains covered by the client help and integration tests. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 967b81ecf09..f08dd68838e 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -432,7 +432,7 @@ Codex pool settings and their consumers follow the [reset-first ordering contrac Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. -The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. +The lightweight top-level CLI help counts Factory Droid among the sixteen registered export clients; registry parity remains covered by the client help and integration tests. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. diff --git a/structure/runtime.md b/structure/runtime.md index 84eb843b35c..fb4e6a6f25e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -387,7 +387,7 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. -The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. +The lightweight top-level CLI help counts Factory Droid among the sixteen registered export clients; registry parity remains covered by the client help and integration tests. Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes. diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 6d8a5135586..ec193112817 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -309,6 +309,12 @@ describe("ocx export --out (accept criterion 3)", () => { }); describe("ocx export argument validation (accept criterion 4)", () => { + test("loopback-only clients refuse a remote live proxy before fetching its catalog", async () => { + const result = await run(["--client", "droid", "--json"], { baseUrl: "http://192.0.2.1:10100" }); + expect(result.code).toBe(2); + expect(result.stderr).toContain("droid export is loopback-only"); + }); + test("an unknown --client names every valid value", async () => { const proxy = fakeProxy(); const result = await run(["--client", "cursor"], { baseUrl: proxy.baseUrl }); diff --git a/tests/clients/droid-client.test.ts b/tests/clients/droid-client.test.ts new file mode 100644 index 00000000000..31eefa21527 --- /dev/null +++ b/tests/clients/droid-client.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_CLIENTS, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + droidConfigPath, + droidHomeDir, + summarizeDroid, + type DroidGeneratedConfig, + type ExportContext, + type ExportModel, +} from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { + applyIntegration, + disableIntegration, + restoreIntegration, +} from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +const MODELS: ExportModel[] = [ + { + namespaced: "anthropic/claude-fable-5-1", + provider: "anthropic", + id: "claude-fable-5-1", + contextWindow: 200_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + }, + { + namespaced: "cursor/composer-2.5-fast", + provider: "cursor", + id: "composer-2.5-fast", + inputModalities: ["text"], + }, + { + namespaced: "mock/unknown", + provider: "mock", + id: "unknown", + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "max", + }, +]; + +function context(models: readonly ExportModel[] = MODELS): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", config: CONFIG, models }; +} + +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-droid-")); + store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-droid-store-"))); +}); + +afterEach(() => { + removeTreeWithRetry(home); + removeTreeWithRetry(store.root); +}); + +function installDroid(seed?: string): string { + const spec = INTEGRATION_CLIENTS.droid; + mkdirSync(spec.detectDir({}, home), { recursive: true }); + const configPath = spec.configPath({}, home); + if (seed !== undefined) writeFileSync(configPath, seed); + return configPath; +} + +function request(models: readonly ExportModel[] = MODELS) { + return { clientId: "droid" as const, models, config: CONFIG, port: 10100, env: {}, home, store }; +} + +function readSettings(configPath: string): Record & DroidGeneratedConfig { + return JSON.parse(readFileSync(configPath, "utf8")) as Record & DroidGeneratedConfig; +} + +describe("Factory Droid client config", () => { + test("maps authoritative catalog metadata onto Factory customModels", () => { + const document = buildClientConfig("droid", context()) as DroidGeneratedConfig; + const fable = document.customModels.find(model => model.model === "anthropic/claude-fable-5-1")!; + expect(fable).toEqual({ + id: "custom:opencodex:anthropic/claude-fable-5-1", + model: "anthropic/claude-fable-5-1", + displayName: "OpenCodex: Claude Fable 5.1", + baseUrl: "http://127.0.0.1:10100/v1", + provider: "generic-chat-completion-api", + maxOutputTokens: 16_384, + maxContextLimit: 200_000, + noImageSupport: false, + enableThinking: true, + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + reasoningEffort: "medium", + }); + const composer = document.customModels.find(model => model.model === "cursor/composer-2.5-fast")!; + expect(composer.noImageSupport).toBe(true); + expect("maxContextLimit" in composer).toBe(false); + expect("enableThinking" in composer).toBe(false); + const unknown = document.customModels.find(model => model.model === "mock/unknown")!; + expect(unknown.supportedReasoningEfforts).toEqual(["low", "high"]); + expect("defaultReasoningEffort" in unknown).toBe(false); + expect("reasoningEffort" in unknown).toBe(false); + for (const model of document.customModels) { + expect("apiKey" in model).toBe(false); + expect(model.maxOutputTokens).toBe(16_384); + } + }); + + test("serializes JSON, summarizes limits, and never carries a configured secret", () => { + const sentinel = ["sk", "live", "droid", "sentinel"].join("-"); + const built = buildClientConfigText("droid", { + ...context(), + config: { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig, + }); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document); + expect(built.text).not.toContain(sentinel); + expect(summarizeDroid(built.document)).toEqual({ modelCount: 3, modelsWithoutLimits: 2 }); + expect(summarizeDroid(null)).toEqual({ modelCount: 0, modelsWithoutLimits: 0 }); + }); + + test("owns one exact customModels row per stable id", () => { + const contribution = buildClientContribution("droid", context()); + expect(contribution.clientId).toBe("droid"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([ + ["customModels", "[id=custom:opencodex:anthropic/claude-fable-5-1]"], + ["customModels", "[id=custom:opencodex:cursor/composer-2.5-fast]"], + ["customModels", "[id=custom:opencodex:mock/unknown]"], + ]); + }); + + test("uses Factory's canonical settings path and stays loopback-only", () => { + expect(droidHomeDir({}, home)).toBe(join(home, ".factory")); + expect(droidConfigPath({}, home)).toBe(join(home, ".factory", "settings.json")); + expect(INTEGRATION_CLIENTS.droid.configPath({}, home)).toBe(droidConfigPath({}, home)); + expect(INTEGRATION_CLIENTS.droid.detectDir({}, home)).toBe(droidHomeDir({}, home)); + expect(EXPORT_CLIENTS.droid).toMatchObject({ + filename: "factory-settings.json", + apiKeyEnv: "", + format: "json", + loopbackOnly: true, + }); + }); + + test("apply, default catalog refresh and disable preserve user settings and user models", async () => { + const userModel = { + id: "custom:user:local", + model: "local", + displayName: "Local", + baseUrl: "http://127.0.0.1:11434/v1", + provider: "generic-chat-completion-api", + }; + const seed = JSON.stringify({ theme: "dark", customModels: [userModel] }, null, 2) + "\n"; + const configPath = installDroid(seed); + expect(applyIntegration(request()).ok).toBe(true); + expect(readSettings(configPath).customModels.map(model => model.id)).toEqual([ + userModel.id, + ...MODELS.map(model => "custom:opencodex:" + model.namespaced).sort(), + ]); + const fewer = MODELS.slice(0, 2); + expect(await refreshOwnedCatalogIntegrations({ + models: fewer, + config: CONFIG, + port: 10100, + env: {}, + home, + store, + })).toEqual([{ client: "droid", ok: true, changed: true }]); + const refreshed = readSettings(configPath); + expect(refreshed.theme).toBe("dark"); + expect(refreshed.customModels[0]).toEqual(userModel); + expect(refreshed.customModels.map(model => model.id)).not.toContain("custom:opencodex:mock/unknown"); + expect(disableIntegration(request(fewer)).ok).toBe(true); + expect(readSettings(configPath)).toEqual({ theme: "dark", customModels: [userModel] }); + }); + + test("restore returns the exact bytes that preceded apply", () => { + const seed = '{\n "theme": "dark",\n "customModels": []\n}\n'; + const configPath = installDroid(seed); + expect(applyIntegration(request()).ok).toBe(true); + const opId = store.listOperations("droid")[0]!.opId; + expect(restoreIntegration({ ...request(), opId }).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(seed); + }); + + test("refuses unsafe containers and non-loopback binds without changing the file", () => { + const seed = '{"customModels":{}}\n'; + const configPath = installDroid(seed); + expect(applyIntegration(request())).toMatchObject({ ok: false, reason: "unsafe" }); + expect(readFileSync(configPath, "utf8")).toBe(seed); + expect(applyIntegration({ + ...request(), + config: { ...CONFIG, hostname: "0.0.0.0" }, + })).toMatchObject({ ok: false, reason: "non_loopback" }); + expect(readFileSync(configPath, "utf8")).toBe(seed); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index f96cf134c51..66516cde100 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => { describe("the loopback-only set is one fact, read through one seam", () => { test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside, raycast and omo are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 219a1480829..714a9bee87b 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -651,12 +651,12 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); }); -test("the direct ocx sync command refreshes MCode, Pi, Raycast, omo and server-owned Aside", async () => { +test("the direct ocx sync command refreshes owned client catalogs and server-owned Aside", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline"]'); + expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "droid"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 03000ae38b3..90bcadfd483 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -64,7 +64,7 @@ describe("no secret reaches a client config", () => { // credential wiring is deliberately deferred from those initial generated // integrations -- omo reuses Pi's builder, which emits no headers at all. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 1884dc941c3..834b857170b 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -837,8 +837,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the fourteen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]); + test("covers every file-toggle client", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", "droid"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d677aea796a..97daac89924 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -267,6 +267,7 @@ "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", "cline-writer.test.ts": "clients", + "droid-client.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 78e0d20f2c8..7acd44d10cf 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -93,7 +93,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(15); + expect(expected).toHaveLength(16); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -181,10 +181,12 @@ describe("every client survives a full lifecycle", () => { // contract -- verified against senpi's own compiled validator, not assumed // from the family resemblance (260912 plan unit, 001). omo: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + droid: '{\n "theme": "dark",\n "customModels": [\n { "id": "custom:user:local", "model": "local", "displayName": "Local", "baseUrl": "http://127.0.0.1:11434/v1", "provider": "generic-chat-completion-api" }\n ]\n}\n', }; /** Where the seed's user-owned entry lives when the seed is a sequence. */ const USER_ELEMENT: Partial> = { raycast: ["providers", "[id=lmstudio]"], + droid: ["customModels", "[id=custom:user:local]"], }; for (const clientId of INTEGRATION_CLIENT_IDS) { From ed4bdc21e40ef868c4589e176adbd3d158b17bab Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 09:30:05 +0900 Subject: [PATCH 02/16] fix: address Droid integration review feedback (#5193) --- assets/factory-droid-icon.png | Bin 0 -> 9980 bytes .../content/docs/fr/guides/factory-droid.md | 37 +++++++++++++++++- gui/public/provider-icons/README.md | 9 +++++ gui/public/provider-icons/factory-droid.svg | 7 ++-- src/server/management/model-routes.ts | 4 +- structure/gui-and-management-api.md | 5 +++ tests/cli/cli-export-command.test.ts | 6 +-- .../management-client-config-route.test.ts | 36 +++++++++++------ 8 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 assets/factory-droid-icon.png diff --git a/assets/factory-droid-icon.png b/assets/factory-droid-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d364f75d4a093df626a3328c306bff9827858664 GIT binary patch literal 9980 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, l’URL de base `/v1` d’OpenCodex et le fournisseur `generic-chat-completion-api` de Factory. Les métadonnées de contexte, de prise en charge des images et de raisonnement proviennent du catalogue dynamique d’OpenCodex. L’intégration configure un plafond de réponse de 16 384 jetons afin que Droid ne demande pas la totalité de la fenêtre de contexte en sortie. OpenCodex n’écrit aucune clé API réelle. + +L’intégration ne gère que ces entrées OpenCodex précisément identifiées. Les paramètres Factory et les modèles personnalisés existants restent inchangés. Un changement dans la sélection des modèles ou `ocx sync` actualise un catalogue déjà géré par l’intégration. La désactivation supprime uniquement les entrées OpenCodex, et la restauration rétablit l’instantané exact du fichier enregistré pour l’opération sélectionnée : + +```bash +ocx integration client disable --client droid +ocx integration client restore --op [--confirm-drift] +``` + +Pour un essai limité à l’exécution de Droid, sans modifier `~/.factory/settings.json`, exportez la configuration dans un fichier temporaire et passez-le à Droid : + +```bash +ocx export --client droid --out /tmp/opencodex-droid-settings.json --force +droid exec --settings /tmp/opencodex-droid-settings.json \ + --model custom:opencodex:gpt-5.6-luna \ + "Reply with DROID_OK only." +``` + +Cette intégration fonctionne uniquement sur l’interface de bouclage. L’intégration n’exporte aucun identifiant d’authentification ni en-tête dédié au contrôle d’accès distant d’OpenCodex dans la configuration Droid. OpenCodex refuse donc toute écoute sur une adresse autre que celle de l’interface de bouclage. + +## Utiliser les modèles Factory dans OpenCodex + Factory Droid est un environnement d’exécution d’agents, et non un point de terminaison d’inférence compatible avec OpenAI et documenté. Si un fournisseur personnalisé qui pointe vers une URL interne de Factory LLM renvoie `403 Forbidden`, modifier uniquement l’adaptateur OpenCodex ou ajouter des en-têtes de fournisseur ne transforme pas cette route privée en API publique prise en charge. L’intégration fonctionnelle est la suivante : diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 3f0878f924e..1e3d6b140fc 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -15,6 +15,15 @@ Export-client marks (used by the API tab's connect rows, not the provider list): - `cline-color.svg` — reuses the existing provider mark already tracked in this directory for Cline CLI; no new image was imported for the file integration. +- `factory-droid.svg` — fetched 2026-09-20 from + `https://docs.factory.ai/favicon.svg`, linked by Factory's official docs. + Identical to `https://factory.com/icon.svg` (source SHA-256 + `416ea4962d7b0b8be8bec7f7190c13c22d5f20fdcac401aa72886fd5c81d2fb2`). + The path, `#FAFAFA` mark, `#020202` background and 508x508 viewBox are verbatim; + redundant SVG wrappers, fixed dimensions, an unresolved clip reference and + no-op theme styles are removed. Kept as an image: masking the opaque + background would hide the mark. No geometry was redrawn. + - `pi.svg` — fetched 2026-08-02 from `https://pi.dev/favicon.svg`, the Pi project's own favicon, unmodified. Pi is `earendil-works/pi` (formerly `badlogic/pi-mono`). diff --git a/gui/public/provider-icons/factory-droid.svg b/gui/public/provider-icons/factory-droid.svg index 14d3255d601..5a79c3f810c 100644 --- a/gui/public/provider-icons/factory-droid.svg +++ b/gui/public/provider-icons/factory-droid.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 42364b3d579..a7d01c6ec44 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -545,9 +545,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { }); describe("ocx export argument validation (accept criterion 4)", () => { - test("loopback-only clients refuse a remote live proxy before fetching its catalog", async () => { - const result = await run(["--client", "droid", "--json"], { baseUrl: "http://192.0.2.1:10100" }); + test.each(["droid", "pi"])("%s export refuses a remote live proxy before fetching its catalog", async client => { + const result = await run(["--client", client, "--json"], { baseUrl: "http://192.0.2.1:10100" }); expect(result.code).toBe(2); - expect(result.stderr).toContain("droid export is loopback-only"); + expect(result.stderr).toContain(`${client} export is loopback-only`); }); test("an unknown --client names every valid value", async () => { diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 8aada987db5..26490f65a3b 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -278,26 +278,35 @@ describe("native Anthropic effort ladder reaches the Aside document", () => { }); describe("GET /api/client-config", () => { for (const hostname of ["0.0.0.0", "::", "192.0.2.40"]) { - test(`Raycast export refuses authenticated bind ${hostname} before generating a document`, async () => { - const response = await clientConfigApi(baseConfig({ hostname }), "?client=raycast"); + test.each(["droid", "pi", "raycast"] as const)(`%s export refuses authenticated bind ${hostname} before loading the catalog`, async client => { + const config = baseConfig({ hostname }); + let providersRead = 0; + Object.defineProperty(config, "providers", { + get() { providersRead += 1; throw new Error("catalog offline"); }, + }); + const response = await clientConfigApi(config, `?client=${client}`); expect(response.status).toBe(400); + expect(providersRead).toBe(0); const body = await response.json() as Record; expect(body.reason).toBe("non_loopback"); + expect(body.error).toContain("unauthenticated loopback destination"); expect(body.config).toBeUndefined(); expect(body.text).toBeUndefined(); }); } - test("Raycast export uses the declared unauthenticated listener instead of the management port", async () => { - const response = await clientConfigApi(baseConfig({ + test.each(["droid", "pi", "raycast"] as const)("%s export uses the declared unauthenticated listener instead of the management port", async client => { + const config = baseConfig({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 }, - }), "?client=raycast"); + }); + const response = await clientConfigApi(config, `?client=${client}`); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; - const document = body.config as RaycastGeneratedConfig; - expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); - expect(document.providers[0]!.models.length).toBeGreaterThan(0); + expect(body.config).toEqual(buildClientConfig(client, { + baseUrl: "http://127.0.0.1:10237/v1", models: await loadExportModels(config), config, + })); + expect(body.modelCount).toBeGreaterThan(0); expect(body.text).not.toContain(REAL_LOOKING_KEY); expect(body.text).not.toContain("api_keys"); }); @@ -313,12 +322,15 @@ describe("GET /api/client-config", () => { .toBe("http://127.0.0.1:10237/v1"); }); - test("Raycast export uses the main port for an ordinary loopback bind", async () => { - const response = await clientConfigApi(baseConfig(), "?client=raycast"); + test.each(["droid", "pi", "raycast"] as const)("%s export uses the main port for an ordinary loopback bind", async client => { + const config = baseConfig(); + const response = await clientConfigApi(config, `?client=${client}`); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; - expect((body.config as RaycastGeneratedConfig).providers[0]!.base_url) - .toBe("http://127.0.0.1:10100/v1"); + expect(body.config).toEqual(buildClientConfig(client, { + baseUrl: "http://127.0.0.1:10100/v1", models: await loadExportModels(config), config, + })); + expect(body.modelCount).toBeGreaterThan(0); }); test("opencode envelope carries the shared builder's exact bytes", async () => { From 10f599c0781e3c6c40f92099879a85014d174fc3 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 11:22:49 +0900 Subject: [PATCH 03/16] fix(droid): resolve review readiness findings --- .../skills/verify-droid-integration/SKILL.md | 14 +++-- .../features/image-input.md | 6 +- .../verify-droid-integration/scripts/run.ts | 63 ++++++++++++++----- gui/src/i18n/ru.ts | 2 +- src/cli/export-command.ts | 8 +-- src/integrations/mutation-plan.ts | 1 + structure/clients/integrations.md | 6 +- tests/cli/cli-export-command.test.ts | 50 +++++++++++---- 8 files changed, 105 insertions(+), 45 deletions(-) diff --git a/.agents/skills/verify-droid-integration/SKILL.md b/.agents/skills/verify-droid-integration/SKILL.md index 2787457aae2..d886b788696 100644 --- a/.agents/skills/verify-droid-integration/SKILL.md +++ b/.agents/skills/verify-droid-integration/SKILL.md @@ -18,7 +18,7 @@ http://127.0.0.1:10100/healthz` succeeds. The provider credentials live in that If no proxy is running, start this checkout and keep its PID: ```bash -node_modules/bun/bin/bun.exe run src/cli/index.ts start --port 10100 +bun run src/cli/index.ts start --port 10100 ``` Wait for `http://127.0.0.1:10100/healthz`. Stop only the PID you started. @@ -28,7 +28,7 @@ Wait for `http://127.0.0.1:10100/healthz`. Stop only the PID you started. Run the no-inference pass first: ```bash -node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts --dry-run +bun .agents/skills/verify-droid-integration/scripts/run.ts --dry-run ``` It exports the live active catalog through `ocx export --client droid`, checks proxy health and @@ -40,7 +40,7 @@ ID with `--list-tools`. A failure makes the instance unfit for the paid live pas Run the full matrix: ```bash -node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts \ +bun .agents/skills/verify-droid-integration/scripts/run.ts \ --concurrency 3 \ --timeout-ms 180000 \ --long-words 20000 @@ -58,8 +58,10 @@ settings, export and health evidence, one command/result JSON per model and case and `failures.json`. The command, exit code, stdout, stderr, timeout state, and duration preserve both the action and result. Keep the full directory through review. -Passing means no `fail` result. `unsupported` is accepted only when the exported model lacks the -corresponding reasoning or image capability. The generated settings must remain free of real keys. +Passing means no `fail` or `error` result. `fail` records a completed assertion mismatch; `error` +records a timeout, nonzero exit, or caught execution exception. `unsupported` is accepted only when +the exported model lacks the corresponding reasoning or image capability. The generated settings +must remain free of real keys. ## Cleanup @@ -74,7 +76,7 @@ helper records their IDs in command output and does not delete unrelated history `scripts/run.ts` is executable and self-documenting: ```bash -node_modules/bun/bin/bun.exe .agents/skills/verify-droid-integration/scripts/run.ts --help +bun .agents/skills/verify-droid-integration/scripts/run.ts --help ``` ## Failure recovery diff --git a/.agents/skills/verify-droid-integration/features/image-input.md b/.agents/skills/verify-droid-integration/features/image-input.md index f2f2d3ac67d..7fd0ba8cce5 100644 --- a/.agents/skills/verify-droid-integration/features/image-input.md +++ b/.agents/skills/verify-droid-integration/features/image-input.md @@ -12,9 +12,9 @@ Select an image-capable OpenCodex custom model and ask Droid to inspect an image ## Driving it with Droid CLI -Run `--cases image`. The helper asks Droid to read `assets/pr-gate-screenshot-required.png` without -putting the headline in the prompt, then requires the exact pixel-only answer plus a matching Read -call and result. +Run `--cases image`. The helper copies `assets/pr-gate-screenshot-required.png` to an opaque random +filename, asks Droid to read that copy without putting the headline in the prompt, then requires the +exact pixel-only answer plus a matching Read call and two-part text/image result payload. ## Gotchas diff --git a/.agents/skills/verify-droid-integration/scripts/run.ts b/.agents/skills/verify-droid-integration/scripts/run.ts index 8773612b2c0..3f57d2b13df 100755 --- a/.agents/skills/verify-droid-integration/scripts/run.ts +++ b/.agents/skills/verify-droid-integration/scripts/run.ts @@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; -type Status = "pass" | "fail" | "unsupported"; +type Status = "pass" | "fail" | "error" | "unsupported"; interface DroidModel { id: string; @@ -149,9 +149,10 @@ function completionText(output: string): string { } function verdict(result: CommandResult, marker: string): CaseResult { - const ok = !result.timedOut && result.exitCode === 0 && completionText(result.stdout).trim() === marker; + const executionError = result.timedOut || result.exitCode !== 0; + const ok = !executionError && completionText(result.stdout).trim() === marker; return { - status: ok ? "pass" : "fail", + status: executionError ? "error" : ok ? "pass" : "fail", detail: result.timedOut ? "timed out" : ok @@ -161,6 +162,10 @@ function verdict(result: CommandResult, marker: string): CaseResult { }; } +function assertionStatus(base: CaseResult, ok: boolean): Status { + return base.status === "pass" ? (ok ? "pass" : "fail") : base.status; +} + function readRoundTrip( parsed: Record[], filePath: string, @@ -312,7 +317,9 @@ async function verifyModel(model: DroidModel): Promise { advertisesRead = false; } cases.catalog = { - status: !catalog.timedOut && catalog.exitCode === 0 && advertisesRead ? "pass" : "fail", + status: catalog.timedOut || catalog.exitCode !== 0 + ? "error" + : advertisesRead ? "pass" : "fail", detail: catalog.timedOut ? "timed out" : "exit " + catalog.exitCode + "; Read advertised=" + advertisesRead, @@ -344,7 +351,7 @@ async function verifyModel(model: DroidModel): Promise { const base = verdict(result, "STREAM_OK"); cases.stream = { ...base, - status: base.status === "pass" && hasLifecycle ? "pass" : "fail", + status: assertionStatus(base, hasLifecycle), detail: hasLifecycle ? base.detail : base.detail + "; stream lifecycle missing", evidence, }; @@ -370,7 +377,7 @@ async function verifyModel(model: DroidModel): Promise { const selected = init?.reasoning_effort === effort; cases.reasoning = { ...base, - status: base.status === "pass" && selected ? "pass" : "fail", + status: assertionStatus(base, selected), detail: selected ? base.detail + "; effort " + effort : base.detail + "; requested effort not observed", evidence, }; @@ -393,9 +400,10 @@ async function verifyModel(model: DroidModel): Promise { const base = verdict(result, "TOOL_OK:" + marker); cases.tool = { ...base, - status: base.status === "pass" && roundTrip.called && roundTrip.returned && resultContainsMarker - ? "pass" - : "fail", + status: assertionStatus( + base, + roundTrip.called && roundTrip.returned && resultContainsMarker, + ), detail: base.detail + "; Read call=" + roundTrip.called + "; result=" + roundTrip.returned + "; result marker=" + resultContainsMarker, evidence, @@ -405,7 +413,9 @@ async function verifyModel(model: DroidModel): Promise { if (model.noImageSupport === true) { cases.image = { status: "unsupported", detail: "exported model disables image input" }; } else { - const imagePath = join(repoRoot, "assets", "pr-gate-screenshot-required.png"); + const sourceImagePath = join(repoRoot, "assets", "pr-gate-screenshot-required.png"); + const imagePath = join(modelDir, "image-" + crypto.randomUUID() + ".png"); + await Bun.write(imagePath, Bun.file(sourceImagePath)); const result = await runCommand([ "droid", "exec", "--settings", settingsPath, "--model", model.id, "--cwd", repoRoot, "--only-tools", "Read", "--output-format", "stream-json", @@ -415,12 +425,14 @@ async function verifyModel(model: DroidModel): Promise { ], repoRoot, timeoutMs); const parsed = events(result.stdout); const roundTrip = readRoundTrip(parsed, imagePath); + const imagePayload = roundTrip.value === "[object Object],[object Object]"; const evidence = await writeEvidence(modelDir, "image", result); const base = verdict(result, "IMAGE_OK: UI screenshot required"); cases.image = { ...base, - status: base.status === "pass" && roundTrip.called && roundTrip.returned ? "pass" : "fail", - detail: base.detail + "; image Read call=" + roundTrip.called + "; result=" + roundTrip.returned, + status: assertionStatus(base, roundTrip.called && roundTrip.returned && imagePayload), + detail: base.detail + "; image Read call=" + roundTrip.called + "; result=" + roundTrip.returned + + "; image payload=" + imagePayload, evidence, }; } @@ -457,12 +469,27 @@ let cursor = 0; const workers = Array.from({ length: Math.min(concurrency, models.length) }, async () => { while (cursor < models.length) { const model = models[cursor++]!; - const result = await verifyModel(model); + let result: ModelResult; + try { + result = await verifyModel(model); + } catch (error) { + result = { + id: model.id, + model: model.model, + cases: { + execution: { + status: "error", + detail: error instanceof Error ? error.stack ?? error.message : String(error), + }, + }, + }; + } summary.results.push(result); summary.results.sort((a, b) => a.model.localeCompare(b.model)); await Bun.write(summaryPath, JSON.stringify(summary, null, 2) + "\n"); - const failed = Object.values(result.cases).filter(item => item.status === "fail").length; - console.log(model.model + ": " + (failed === 0 ? "PASS" : "FAIL (" + failed + ")")); + const nonPassing = Object.values(result.cases) + .filter(item => item.status === "fail" || item.status === "error").length; + console.log(model.model + ": " + (nonPassing === 0 ? "PASS" : "FAIL (" + nonPassing + ")")); } }); await Promise.all(workers); @@ -471,8 +498,10 @@ summary.finishedAt = new Date().toISOString(); await Bun.write(summaryPath, JSON.stringify(summary, null, 2) + "\n"); const failures = summary.results.flatMap(model => Object.entries(model.cases) - .filter(([, result]) => result.status === "fail") - .map(([name, result]) => ({ model: model.model, case: name, detail: result.detail }))); + .filter(([, result]) => result.status === "fail" || result.status === "error") + .map(([name, result]) => ({ + model: model.model, case: name, status: result.status, detail: result.detail, + }))); await Bun.write(join(runDir, "failures.json"), JSON.stringify(failures, null, 2) + "\n"); console.log("Evidence: " + runDir); console.log("Models: " + summary.results.length + "; failures: " + failures.length); diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b79f1ae7ce5..c4b3e996ff4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2911,6 +2911,6 @@ export const ru: Record = { "remote.event.tool": "Удалённый инструмент", "remote.event.error": "Ошибка", "integrations.tab.factoryDroid": "Factory Droid", - "integrations.semantics.factoryDroid": "Управляет активными моделями OpenCodex в settings.json Factory Droid. Синхронизация обновляет только записи OpenCodex, отключение удаляет их, а отмена восстанавливает предыдущий файл.", + "integrations.semantics.factoryDroid": "Управляет активными моделями OpenCodex в файле settings.json клиента Factory Droid. Синхронизация обновляет только записи OpenCodex, отключение удаляет их, а отмена восстанавливает предыдущий файл.", "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 272db7a2908..2a40fb5c431 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -163,16 +163,16 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep throw new CliUsageError(`${client} export is loopback-only because its config cannot carry OpenCodex's remote-admission header.`, USAGE); } let built: { document: unknown; text: string }; - if (client === "raycast") { + if (spec.loopbackOnly) { // The dial address alone cannot distinguish a wildcard authenticated bind // from loopback. Let the live server resolve its admission/listener policy; // saved config can differ from the process serving this request. const exported = await runtimeRequest<{ client: string; format: string; config: unknown; text: string; - }>("/api/client-config?client=raycast", {}, { ...deps, baseUrl: root }); - if (!exported || exported.client !== "raycast" || exported.format !== "yaml" + }>(`/api/client-config?client=${encodeURIComponent(client)}`, {}, { ...deps, baseUrl: root }); + if (!exported || exported.client !== client || exported.format !== spec.format || typeof exported.text !== "string" || exported.config === undefined) { - throw new RuntimeApiError("Management API returned an unexpected Raycast export payload.", 502, null); + throw new RuntimeApiError(`Management API returned an unexpected ${client} export payload.`, 502, null); } built = { document: exported.config, text: exported.text }; } else { diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 20cc91c9f6a..f39900f1561 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -123,6 +123,7 @@ const CLIENT_MANAGED_PATHS = { ["settings", "providers", OPENCODE_PROVIDER_ID], ["catalog", "providers", OPENCODE_PROVIDER_ID], ], + droid: [["customModels", DYNAMIC_SEGMENT]], } satisfies Record; /** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */ diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index fe1bbb911ed..05b85939372 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -13,8 +13,10 @@ preserves sibling rows and top-level settings; disable removes only recorded row the same snapshot transaction as every other JSON client. Droid reaches the loopback `/v1/chat/completions` surface through `generic-chat-completion-api`. The export stays loopback-only because its schema cannot carry the dedicated remote admission header without persisting a secret. -Each row uses Factory's documented 16,384-token response ceiling rather than turning the context -window into the request's output budget. +Each row uses a fixed 16,384-token output ceiling selected by the Droid export from Factory's +example because the catalog has no per-model output-limit field. It remains separate from the +request context window. Mutation plans publish each owned row as `customModels.*`, keeping the +model selector out of the value-free plan. Shared response support has a separate [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 0644a9271f4..36cc030eaf3 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { handleExportCommand, exportModelsFromProxyRows } from "../../src/cli/export-command"; +import { buildClientConfigText, isExportClientId } from "../../src/clients/config-export"; import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; @@ -58,6 +59,24 @@ function fakeProxy(rows: unknown = ROWS) { fetch(req) { const url = new URL(req.url); if (url.pathname === "/api/models") return Response.json(rows); + if (url.pathname === "/api/client-config") { + const client = url.searchParams.get("client") ?? ""; + if (!isExportClientId(client) || !Array.isArray(rows)) { + return Response.json({ error: "invalid fixture request" }, { status: 400 }); + } + const exportConfig = config(); + const built = buildClientConfigText(client, { + baseUrl: `http://127.0.0.1:${server.port}/v1`, + models: exportModelsFromProxyRows(rows, exportConfig), + config: exportConfig, + }); + return Response.json({ + client, + format: built.format, + config: built.document, + text: built.text, + }); + } return new Response("not found", { status: 404 }); }, }); @@ -477,7 +496,7 @@ describe("export allowlist parity", () => { try { process.env.OPENCODEX_HOME = home; writeFileSync(path, JSON.stringify(pending)); - const code = await handleExportCommand(["--client", "pi", "--json"], { + const code = await handleExportCommand(["--client", "opencode", "--json"], { baseUrl: "http://127.0.0.1:10123", fetchImpl: async input => { expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); @@ -489,7 +508,7 @@ describe("export allowlist parity", () => { }); expect(code).toBe(0); expect(requests).toBe(1); - expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + expect(Object.keys(JSON.parse(stdout()).provider.opencodex.models)) .toEqual(["pending/chosen"]); expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); } finally { @@ -517,7 +536,7 @@ describe("export allowlist parity", () => { process.env.OPENCODEX_HOME = home; const localBytes = JSON.stringify(local); writeFileSync(path, localBytes); - const code = await handleExportCommand(["--client", "pi", "--json"], { + const code = await handleExportCommand(["--client", "opencode", "--json"], { baseUrl: "http://127.0.0.1:10123", configImpl: () => { events.push("config"); return structuredClone(resolved); }, fetchImpl: async () => { @@ -528,7 +547,7 @@ describe("export allowlist parity", () => { }); expect(code).toBe(0); expect(events).toEqual(["fetch", "config"]); - expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + expect(Object.keys(JSON.parse(stdout()).provider.opencodex.models)) .toEqual(["custom/remote-only"]); expect(readFileSync(path, "utf8")).toBe(localBytes); } finally { @@ -568,9 +587,10 @@ describe("export allowlist parity", () => { }); }); -describe("Raycast export uses the live management admission policy", () => { - for (const secondary of [false, true]) { - test(`live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => { +describe("loopback-only exports use the live management admission policy", () => { + for (const client of ["raycast", "droid"] as const) { + for (const secondary of [false, true]) { + test(`${client} wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => { const oldHome = process.env.OPENCODEX_HOME; const oldCodexHome = process.env.CODEX_HOME; const root = tempDir(); @@ -587,17 +607,22 @@ describe("Raycast export uses the live management admission policy", () => { ...(secondary ? { unauthenticatedLoopbackListener: { enabled: true, port: 10237 } } : {}), }); const proxy = managementProxy(liveConfig); - const out = join(root, "providers.yaml"); + const out = join(root, client === "raycast" ? "providers.yaml" : "settings.json"); writeFileSync(out, "keep existing export\n"); - const result = await run(["--client", "raycast", "--json", "--out", out, "--force"], { + const result = await run(["--client", client, "--json", "--out", out, "--force"], { baseUrl: proxy.baseUrl, // Deliberately contradict both live bind and secondary port. config: config({ unauthenticatedLoopbackListener: { enabled: true, port: 10999 } }), }); if (secondary) { expect(result.code).toBe(0); - const document = JSON.parse(result.stdout) as { providers: Array<{ base_url: string }> }; - expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); + const document = JSON.parse(result.stdout) as { + providers?: Array<{ base_url: string }>; + customModels?: Array<{ baseUrl: string }>; + }; + expect(client === "raycast" + ? document.providers?.[0]?.base_url + : document.customModels?.[0]?.baseUrl).toBe("http://127.0.0.1:10237/v1"); expect(readFileSync(out, "utf8")).toContain("10237/v1"); expect(readFileSync(out, "utf8")).not.toContain("10999"); } else { @@ -612,6 +637,7 @@ describe("Raycast export uses the live management admission policy", () => { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; } - }); + }); + } } }); From b153329bff162d9b45234203f6b25f016594f351 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 12:11:31 +0900 Subject: [PATCH 04/16] docs: refresh Droid integration screenshot --- assets/factory-droid-integration.png | Bin 44812 -> 46491 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/factory-droid-integration.png b/assets/factory-droid-integration.png index 252bfa024edbdd33536a0082f8ecaf73d4782af8..1f2cdf44efa9f35f25467d1a21bdea148a8e3cc4 100644 GIT binary patch literal 46491 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

VQMy^G#ES_*#XC*RE?AM4gjBu;u(LqT+ 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 literal 44812 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

q1Os%@(7zE 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⪙)F>__@$diNA) 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 From cb4fb4397c6791bc1946623d649437c899e227e5 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 12:15:53 +0900 Subject: [PATCH 05/16] fix(droid): reject credential-bearing verification exports --- .agents/skills/verify-droid-integration/scripts/run.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/verify-droid-integration/scripts/run.ts b/.agents/skills/verify-droid-integration/scripts/run.ts index 3f57d2b13df..88af62e721c 100755 --- a/.agents/skills/verify-droid-integration/scripts/run.ts +++ b/.agents/skills/verify-droid-integration/scripts/run.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { mkdir } from "node:fs/promises"; +import { mkdir, unlink } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; type Status = "pass" | "fail" | "error" | "unsupported"; @@ -208,6 +208,10 @@ const settings = await Bun.file(settingsPath).json() as DroidSettings; if (!Array.isArray(settings.customModels) || settings.customModels.length === 0) { throw new Error("exported settings contain no customModels"); } +if (settings.customModels.some(model => Object.hasOwn(model, "apiKey"))) { + await unlink(settingsPath); + throw new Error("exported settings contain an API key field"); +} const ids = settings.customModels.map(model => model.id); if (new Set(ids).size !== ids.length) throw new Error("exported custom model IDs are not unique"); if (settings.customModels.some(model => model.provider !== "generic-chat-completion-api")) { From b9a0f30bb8a303ef3b1a36a597db754853c26c42 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 12:40:48 +0900 Subject: [PATCH 06/16] test: bound standalone shim probe --- tests/codex-integration/codex-shim-destroyed-probe.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-shim-destroyed-probe.test.ts b/tests/codex-integration/codex-shim-destroyed-probe.test.ts index 1263b676898..41bb8270db7 100644 --- a/tests/codex-integration/codex-shim-destroyed-probe.test.ts +++ b/tests/codex-integration/codex-shim-destroyed-probe.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { rmSync } from "node:fs"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { withInstalledShim } from "../helpers/codex-shim-install-fixture"; +import { setCodexShimProbeObservationMsForTests } from "../../src/codex/shim"; + +setCodexShimProbeObservationMsForTests(20); +afterAll(() => setCodexShimProbeObservationMsForTests(null)); describe("version-manager shim destruction (#2412)", () => { test("a destroyed shim diagnostic does not open a non-file launcher", () => { From c2f3b86631763952a2ee8f6bf09c8ac0c5142764 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 15:48:08 +0900 Subject: [PATCH 07/16] Address PR review feedback (#5193) - bound timed-out verifier processes with forced termination - require structured image evidence - clarify Vietnamese disable and undo semantics --- .../verify-droid-integration/scripts/run.ts | 56 ++++++++++++++++--- gui/src/i18n/vi.ts | 2 +- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.agents/skills/verify-droid-integration/scripts/run.ts b/.agents/skills/verify-droid-integration/scripts/run.ts index 88af62e721c..ab6870f7013 100755 --- a/.agents/skills/verify-droid-integration/scripts/run.ts +++ b/.agents/skills/verify-droid-integration/scripts/run.ts @@ -99,17 +99,30 @@ async function runCommand(command: string[], cwd: string, timeout: number): Prom const stdoutPromise = new Response(proc.stdout).text(); const stderrPromise = new Response(proc.stderr).text(); let timedOut = false; + let forceKillTimer: ReturnType | undefined; + const kill = (signal: "SIGTERM" | "SIGKILL") => { + try { + proc.kill(signal); + } catch { + // The process may exit between the timeout and the signal delivery. + } + }; const timer = setTimeout(() => { timedOut = true; - proc.kill(); + kill("SIGTERM"); + forceKillTimer = setTimeout(() => kill("SIGKILL"), 1_000); }, timeout); - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - stdoutPromise, - stderrPromise, - ]); - clearTimeout(timer); - return { command, exitCode, stdout, stderr, durationMs: Date.now() - started, timedOut }; + try { + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + stdoutPromise, + stderrPromise, + ]); + return { command, exitCode, stdout, stderr, durationMs: Date.now() - started, timedOut }; + } finally { + clearTimeout(timer); + if (forceKillTimer) clearTimeout(forceKillTimer); + } } async function writeEvidence( @@ -182,6 +195,31 @@ function readRoundTrip( return { called: true, returned: result !== undefined, value: result?.value }; } +function isPngImagePayload(value: unknown): boolean { + if (!Array.isArray(value) || value.length !== 2) return false; + const hasText = value.some(part => { + if (!part || typeof part !== "object" || Array.isArray(part)) return false; + const record = part as Record; + return record.type === "text" + && typeof record.text === "string" + && record.text.trim().length > 0; + }); + const hasImage = value.some(part => { + if (!part || typeof part !== "object" || Array.isArray(part)) return false; + const record = part as Record; + const source = record.source; + if (record.type !== "image" || !source || typeof source !== "object" || Array.isArray(source)) { + return false; + } + const imageSource = source as Record; + return imageSource.type === "base64" + && imageSource.media_type === "image/png" + && typeof imageSource.data === "string" + && imageSource.data.length > 0; + }); + return hasText && hasImage; +} + function safeDirName(model: DroidModel): string { const slug = model.model.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80); return slug + "-" + Bun.hash(model.id).toString(16); @@ -429,7 +467,7 @@ async function verifyModel(model: DroidModel): Promise { ], repoRoot, timeoutMs); const parsed = events(result.stdout); const roundTrip = readRoundTrip(parsed, imagePath); - const imagePayload = roundTrip.value === "[object Object],[object Object]"; + const imagePayload = isPngImagePayload(roundTrip.value); const evidence = await writeEvidence(modelDir, "image", result); const base = verdict(result, "IMAGE_OK: UI screenshot required"); cases.image = { diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 78bc7c476b9..567e6a404fe 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2891,7 +2891,7 @@ export const vi: Record = { "remote.event.tool": "Công cụ từ xa", "remote.event.error": "Lỗi", "integrations.tab.factoryDroid": "Factory Droid", - "integrations.semantics.factoryDroid": "Quản lý các mô hình OpenCodex đang hoạt động trong settings.json của Factory Droid. Đồng bộ chỉ cập nhật các mục OpenCodex, tắt sẽ xóa chúng và hoàn tác khôi phục tệp trước đó.", + "integrations.semantics.factoryDroid": "Quản lý các mô hình OpenCodex đang hoạt động trong settings.json của Factory Droid. Đồng bộ chỉ cập nhật các mục OpenCodex. Khi tắt, các mục này sẽ bị xóa. Khi hoàn tác, tệp trước đó sẽ được khôi phục.", "api.clientConfig.clientFactoryDroid": "Factory Droid", "models.newPolicyGlobal": "Model mới mặc định bị tắt", "models.newPolicyProvider": "Chính sách model mới", From dac99771c06f710a5ac73a92a366896ea2710ea7 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 20 Sep 2026 16:10:29 +0900 Subject: [PATCH 08/16] Test Droid unknown image modalities (#5193) --- tests/clients/droid-client.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/clients/droid-client.test.ts b/tests/clients/droid-client.test.ts index 31eefa21527..e9a06b22866 100644 --- a/tests/clients/droid-client.test.ts +++ b/tests/clients/droid-client.test.ts @@ -113,9 +113,14 @@ describe("Factory Droid client config", () => { expect("maxContextLimit" in composer).toBe(false); expect("enableThinking" in composer).toBe(false); const unknown = document.customModels.find(model => model.model === "mock/unknown")!; + expect(unknown.noImageSupport).toBe(true); expect(unknown.supportedReasoningEfforts).toEqual(["low", "high"]); expect("defaultReasoningEffort" in unknown).toBe(false); expect("reasoningEffort" in unknown).toBe(false); + const emptyModalities = buildClientConfig("droid", context([ + { namespaced: "mock/empty-modalities", provider: "mock", id: "empty-modalities", inputModalities: [] }, + ])) as DroidGeneratedConfig; + expect(emptyModalities.customModels[0]!.noImageSupport).toBe(true); for (const model of document.customModels) { expect("apiKey" in model).toBe(false); expect(model.maxOutputTokens).toBe(16_384); From 8c7cd0680d4c27aadaa92c515764cce0674e3a84 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Mon, 21 Sep 2026 09:46:17 +0900 Subject: [PATCH 09/16] fix(gui): accept Droid mutation plan paths --- gui/src/pages/integrations/integration-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 2cd68e1ca4d..b7ab85d86d6 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -236,6 +236,7 @@ const PLAN_SCHEMA_PATHS = new Set([ "models.*", "llm-pi-ai.providers.opencodex", "custom_provider.opencodex", + "customModels.*", "providers.[id=opencodex]", "settings.providers.opencodex", "catalog.providers.opencodex", From 0589fc3720380c7f03d04c5e8aeaf5926a2491a6 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Mon, 21 Sep 2026 17:22:05 +0900 Subject: [PATCH 10/16] test(desktop): follow moved ownership validator --- tests/clients/desktop-install-identity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 797b2192e1b..87e11e8b615 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -6,7 +6,7 @@ import { repoPath } from "../helpers/repo-root"; * The desktop app's half of the runtime-ownership claim. * * The claim lives in the shared service install state, which core owns across two files: the - * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * validation that decides what a record may say is in `src/service/state-record.mjs`, * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an * installation applies to its own locally stored install id — are in `src/service/state.ts`. The * shell holds the other half, an id of its own to compare against, and mirrors the rule rather @@ -21,7 +21,7 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); -const CONTRACT = repoPath("src/service/install-state-contract.mjs"); +const CONTRACT = repoPath("src/service/state-record.mjs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); From 0282d93c19391653e6371e3d30977b499db7af0c Mon Sep 17 00:00:00 2001 From: shawnkim Date: Mon, 21 Sep 2026 18:39:44 +0900 Subject: [PATCH 11/16] fix(gui): clarify Factory Droid undo wording (#5193) --- gui/src/i18n/de.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6657a33d45a..5bbd7e7049f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3075,6 +3075,6 @@ export const de: Record = { "remote.event.tool": "Remote-Werkzeug", "remote.event.error": "Fehler", "integrations.tab.factoryDroid": "Factory Droid", - "integrations.semantics.factoryDroid": "Verwaltet aktive OpenCodex-Modelle in der settings.json von Factory Droid. Synchronisieren ändert nur OpenCodex-Einträge, Deaktivieren entfernt sie und Rückgängig stellt die vorige Datei wieder her.", + "integrations.semantics.factoryDroid": "Verwaltet aktive OpenCodex-Modelle in der settings.json von Factory Droid. Synchronisieren ändert nur OpenCodex-Einträge; beim Deaktivieren werden sie entfernt, und mit „Rückgängig“ wird die vorherige Datei wiederhergestellt.", "api.clientConfig.clientFactoryDroid": "Factory Droid", }; From 7255a44f2fb2ab241458c1b80b69fdd15b125c87 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Fri, 25 Sep 2026 20:19:01 +0900 Subject: [PATCH 12/16] test: isolate readiness checks from host state and DNS --- scripts/test.ts | 6 ++-- structure/clients/integrations.md | 5 +-- .../native-codex-toggle.test.ts | 36 ++++++++++++++----- .../protocol-direct-encoders-chat.test.ts | 2 +- .../management-provider-validation.test.ts | 18 ++++++++++ tests/service/service-claim.test.ts | 8 +++-- 6 files changed, 58 insertions(+), 17 deletions(-) diff --git a/scripts/test.ts b/scripts/test.ts index 6db9075224a..f6d1ba3c2bf 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -381,8 +381,10 @@ export const SERIAL_FULL_SUITE_FILES = [ // changing. Quarantining it here is what keeps it a test of the relay instead of a test of // its neighbours. "server/server-live.test.ts", - // These exercise the default-home service authority, shared by parallel Bun workers. - // A fresh process/home prevents another file's authority from becoming this fixture's input. + // These exercise process-wide service-home and test-guard state. A fresh process/home + // prevents a parallel fixture from changing their authority or guard between assertions. + "service/service-claim.test.ts", + "service/service-wsl-home-ownership.test.ts", "service/service-ownership-state.test.ts", "service/service-sqlite-home.test.ts", "service/service.test.ts", diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 04860e29df7..40cd723b57a 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -138,8 +138,9 @@ their existing visibility rules. ## Owned catalog convergence -Visibility, selected-model and preset writes refresh already-owned Pi/Aside contributions after -persisting the selection. Explicit sync refreshes MCode, Pi and Aside. The shared catalog-refresh +Visibility, selected-model and preset writes refresh already-owned Pi, Aside, Raycast, omo and +Factory Droid contributions after persisting the selection. Explicit sync also refreshes MCode +and Cline. The shared catalog-refresh fan-out loads the filtered roster lazily once, leaves unowned clients alone, and reports each refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy diff --git a/tests/codex-integration/native-codex-toggle.test.ts b/tests/codex-integration/native-codex-toggle.test.ts index dde313a8718..c545634b641 100644 --- a/tests/codex-integration/native-codex-toggle.test.ts +++ b/tests/codex-integration/native-codex-toggle.test.ts @@ -11,12 +11,14 @@ * so a process that dies between the two leaves a decision the next start can * act on — rather than artifacts the next start silently undoes. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; +import * as codexSync from "../../src/codex/sync"; +import type { CodexSyncResult } from "../../src/codex/sync"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -159,15 +161,31 @@ describe("turning Codex off", () => { test("the status read follows an off-then-on round trip against the same stale server config", async () => { const serverConfig = baseConfig(); await put(serverConfig, { enabled: false }); - await put(serverConfig, { enabled: true }); - expect(persistedCodexIntent()).not.toBe(false); + const applied: CodexSyncResult = { + status: "applied", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + message: "test sync applied", + }; + const sync = spyOn(codexSync, "syncModelsToCodex").mockResolvedValue(applied); + try { + const enabled = await put(serverConfig, { enabled: true }); + expect(enabled.body).toMatchObject({ state: "current", desiredEnabled: true }); + expect(persistedCodexIntent()).not.toBe(false); - const response = await dispatch(serverConfig, "/api/native-integrations"); - const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; - expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ - state: "current", - desiredEnabled: true, - }); + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ + state: "current", + desiredEnabled: true, + }); + } finally { + sync.mockRestore(); + } }); test("a failed native restore stays unsafe on the next status read", async () => { diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index 36c56cb1f5a..b51f4c63016 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -55,7 +55,7 @@ function directOptions(options: ToolOptions = {}) { } function normalizeFrames(text: string): unknown[] { - return text.split("\n\n").filter(block => block.trim().length > 0).map(block => { + return text.split("\n\n").filter(block => block.split("\n").some(line => line.startsWith("data:"))).map(block => { const data = block.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join(""); if (data === "[DONE]") return "[DONE]"; const parsed = JSON.parse(data) as Record; diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 46da86d4234..18aebfce3af 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -1,5 +1,6 @@ import { config, registerRelativeSendPathTests } from "../helpers/management-relative-send-paths"; import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import * as dnsPromises from "node:dns/promises"; import { managementFetch as fetch, ManagementRequest as Request } from "../helpers/management-auth"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -108,6 +109,18 @@ function stubModelDiscoveryFor(...origins: string[]): void { }) as typeof fetch; } +function stubPublicDestinationDnsFor(...hostnames: string[]) { + const deterministicHosts = new Set(hostnames); + const originalLookup = dnsPromises.lookup; + return spyOn(dnsPromises, "lookup").mockImplementation(((hostname: string, options?: unknown) => { + if (deterministicHosts.has(hostname) && options && typeof options === "object" + && "all" in options && options.all === true) { + return Promise.resolve([{ address: "8.8.8.8", family: 4 }]); + } + return originalLookup(hostname, options as never); + }) as typeof dnsPromises.lookup); +} + beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-server-auth-codex-"); }); @@ -2651,6 +2664,7 @@ describe("provider management validation", () => { process.env.OPENCODEX_HOME = TEST_DIR; saveConfig(config("127.0.0.1")); stubModelDiscoveryFor("https://api.example.com", "http://127.0.0.1:11434"); + const dnsLookup = stubPublicDestinationDnsFor("api.example.com"); const server = startServer(0); try { @@ -2688,6 +2702,7 @@ describe("provider management validation", () => { expect(saved.providers["patch-test"].allowPrivateNetwork).toBe(true); expect(saved.providers["patch-test"].baseUrl).toContain("127.0.0.1"); } finally { + dnsLookup.mockRestore(); await server.stop(true); } }); @@ -2731,6 +2746,8 @@ describe("provider management validation", () => { mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; saveConfig(config("127.0.0.1")); + stubModelDiscoveryFor("https://api.example.com"); + const dnsLookup = stubPublicDestinationDnsFor("api.example.com"); const server = startServer(0); try { @@ -2780,6 +2797,7 @@ describe("provider management validation", () => { }; expect(saved.providers["discovery-toggle"].liveModels).toBe(false); } finally { + dnsLookup.mockRestore(); await server.stop(true); } }); diff --git a/tests/service/service-claim.test.ts b/tests/service/service-claim.test.ts index d886631522d..04544bd4569 100644 --- a/tests/service/service-claim.test.ts +++ b/tests/service/service-claim.test.ts @@ -147,8 +147,10 @@ describe("runServiceClaim", () => { const previousUserProfile = process.env.USERPROFILE; if (process.platform === "win32") process.env.USERPROFILE = home.root; try { - expect(serviceStatePaths().every(path => path.startsWith(home.root))).toBe(true); - mkdirSync(serviceStatePath()); + const sandboxStatePath = serviceStatePath(); + expect(sandboxStatePath).toBe(home.path("service-state.json")); + expect(serviceStatePaths()).toContain(sandboxStatePath); + mkdirSync(sandboxStatePath); const lines: string[] = []; const code = await runServiceClaim([...VALID, "--json"], { stdout: { log: value => lines.push(value) }, @@ -157,7 +159,7 @@ describe("runServiceClaim", () => { expect(JSON.parse(lines[0]!)).toMatchObject({ schema: CLAIM_SCHEMA, ok: false, code: "service-ownership-subject-unknown", }); - expect(statSync(serviceStatePath()).isDirectory()).toBe(true); + expect(statSync(sandboxStatePath).isDirectory()).toBe(true); } finally { if (process.platform === "win32") { if (previousUserProfile === undefined) delete process.env.USERPROFILE; From 268f14ba2fa1b477e3e04c2c3edb1912c1ec0954 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Fri, 25 Sep 2026 20:20:20 +0900 Subject: [PATCH 13/16] test: keep provider validation within file-size cap --- tests/helpers/public-destination-dns.ts | 14 ++++++++++++++ .../server/management-provider-validation.test.ts | 14 +------------- 2 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 tests/helpers/public-destination-dns.ts diff --git a/tests/helpers/public-destination-dns.ts b/tests/helpers/public-destination-dns.ts new file mode 100644 index 00000000000..10f3d6a97a6 --- /dev/null +++ b/tests/helpers/public-destination-dns.ts @@ -0,0 +1,14 @@ +import { spyOn } from "bun:test"; +import * as dnsPromises from "node:dns/promises"; + +export function stubPublicDestinationDnsFor(...hostnames: string[]) { + const deterministicHosts = new Set(hostnames); + const originalLookup = dnsPromises.lookup; + return spyOn(dnsPromises, "lookup").mockImplementation(((hostname: string, options?: unknown) => { + if (deterministicHosts.has(hostname) && options && typeof options === "object" + && "all" in options && options.all === true) { + return Promise.resolve([{ address: "8.8.8.8", family: 4 }]); + } + return originalLookup(hostname, options as never); + }) as typeof dnsPromises.lookup); +} diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 18aebfce3af..47eeccf7e42 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -1,6 +1,6 @@ import { config, registerRelativeSendPathTests } from "../helpers/management-relative-send-paths"; import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; -import * as dnsPromises from "node:dns/promises"; +import { stubPublicDestinationDnsFor } from "../helpers/public-destination-dns"; import { managementFetch as fetch, ManagementRequest as Request } from "../helpers/management-auth"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -109,18 +109,6 @@ function stubModelDiscoveryFor(...origins: string[]): void { }) as typeof fetch; } -function stubPublicDestinationDnsFor(...hostnames: string[]) { - const deterministicHosts = new Set(hostnames); - const originalLookup = dnsPromises.lookup; - return spyOn(dnsPromises, "lookup").mockImplementation(((hostname: string, options?: unknown) => { - if (deterministicHosts.has(hostname) && options && typeof options === "object" - && "all" in options && options.all === true) { - return Promise.resolve([{ address: "8.8.8.8", family: 4 }]); - } - return originalLookup(hostname, options as never); - }) as typeof dnsPromises.lookup); -} - beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-server-auth-codex-"); }); From 3607336027404cd4a5f3d1004ae04e52d585e82b Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sat, 26 Sep 2026 12:32:24 +0900 Subject: [PATCH 14/16] docs: align translated integration refresh rosters --- docs-site/src/content/docs/fr/guides/integrations.md | 2 +- docs-site/src/content/docs/guides/integrations.md | 2 +- docs-site/src/content/docs/ja/guides/integrations.md | 2 +- docs-site/src/content/docs/ko/guides/integrations.md | 2 +- docs-site/src/content/docs/ru/guides/integrations.md | 4 ++-- docs-site/src/content/docs/tr/guides/integrations.md | 2 +- docs-site/src/content/docs/zh-cn/guides/integrations.md | 2 +- docs-site/src/content/docs/zh-tw/guides/integrations.md | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index d809e21a6ed..deb8cf5948a 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -232,7 +232,7 @@ ocx mcode ``` Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, -Pi, Aside, Raycast, omo et Factory Droid gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Pi, Aside, Raycast, omo, Cline et Factory Droid gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside, Raycast, omo et Factory Droid. Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 6af5cbb0576..94e1c51441e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -327,7 +327,7 @@ ocx mcode ``` Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, -Raycast, omo, and Factory Droid catalogs with the current model selection, context windows, and +Raycast, omo, Cline, and Factory Droid catalogs with the current model selection, context windows, and reasoning-effort ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, provider selection, or presets also refresh connected Pi, Aside, Raycast, omo, and Factory Droid catalogs. diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 43ccd49d704..e317e78f366 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -140,7 +140,7 @@ ocx integration client enable --client mcode ocx mcode ``` -接続後、`ocx sync` と `POST /api/sync` は、所有する MCode、Pi、Aside、Raycast、omo のカタログを現在のモデル選択、コンテキストウィンドウ、推論負荷の段階で更新します。プロキシ起動時には所有する Raycast カタログを更新します。モデルの表示設定、プロバイダーの選択、プリセットの変更でも、接続済みの Pi、Aside、Raycast、omo カタログが更新されます。存在しないブロック、他者が編集したブロック、安全でないブロック、および手動で削除した以前の所有ブロックには触れません。 +接続後、`ocx sync` と `POST /api/sync` は、所有する MCode、Pi、Aside、Raycast、omo、Cline、Factory Droid のカタログを現在のモデル選択、コンテキストウィンドウ、推論負荷の段階で更新します。プロキシ起動時には所有する Raycast カタログを更新します。モデルの表示設定、プロバイダーの選択、プリセットの変更でも、接続済みの Pi、Aside、Raycast、omo、Factory Droid カタログが更新されます。存在しないブロック、他者が編集したブロック、安全でないブロック、および手動で削除した以前の所有ブロックには触れません。 有効な Aside プロファイルは、通常の「所有済みのみ更新」の例外です。アカウントディレクトリが存在し、まだ所有ブロックがなく、その場所が空であれば、同期時に最初のブロックを作れます。以前の Aside 接続があれば、登録済みの全プロファイルでこの動作がデフォルトで有効になります。同期は存在しないアカウントディレクトリを作らず、手動のブロックも置き換えません。拒否または重複する更新は、クライアントごとに別々に報告されます。更新されたファイルを読み込むには、新しい Pi セッションを開始するか、Aside を完全に終了して開き直してください。Aside の更新には[対応する稼働中のプロキシ](#aside-プロファイルの管理)が必要です。 diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index 5d27c27ba69..4ffb3978b70 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -140,7 +140,7 @@ ocx integration client enable --client mcode ocx mcode ``` -연결 후 `ocx sync`와 `POST /api/sync`는 소유한 MCode, Pi, Aside, Raycast, omo 카탈로그를 현재 모델 선택, 컨텍스트 창, 추론 강도 단계로 갱신합니다. 프록시 시작 시 소유한 Raycast 카탈로그도 갱신합니다. 모델 표시 여부, 프로바이더 선택, 프리셋이 바뀌어도 연결된 Pi, Aside, Raycast, omo 카탈로그를 갱신합니다. 누락되거나 외부에서 수정되었거나 안전하지 않은 블록, 그리고 이전에 소유했지만 사용자가 직접 삭제한 블록은 그대로 둡니다. 활성화된 Aside 프로필은 일반적인 소유 블록만 갱신하는 규칙의 예외입니다. 계정 디렉터리가 있고 소유 블록이 생긴 적이 없다면 해당 슬롯이 비어 있을 때 동기화로 첫 블록을 만들 수 있습니다. 이전 Aside 연결이 있으면 이 동작이 기본적으로 등록된 모든 프로필에 적용됩니다. 동기화는 없는 계정 디렉터리를 만들거나 수동 블록을 교체하지 않습니다. 거부되거나 겹친 갱신은 클라이언트마다 따로 보고합니다. 갱신 파일을 읽으려면 새 Pi 세션을 시작하거나 Aside를 완전히 종료하고 다시 여세요. Aside 갱신에는 [호환되는 실행 중 프록시](#aside-프로필-제어)가 필요합니다. +연결 후 `ocx sync`와 `POST /api/sync`는 소유한 MCode, Pi, Aside, Raycast, omo, Cline, Factory Droid 카탈로그를 현재 모델 선택, 컨텍스트 창, 추론 강도 단계로 갱신합니다. 프록시 시작 시 소유한 Raycast 카탈로그도 갱신합니다. 모델 표시 여부, 프로바이더 선택, 프리셋이 바뀌어도 연결된 Pi, Aside, Raycast, omo, Factory Droid 카탈로그를 갱신합니다. 누락되거나 외부에서 수정되었거나 안전하지 않은 블록, 그리고 이전에 소유했지만 사용자가 직접 삭제한 블록은 그대로 둡니다. 활성화된 Aside 프로필은 일반적인 소유 블록만 갱신하는 규칙의 예외입니다. 계정 디렉터리가 있고 소유 블록이 생긴 적이 없다면 해당 슬롯이 비어 있을 때 동기화로 첫 블록을 만들 수 있습니다. 이전 Aside 연결이 있으면 이 동작이 기본적으로 등록된 모든 프로필에 적용됩니다. 동기화는 없는 계정 디렉터리를 만들거나 수동 블록을 교체하지 않습니다. 거부되거나 겹친 갱신은 클라이언트마다 따로 보고합니다. 갱신 파일을 읽으려면 새 Pi 세션을 시작하거나 Aside를 완전히 종료하고 다시 여세요. Aside 갱신에는 [호환되는 실행 중 프록시](#aside-프로필-제어)가 필요합니다. Models에 **“Model selection saved”**와 클라이언트 갱신 경고가 함께 표시되면 선택 자체는 저장되었지만 클라이언트 파일 하나 이상을 갱신하지 못한 상태입니다. 경고는 해당 클라이언트와, 필요하면 Aside 프로필을 알려주고 거부 이유를 설명합니다. 새 세션을 시작하기 전에 **Integrations**에서 해당 클라이언트나 프로필을 확인하세요. 문제를 해결한 뒤 `ocx sync`를 다시 실행합니다. 겹친 작업은 먼저 끝나야 합니다. 경고에 백업 경로가 있거나 복구가 완료되지 않았다고 나오면 재시도 전 복구 상태를 확인하세요. 선택 저장 성공만으로 클라이언트 파일 복구까지 확인된 것은 아닙니다. diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index e496ae9b70f..5c1dfee5e43 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -335,10 +335,10 @@ ocx mcode ``` После подключения `ocx sync` и `POST /api/sync` обновляют принадлежащие -интеграции каталоги MCode, Pi, Aside, Raycast и omo с текущим выбором моделей, +интеграции каталоги MCode, Pi, Aside, Raycast, omo, Cline и Factory Droid с текущим выбором моделей, контекстными окнами и ступенями effort. Запуск прокси обновляет принадлежащий ему каталог Raycast. Изменения видимости моделей, выбора провайдера или пресетов -также обновляют подключённые каталоги Pi, Aside, Raycast и omo. +также обновляют подключённые каталоги Pi, Aside, Raycast, omo и Factory Droid. Отсутствующие, изменённые извне или небезопасные блоки остаются нетронутыми, как и ранее принадлежавшие интеграции блоки, удалённые вами вручную. Есть исключение для включённого профиля Aside: если каталог его аккаунта diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index bdbc2e25599..2da47df311b 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -257,7 +257,7 @@ ocx mcode ``` Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside, -Raycast, omo ve Factory Droid kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast +Raycast, omo, Cline ve Factory Droid kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, Aside, Raycast, omo ve Factory Droid kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 2d7a03b7f75..e34ff6255c1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -140,7 +140,7 @@ ocx integration client enable --client mcode ocx mcode ``` -连接后,`ocx sync` 和 `POST /api/sync` 会按当前模型选择、上下文窗口及推理强度级别刷新已管理的 MCode、Pi、Aside、Raycast 和 omo 目录。代理启动时会刷新已管理的 Raycast 目录。模型可见性、提供商选择或预设变化,也会刷新已连接的 Pi、Aside、Raycast 和 omo 目录。缺失、被外部编辑或不安全的配置块不会被触碰;此前归 OpenCodex 管理、但被你手动删除的配置块也不会重建。已启用的 Aside 配置文件是“仅刷新已管理配置块”规则的例外:如果账户目录存在且从未有过已管理配置块,当该位置为空时,同步可以创建首个配置块。此前连接过 Aside 会默认对所有已注册配置文件启用这一行为。同步不会创建缺失的账户目录,也不会替换手动配置块。拒绝或重叠的刷新会按客户端分别报告。启动新 Pi 会话,或完全退出并重新打开 Aside,才能加载更新后的文件。Aside 刷新要求[运行中的兼容代理](#aside-配置文件控制)。 +连接后,`ocx sync` 和 `POST /api/sync` 会按当前模型选择、上下文窗口及推理强度级别刷新已管理的 MCode、Pi、Aside、Raycast、omo、Cline 和 Factory Droid 目录。代理启动时会刷新已管理的 Raycast 目录。模型可见性、提供商选择或预设变化,也会刷新已连接的 Pi、Aside、Raycast、omo 和 Factory Droid 目录。缺失、被外部编辑或不安全的配置块不会被触碰;此前归 OpenCodex 管理、但被你手动删除的配置块也不会重建。已启用的 Aside 配置文件是“仅刷新已管理配置块”规则的例外:如果账户目录存在且从未有过已管理配置块,当该位置为空时,同步可以创建首个配置块。此前连接过 Aside 会默认对所有已注册配置文件启用这一行为。同步不会创建缺失的账户目录,也不会替换手动配置块。拒绝或重叠的刷新会按客户端分别报告。启动新 Pi 会话,或完全退出并重新打开 Aside,才能加载更新后的文件。Aside 刷新要求[运行中的兼容代理](#aside-配置文件控制)。 如果 Models 同时显示 **“Model selection saved”** 和客户端刷新警告,说明选择已保存,但一个或多个客户端文件未能更新。警告会指出受影响的客户端,以及适用时的 Aside 配置文件,并解释拒绝原因。启动新会话前,请打开 **Integrations** 检查该客户端或配置文件。处理报告的问题后,重试 `ocx sync`;重叠操作必须先完成。如果警告包含备份路径,或指出恢复未完成,请在重试前检查恢复状态。仅有选择保存成功的提示,并不能证明客户端文件恢复完成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 626b75ea9d3..a2601754d80 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -150,7 +150,7 @@ ocx mcode ``` 完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 -MCode、Pi、Aside、Raycast、omo 與 Factory Droid 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +MCode、Pi、Aside、Raycast、omo、Cline 與 Factory Droid 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 模型可見性、provider 或 preset 變更會更新 Pi、Aside、Raycast、omo 與 Factory Droid。若區塊已刪除、 遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 連接時才再次執行 enable。 From 8ae73ae11c61d01695a6a1e3a507d7728f25c485 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sat, 26 Sep 2026 13:34:03 +0900 Subject: [PATCH 15/16] test: isolate service claim refusal from legacy state --- tests/service/service-claim.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/service/service-claim.test.ts b/tests/service/service-claim.test.ts index 04544bd4569..d490df9ced9 100644 --- a/tests/service/service-claim.test.ts +++ b/tests/service/service-claim.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, statSync } from "node:fs"; import { parseClaimArgs, runServiceClaim, CLAIM_SCHEMA } from "../../src/service/claim"; -import { ServiceOwnershipSubjectMismatchError, serviceStatePath, serviceStatePaths } from "../../src/service/state"; +import { recordServiceOwner, ServiceOwnershipSubjectMismatchError, serviceStatePath, serviceStatePaths } from "../../src/service/state"; import type { ServiceOwnershipSubject } from "../../src/service/state"; import { createTempHome } from "../helpers/temp-home"; @@ -153,6 +153,8 @@ describe("runServiceClaim", () => { mkdirSync(sandboxStatePath); const lines: string[] = []; const code = await runServiceClaim([...VALID, "--json"], { + // Keep legacy default-home records from overriding this unreadable fixture. + recordOwner: (request, deps) => recordServiceOwner(request, { ...deps, paths: [sandboxStatePath] }), stdout: { log: value => lines.push(value) }, }); expect(code).toBe(1); From 68aad0821f8ab006590d5da15b1c8c20c9932bba Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sat, 26 Sep 2026 13:52:58 +0900 Subject: [PATCH 16/16] test: run history lock checks in a fresh process --- scripts/test.ts | 2 ++ structure/ops/docs-and-release.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test.ts b/scripts/test.ts index f6d1ba3c2bf..7b6c2ea0e6f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -371,6 +371,8 @@ export const SERIAL_FULL_SUITE_FILES = [ // Synchronous injection subprocesses can wedge the long-lived macOS isolate // parent while reaping a history Worker; contain them in a fresh bounded lane. "codex-integration/codex-inject-write-lock.test.ts", + // History-lock warm-up children can stall after the long-lived macOS isolate pool. + "codex-integration/codex-history-lock.test.ts", "update/update-stop-first.test.ts", // Relays a 50 MiB WebSocket frame end to end against a 15s deadline, so its result is a // measurement of the whole process, not of the relay. On a healthy 3-CPU macOS runner the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 1b8c912451a..475999bfb93 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -205,7 +205,8 @@ it is promoted, so those files follow the promotion model rather than ordinary i `scripts/test.ts` owns `SERIAL_FULL_SUITE_FILES`, the shared process-isolation roster. Local full-suite runs, both macOS paths, and `scripts/ci/run-bun-test-batches.sh` execute those files -alone with fresh process homes. Hosted batches assign shard membership by the per-file durations +alone with fresh process homes. The history-lock suite runs alone so its child warm-up starts +outside the long-lived isolate pool. Hosted batches assign shard membership by the per-file durations in `scripts/ci/test-durations.tsv` (sorted round-robin when nothing is recorded), run each shard's files in sorted order and split only process boundaries; every selected file still runs once. Ordinary macOS shards select 1/2 and 2/2 from the full file list; macOS control selects 1/1. Both