diff --git a/.agents/skills/verify-droid-integration/SKILL.md b/.agents/skills/verify-droid-integration/SKILL.md new file mode 100644 index 00000000000..d886b788696 --- /dev/null +++ b/.agents/skills/verify-droid-integration/SKILL.md @@ -0,0 +1,92 @@ +--- +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 +bun 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 +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 +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 +bun .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` 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 + +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 +bun .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..7fd0ba8cce5 --- /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 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 + +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..ab6870f7013 --- /dev/null +++ b/.agents/skills/verify-droid-integration/scripts/run.ts @@ -0,0 +1,550 @@ +#!/usr/bin/env bun +import { mkdir, unlink } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; + +type Status = "pass" | "fail" | "error" | "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; + 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; + kill("SIGTERM"); + forceKillTimer = setTimeout(() => kill("SIGKILL"), 1_000); + }, timeout); + 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( + 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 executionError = result.timedOut || result.exitCode !== 0; + const ok = !executionError && completionText(result.stdout).trim() === marker; + return { + status: executionError ? "error" : ok ? "pass" : "fail", + detail: result.timedOut + ? "timed out" + : ok + ? "observed " + marker + : "exit " + result.exitCode + "; exact response mismatch", + durationMs: result.durationMs, + }; +} + +function assertionStatus(base: CaseResult, ok: boolean): Status { + return base.status === "pass" ? (ok ? "pass" : "fail") : base.status; +} + +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 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); +} + +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"); +} +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")) { + 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 + ? "error" + : 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: assertionStatus(base, hasLifecycle), + 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: assertionStatus(base, selected), + 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: assertionStatus( + base, + roundTrip.called && roundTrip.returned && resultContainsMarker, + ), + 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 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", + "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 imagePayload = isPngImagePayload(roundTrip.value); + const evidence = await writeEvidence(modelDir, "image", result); + const base = verdict(result, "IMAGE_OK: UI screenshot required"); + cases.image = { + ...base, + status: assertionStatus(base, roundTrip.called && roundTrip.returned && imagePayload), + detail: base.detail + "; image Read call=" + roundTrip.called + "; result=" + roundTrip.returned + + "; image payload=" + imagePayload, + 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++]!; + 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 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); + +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" || 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); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/assets/factory-droid-icon.png b/assets/factory-droid-icon.png new file mode 100644 index 00000000000..d364f75d4a0 Binary files /dev/null and b/assets/factory-droid-icon.png differ diff --git a/assets/factory-droid-integration.png b/assets/factory-droid-integration.png new file mode 100644 index 00000000000..1f2cdf44efa Binary files /dev/null and b/assets/factory-droid-integration.png differ 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 fc4162f8879..cdeb2366a13 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -91,7 +91,7 @@ export default defineConfig({ { label: "Response Inspection", translations: { fr: "Inspection des réponses et réponses volumineuses", ko: "응답 검사와 대용량 응답", "zh-CN": "响应检查与大型响应", "zh-TW": "回應檢查與大型回應", ru: "Проверка ответов и большие ответы", ja: "レスポンスの検査と大きなレスポンス", tr: "Yanıt incelemesi ve büyük yanıtlar" }, 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 브리지", "zh-CN": "Factory Droid 桥接", "zh-TW": "Factory Droid 橋接", ru: "Мост Factory Droid", ja: "Factory Droid ブリッジ", tr: "Factory Droid köprüsü" }, slug: "guides/factory-droid" }, + { label: "Factory Droid", slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference", fr: "Cursor Private Inference", "zh-CN": "Cursor Private Inference", "zh-TW": "Cursor Private Inference", ru: "Cursor Private Inference", ja: "Cursor Private Inference", tr: "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/factory-droid.md b/docs-site/src/content/docs/fr/guides/factory-droid.md index 4328811bc28..e019662d916 100644 --- a/docs-site/src/content/docs/fr/guides/factory-droid.md +++ b/docs-site/src/content/docs/fr/guides/factory-droid.md @@ -1,8 +1,41 @@ --- -title: Pont Factory Droid -description: Connectez les modèles Factory Droid à OpenCodex au moyen d’un pont local compatible avec Responses. +title: Intégrations Factory Droid +description: Utilisez les modèles OpenCodex dans Droid ou connectez les modèles Factory à OpenCodex au moyen d’un pont local. --- +Factory Droid et OpenCodex peuvent se connecter dans les deux sens. L’intégration gérée décrite ci-dessous est le choix habituel pour utiliser les modèles OpenCodex dans Droid. La section consacrée au pont décrit le sens inverse, dans lequel OpenCodex appelle un modèle fourni par Factory. + +## Utiliser les modèles OpenCodex dans Droid + +Installez le [CLI Droid](https://docs.factory.ai/droid-cli/quickstart) et connectez-vous, démarrez OpenCodex sur l’interface de bouclage, puis activez **Factory Droid** sur la page **Intégrations** d’OpenCodex. Les commandes CLI équivalentes sont : + +```bash +ocx integration client status --client droid +ocx integration client enable --client droid +``` + +OpenCodex ajoute une entrée par modèle actif dans `~/.factory/settings.json`, sous `customModels`. Chaque entrée utilise un identifiant stable `custom:opencodex:`, 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/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index fa50e57a3d6..deb8cf5948a 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 @@ -227,8 +232,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, 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/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 3a412061047..94e1c51441e 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](/guides/factory-droid/) for the managed outbound path and +the separate inbound bridge design. + `ocx uninstall` disables recorded integrations, including all owned Aside profiles, before deleting OpenCodex's recovery state. Unreadable ownership, missing profile registration or a conflicting edit stops that deletion. Cleanup is sequential: earlier successful disables are not undone when a later @@ -321,10 +327,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, 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, 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/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/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/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 1c5496f4b44..2da47df311b 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ışı @@ -252,9 +257,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, 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 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-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 12dc51aff58..a2601754d80 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 只移除該片段, @@ -146,8 +150,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、Cline 與 Factory Droid 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside、Raycast、omo 與 Factory Droid。若區塊已刪除、 遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 連接時才再次執行 enable。 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 new file mode 100644 index 00000000000..5a79c3f810c --- /dev/null +++ b/gui/public/provider-icons/factory-droid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 539068c88ef..c7bccd83b59 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -109,6 +109,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/raycast", "integrations/omo", "integrations/cline", + "integrations/droid", ] as const; /** 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 627d1cf186d..fde75133fd1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3491,4 +3491,7 @@ export const de: Record = { "cws.plan.description": "Fragt den Proxy, wie jedes Ziel eine Anfrage mit allen Funktionen der Client-API übertragen würde und welche Funktionen alle Ziele erhalten. Es wird nichts an den Anbieter gesendet.", "cws.plan.run": "Pfade anzeigen", "cws.plan.savedOnly": "Zeigt die gespeicherte Kombination. Speichern Sie Ihre Änderungen, um sie in der Vorschau zu sehen.", + "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; beim Deaktivieren werden sie entfernt, und mit „Rückgängig“ wird die vorherige Datei wiederhergestellt.", + "api.clientConfig.clientFactoryDroid": "Factory Droid", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 572ce773d93..4ecd12e4296 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3525,6 +3525,9 @@ export const en = { "cws.plan.description": "Asks the proxy how each target would carry a request with every feature the client API can express, and which features all targets keep. Nothing is sent upstream.", "cws.plan.run": "Show candidate paths", "cws.plan.savedOnly": "Shows the saved combo. Save your changes to preview them.", + "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 49eb914b879..c693b020daa 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3480,4 +3480,7 @@ export const fr: Record = { "cws.plan.description": "Demande au proxy comment chaque cible transporterait une requête utilisant toutes les fonctionnalités de l'API cliente, et lesquelles toutes les cibles conservent. Rien n'est envoyé en amont.", "cws.plan.run": "Afficher les chemins", "cws.plan.savedOnly": "Affiche le combo enregistré. Enregistrez vos modifications pour les prévisualiser.", + "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 2260207f58d..4b5a91cf6bd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3513,4 +3513,7 @@ export const ja: Record = { "cws.plan.description": "クライアント API が表現できるすべての機能を含むリクエストを各ターゲットがどう運ぶか、すべてのターゲットで保たれる機能は何かをプロキシに問い合わせます。上流には何も送信しません。", "cws.plan.run": "経路を表示", "cws.plan.savedOnly": "保存済みのコンボを表示しています。変更をプレビューするには保存してください。", + "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 958721d62f6..c3209079131 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3513,4 +3513,7 @@ export const ko: Record = { "cws.plan.description": "클라이언트 API가 표현할 수 있는 모든 기능을 담은 요청을 각 대상이 어떻게 전달하는지, 모든 대상이 유지하는 기능은 무엇인지 프록시에 묻습니다. 업스트림으로는 아무것도 보내지 않습니다.", "cws.plan.run": "경로 보기", "cws.plan.savedOnly": "저장된 콤보를 보여 줍니다. 변경 사항을 미리 보려면 먼저 저장하세요.", + "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 4e2211d4699..a9a4f0514c6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3514,4 +3514,7 @@ export const ru: Record = { "cws.plan.description": "Спрашивает прокси, как каждая цель передаст запрос со всеми возможностями клиентского API и какие возможности сохраняют все цели. Ничего не отправляется провайдеру.", "cws.plan.run": "Показать пути", "cws.plan.savedOnly": "Показан сохранённый комбо. Сохраните изменения, чтобы увидеть их в предпросмотре.", + "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 586bd869ac8..bfcfb7b8111 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3514,4 +3514,7 @@ export const tr: Record = { "cws.plan.description": "Proxy'ye her hedefin, istemci API'sinin ifade edebildiği tüm özellikleri içeren bir isteği nasıl taşıyacağını ve hangi özellikleri tüm hedeflerin koruduğunu sorar. Yukarı akışa hiçbir şey gönderilmez.", "cws.plan.run": "Yolları göster", "cws.plan.savedOnly": "Kaydedilmiş kombo gösteriliyor. Değişikliklerinizi önizlemek için kaydedin.", + "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 ec8ada3e6ac..5bc4ab10447 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3408,6 +3408,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. 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", "models.fastProvider": "Chế độ Fast", "models.fastProviderHint": "Dùng tín dụng sử dụng với giá gấp 2", "models.fastEnabled": "Đã bật chế độ Fast", "models.fastDisabled": "Đã tắt chế độ Fast", "models.fastSaveFailed": "Không thể lưu chế độ Fast", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e6ee16e47c1..58edbe5bc1b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3477,4 +3477,7 @@ export const zhTW: Record = { "cws.plan.description": "詢問代理每個目標會如何傳遞帶有用戶端 API 所有可表達功能的請求,以及哪些功能所有目標都保留。不會向上游傳送任何內容。", "cws.plan.run": "顯示路徑", "cws.plan.savedOnly": "顯示的是已儲存的組合。請先儲存變更再預覽。", + "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 0eed9c08c8c..4144a2d6814 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3512,4 +3512,7 @@ export const zh: Record = { "cws.plan.description": "询问代理每个目标会如何传递带有客户端 API 所有可表达功能的请求,以及哪些功能所有目标都保留。不会向上游发送任何内容。", "cws.plan.run": "显示路径", "cws.plan.savedOnly": "显示的是已保存的组合。请先保存更改再预览。", + "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 b8806d563df..41cea7dcc4a 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -83,6 +83,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 = { @@ -101,6 +102,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 846016f970d..f956af03b7c 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -795,7 +795,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 02733e095ce..b7ab85d86d6 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]; @@ -235,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", 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 9baa172e2ec..aa521bff624 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -146,6 +146,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", "models.reasoningEffort.ultra", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 17599aa4998..c51227484a9 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -19,9 +19,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 5a4429d3b10..7c2e86833aa 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -1428,7 +1428,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 44508dd9dd0..1c282891ecd 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -120,6 +120,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 a89fdd0caf5..cbfd5fc47df 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -565,6 +565,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-live-acceptance.test.ts": "providers", diff --git a/scripts/test.ts b/scripts/test.ts index 6db9075224a..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 @@ -381,8 +383,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/src/cli/dispatch.ts b/src/cli/dispatch.ts index 9df115fa24b..321e483968b 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -510,7 +510,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..2a40fb5c431 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,17 +159,20 @@ 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") { + 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/cli/help.ts b/src/cli/help.ts index 967e431add0..86bf33a681f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -83,7 +83,7 @@ Usage: ocx api-key Alias of ocx access key ocx access External API keys and endpoint information ocx api Protocol paths: vocabulary, request-path preview, and policy - 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 214baa31a25..f7822980046 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -416,8 +416,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { 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 51fb364bde0..50267d00df6 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -51,6 +51,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"; @@ -61,6 +63,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"; @@ -1324,6 +1327,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", @@ -1555,6 +1566,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 6744150d7a7..207fc81aba0 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -105,7 +105,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/mutation-plan.ts b/src/integrations/mutation-plan.ts index 9ab2fac0d82..3fab34570a6 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -144,6 +144,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/src/integrations/registry.ts b/src/integrations/registry.ts index 55d87a60ac4..43913c3bf3b 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -20,6 +20,8 @@ import { asideHomeDir, dshConfigPath, dshHomeDir, + droidConfigPath, + droidHomeDir, gajaeConfigPath, gajaeHomeDir, hermesConfigPath, @@ -345,6 +347,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 9682b062649..4978c6d0c8b 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -268,7 +268,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/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 Decision record: [ADR-0107](../decisions/ADR-0107-uninstall-integration-recovery.md) +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 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. The OrcaRouter login ceiling applies to its key exchange; client configuration files retain the @@ -137,8 +148,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 @@ -179,6 +191,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 32dc63be3ad..4f83bb3359e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -543,7 +543,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-accounts.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-accounts.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9f23cb400b3..5470acd4e5e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -168,6 +168,11 @@ because they are a different plane. Upstream account response reads and OrcaRout The registered route set is larger than the areas described below; the code is the route SOT. What this document owns is which module holds which area and what invariant that area must not break. +`GET /api/client-config` checks the export registry's `loopbackOnly` flag with +`shouldInjectApiAuthHeader` before loading the model catalog. A destination requiring an admission +header returns 400 with `reason: non_loopback` for these clients; loopback binds and enabled +unauthenticated loopback listeners remain eligible. + `GET /api/native-integrations` reads the Codex, Grok and Claude Desktop desired switch states from persisted configuration because those toggles write intent independently of the server's startup config snapshot; every other field still comes from that snapshot, and without a config file the snapshot's own intent stands. The dashboard can therefore refresh a switch immediately after a successful toggle while its routing badge remains based on observed routing. The Codex row reports the state its latest toggle in this process reported while the persisted intent still matches it, so a skipped or failed enable stays `absent` and an incomplete restore stays `unsafe` instead of being re-derived from intent alone. After `PUT /api/native-integrations/claude-desktop` persists its intent, and whenever the Desktop mode marker is @@ -411,6 +416,7 @@ ownership, a GET HTTP failure stops polling without starting a second login POST Pairing-grant source limiting applies only to invalid guesses from an allowed browser origin; disallowed origins record no limiter state, and a valid grant redeems even from a throttled source. + ## Durable provider PATCH `src/server/management/provider-routes.ts` commits every provider PATCH variant through diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b5858dad9eb..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 @@ -505,7 +506,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 84c3c0d7095..a6066bfa876 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -446,7 +446,7 @@ following a final symlink, so an exchange during a mutation cannot redirect the `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. Claude skill-bundle marker parsing follows the [bounded inbound contract](data-planes/inbound-compat.md#claude-skill-marker-path-bound). -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. The adapter takes a tenant host only from the stored account that owns the exact key being transmitted, in the literal slot or, during a detached rekey window, the alias slot, so separately configured or forwarded credentials and non-owning accounts cannot lend another account's destination. diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 6d8a5135586..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 }); }, }); @@ -309,6 +328,12 @@ describe("ocx export --out (accept criterion 3)", () => { }); describe("ocx export argument validation (accept criterion 4)", () => { + 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(`${client} 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 }); @@ -471,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"); @@ -483,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 { @@ -511,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 () => { @@ -522,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 { @@ -562,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(); @@ -581,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 { @@ -606,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; } - }); + }); + } } }); diff --git a/tests/clients/droid-client.test.ts b/tests/clients/droid-client.test.ts new file mode 100644 index 00000000000..e9a06b22866 --- /dev/null +++ b/tests/clients/droid-client.test.ts @@ -0,0 +1,218 @@ +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.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); + } + }); + + 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 874d760f052..4785e561a37 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); @@ -903,12 +903,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/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/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index f4b3a64bcbc..faee342ad22 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 0b6fd2686a1..b973873337c 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -847,8 +847,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 c25660f076d..b71ea19a0b9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -391,6 +391,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-live-acceptance.test.ts": "providers", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 0378ac8e1b6..988d50759db 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -99,7 +99,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); @@ -273,10 +273,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) { 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-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index ef3cb4c590c..e1af0daf1c3 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -290,26 +290,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"); }); @@ -325,12 +334,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 () => { diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 46da86d4234..47eeccf7e42 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 { 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"; @@ -2651,6 +2652,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 +2690,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 +2734,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 +2785,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..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"; @@ -147,17 +147,21 @@ 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"], { + // 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); 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;