Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/custom-claude-inject.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/custom-claude-official.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/custom-grok-inject.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/custom-grok-official.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions server/drivers/acp/droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function readSettings(env: Record<string, string | undefined>): FactorySettings
return JSON.parse(readFileSync(join(home, ".factory", "settings.json"), "utf8")) as FactorySettings;
}

function resolveModels(env: Record<string, string | undefined>) {
async function resolveModels(env: Record<string, string | undefined>) {
let settings: FactorySettings;
try {
settings = readSettings(env);
Expand Down Expand Up @@ -165,7 +165,7 @@ const support: AcpSupport = {
// Pin the model for the same reason as the mode: with no set_model the
// session runs whatever ~/.factory/settings.json selected, which can be a
// `custom:` provider pointing at its own endpoint and key.
const modelId = turn.model || MODELS.default;
const modelId = turn.model ?? MODELS.default;
await applySetting(request, "session/set_model", { sessionId, modelId }, `model "${modelId}"`);
},

Expand Down
91 changes: 88 additions & 3 deletions server/drivers/acp/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
// (~/.grok/auth.json), NOT the xAI API key (that driver is drivers/grok.ts).
// The generic protocol runtime lives in acp/core.ts; this file is only the
// per-harness quirks. Verified against grok 1.0.0.
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import type { ModelCatalog } from "../../contracts.ts";
import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.ts";
import { createAcpDriver, type AcpSupport } from "./core.ts";

export const STATIC_GROK_MODELS: ModelCatalog = {
Expand Down Expand Up @@ -95,11 +96,95 @@ export function readGrokModelCatalog(env: Record<string, string | undefined> = p
};
}

function suggestGrokSlug(host: string, model: string, taken: Set<string>): string {
let base = `${host}-${model}`.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!base || !/^[a-z]/.test(base)) base = `m-${base || "model"}`;
let slug = base;
let n = 2;
while (taken.has(slug)) {
slug = `${base}-${n}`;
n += 1;
}
return slug;
}

function quoteToml(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}

/** Write a [model.slug] block so `grok -m` can reach the injected host. */
export function ensureGrokInjectSlug(
modelId: string,
env: Record<string, string | undefined> = process.env,
): string {
const inject = decodeInjectId(modelId);
if (!inject) return modelId;
const host = localHost(inject.host);
if (!host) return modelId;

const path = join(grokHome(env), "config.toml");
let text = "";
try {
text = readFileSync(path, "utf8");
} catch {
text = "";
}

const taken = new Set<string>(STATIC_GROK_MODELS.options.map((option) => option.id));
let current: { slug: string; model?: string; baseUrl?: string } | null = null;
const flush = () => {
if (!current) return;
taken.add(current.slug);
if (current.model === inject.model && current.baseUrl === host.baseUrl) {
found = current.slug;
}
current = null;
};
let found: string | null = null;
for (const line of text.split(/\r?\n/)) {
const stripped = line.trim();
if (stripped.startsWith("[model.") && stripped.endsWith("]")) {
flush();
let inner = stripped.slice("[model.".length, -1);
if (inner.startsWith('"') && inner.endsWith('"')) inner = inner.slice(1, -1);
current = { slug: inner };
continue;
}
if (stripped.startsWith("[")) {
flush();
continue;
}
if (!current || !stripped.includes("=")) continue;
const eq = stripped.indexOf("=");
const key = stripped.slice(0, eq).trim();
const value = unquote(stripped.slice(eq + 1));
if (key === "model") current.model = value;
if (key === "base_url") current.baseUrl = value;
}
flush();
if (found) return found;

const slug = suggestGrokSlug(inject.host, inject.model, taken);
const heading = /[^a-z0-9_-]/i.test(slug) ? `[model."${slug}"]` : `[model.${slug}]`;
const block = [
heading,
`model = ${quoteToml(inject.model)}`,
`base_url = ${quoteToml(host.baseUrl)}`,
`name = ${quoteToml(`${inject.model} (${host.label})`)}`,
`api_backend = "chat_completions"`,
`api_key = ${quoteToml(hostApiKey(host, env))}`,
"",
].join("\n");
const next = text && !text.endsWith("\n") ? `${text}\n\n${block}` : `${text}${text ? "\n" : ""}${block}`;
writeFileSync(path, next);
return slug;
}

const support: AcpSupport = {
driverKind: "grokAgent",
displayName: "Grok",
models: STATIC_GROK_MODELS,
resolveModels: (env) => readGrokModelCatalog(env),
resolveModels: (env) => mergeLocalInject(readGrokModelCatalog(env), env),
// Grok's accepted levels vary by model and the CLI validates lazily — a
// rejected level only logs and falls back. Offer the intersection shared
// by every model in this driver's picker; notably, grok-4.5 rejects xhigh.
Expand All @@ -126,7 +211,7 @@ const support: AcpSupport = {
spawnArgs: (config, turn) => [
"--permission-mode",
config.fullAuto ? "bypassPermissions" : "default",
...(turn.model ? ["-m", turn.model] : []),
...(turn.model ? ["-m", ensureGrokInjectSlug(turn.model)] : []),
// long form on purpose: `--effort` is documented as an alias, and an
// alias is the part a CLI is free to rename
...(turn.effort ? ["--reasoning-effort", turn.effort] : []),
Expand Down
5 changes: 4 additions & 1 deletion server/drivers/acp/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ const support: AcpSupport = {

// -m is a global commander option and must precede the `acp` subcommand
// (verified against 0.29.1).
spawnArgs: (_config, turn) => [...(turn.model ? ["-m", turn.model] : []), "acp"],
spawnArgs: (_config, turn) => {
const model = turn.model;
return [...(model ? ["-m", model] : []), "acp"];
},

// Subscription CLI: a leaked Moonshot/Kimi API key must not flip billing
// to pay-as-you-go inside the spawned agent (mirrors claude/grok).
Expand Down
22 changes: 20 additions & 2 deletions server/drivers/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ describe("ClaudeDriver turns (fake CLI)", () => {
let recorder: EventRecorder;
let scratch: string;

const create = async (mode?: string) => {
const create = async (mode?: string, environment: Record<string, string> = {}) => {
if (mode) process.env.FAKE_CLAUDE_MODE = mode;
instance = await ClaudeDriver.create({
instanceId: "claude-test",
displayName: "Claude Test",
environment: {},
environment,
enabled: true,
config: { cli: FAKE_CLI, permissionMode: "acceptEdits" },
});
Expand Down Expand Up @@ -136,6 +136,24 @@ describe("ClaudeDriver turns (fake CLI)", () => {
expect(seen.env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
});

it("uses instance credentials when launching an injected local model", async () => {
await create(undefined, { UNSLOTH_STUDIO_AUTH_TOKEN: "unsloth-secret" });
const dump = join(scratch, "dump.json");
process.env.FAKE_CLAUDE_DUMP = dump;

await instance.adapter.sendTurn({
threadId: "t-local-model",
text: "hi",
model: "unsloth::local-model",
});
await recorder.until((e) => e.type === "turn.completed");

const seen = JSON.parse(readFileSync(dump, "utf8"));
expect(seen.argv[seen.argv.indexOf("--model") + 1]).toBe("local-model");
expect(seen.env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:8888");
expect(seen.env.ANTHROPIC_AUTH_TOKEN).toBe("unsloth-secret");
});

it("mounts the agents comms proxy as an MCP server and pre-allows its tools", async () => {
await create();
const dump = join(scratch, "dump.json");
Expand Down
21 changes: 14 additions & 7 deletions server/drivers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
} from "../contracts.ts";
import { computerProxyEnv } from "../container-computer.ts";
import { newEventId, newId } from "../contracts.ts";
import { applyClaudeInject, mergeLocalInject } from "./local-inject.ts";
import { appendNative } from "./native.ts";

/** Whether `claude` has been signed in.
Expand Down Expand Up @@ -65,11 +66,15 @@ export function claudeSignedIn(
* Keeping the probe and turn environments identical prevents setup from
* claiming an API-key login that the turn itself would deliberately remove.
*/
function claudeEnvironment(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, PATH: augmentedPath(), NPM_CONFIG_LOGLEVEL: "error" };
delete env.ANTHROPIC_API_KEY;
function claudeEnvironment(
model?: string | null,
source: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...source, PATH: augmentedPath(), NPM_CONFIG_LOGLEVEL: "error" };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_ENTRYPOINT;
const applied = applyClaudeInject(env, model);
if (!applied.injected) delete env.ANTHROPIC_API_KEY;
return env;
}

Expand Down Expand Up @@ -319,7 +324,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeConfig> = {
let models = STATIC_CLAUDE_MODELS;
const refreshModels = async () => {
try {
const resolved = readClaudeModelCatalog(catalogEnv);
const resolved = await mergeLocalInject(readClaudeModelCatalog(catalogEnv), catalogEnv);
if (resolved.options.length) models = resolved;
} catch {
// Keep the last usable catalog when settings.json is unreadable.
Expand Down Expand Up @@ -360,7 +365,9 @@ export const ClaudeDriver: ProviderDriver<ClaudeConfig> = {
];
if (sessionId) args.push("--resume", sessionId);
else args.push("--session-id", newSessionId!);
if (turn.model) args.push("--model", turn.model);
const turnEnvironment: NodeJS.ProcessEnv = { ...process.env, ...input.environment };
const injected = applyClaudeInject({ ...turnEnvironment }, turn.model);
if (injected.model) args.push("--model", injected.model);
if (turn.effort) args.push("--effort", turn.effort);
if (turn.system) args.push("--append-system-prompt", turn.system);

Expand Down Expand Up @@ -456,7 +463,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeConfig> = {
args.push("--allowedTools", allowed.join(","));
}

const env = claudeEnvironment();
const env = claudeEnvironment(turn.model, turnEnvironment);

const child = spawnCli(config.cli, args, {
cwd: turn.cwd ?? homedir(),
Expand Down Expand Up @@ -604,7 +611,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeConfig> = {
};

const snapshot = async (): Promise<ProviderSnapshot> => {
const env = claudeEnvironment();
const env = claudeEnvironment(undefined, { ...process.env, ...input.environment });
const version = await new Promise<string | null>((resolve) => {
execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) =>
resolve(err ? null : stdout.trim()),
Expand Down
13 changes: 9 additions & 4 deletions server/drivers/codex-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { homedir } from "node:os";
import { basename, join } from "node:path";

import type { ModelCatalog } from "../contracts.ts";
import { mergeLocalInject } from "./local-inject.ts";

export const STATIC_CODEX_MODELS: ModelCatalog = {
default: "gpt-5.6-sol",
Expand Down Expand Up @@ -300,8 +301,12 @@ export async function readCodexModelCatalog(
? main.model
: null;

return {
default: configured && seen.has(configured) ? configured : STATIC_CODEX_MODELS.default,
options,
};
return mergeLocalInject(
{
default: configured && seen.has(configured) ? configured : STATIC_CODEX_MODELS.default,
options,
},
env,
fetchImpl,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
16 changes: 11 additions & 5 deletions server/drivers/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ describe("CodexDriver turns (fake app-server)", () => {
let recorder: EventRecorder;
let scratch: string;

const create = async (opts: { mode?: string; fullAuto?: boolean } = {}) => {
const create = async (
opts: { mode?: string; fullAuto?: boolean; environment?: Record<string, string> } = {},
) => {
if (opts.mode) process.env.FAKE_CODEX_MODE = opts.mode;
instance = await CodexDriver.create({
instanceId: "codex-test",
displayName: "Codex Test",
environment: {},
environment: opts.environment ?? {},
enabled: true,
config: { cli: FAKE_CLI, fullAuto: opts.fullAuto ?? false },
});
Expand Down Expand Up @@ -106,20 +108,24 @@ describe("CodexDriver turns (fake app-server)", () => {
});

it("sends the local provider when the picker id is custom-encoded", async () => {
await create();
await create({ environment: { UNSLOTH_STUDIO_AUTH_TOKEN: "unsloth-secret" } });
const dump = join(scratch, "dump.json");
process.env.FAKE_CODEX_DUMP = dump;
await instance.adapter.sendTurn({
threadId: "t-local",
text: "hi",
model: "omlx::Qwen3.6-35B-A3B-bf16:qwen3-5-6-n-r-reasoning",
model: "unsloth::Qwen3.6-35B-A3B-bf16:qwen3-5-6-n-r-reasoning",
});
await recorder.until((e) => e.type === "turn.completed");
const threadStart = JSON.parse(readFileSync(dump, "utf8")).calls.find((c: { method: string }) => c.method === "thread/start");
expect(threadStart.params).toMatchObject({
model: "Qwen3.6-35B-A3B-bf16:qwen3-5-6-n-r-reasoning",
modelProvider: "omlx",
modelProvider: "unsloth",
});
const seen = JSON.parse(readFileSync(dump, "utf8"));
expect(seen.argv).toContain("model_providers.unsloth.base_url=\"http://127.0.0.1:8888/v1\"");
expect(JSON.stringify(seen.argv)).not.toContain("unsloth-secret");
expect(seen.env.OPENMAUSBOT_LOCAL_UNSLOTH_API_KEY).toBe("unsloth-secret");
});

it("streams agentMessage deltas without re-emitting the settled text", async () => {
Expand Down
10 changes: 8 additions & 2 deletions server/drivers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
} from "../contracts.ts";
import { newEventId, newId } from "../contracts.ts";
import { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.ts";
import { codexLocalProviderArgs } from "./local-inject.ts";
import { augmentedPath } from "../env-path.ts";
import { appendNative } from "./native.ts";

Expand Down Expand Up @@ -102,12 +103,17 @@ export const CodexDriver: ProviderDriver<CodexConfig> = {
if (active.has(threadId)) throw new Error("a turn is already running on this thread");
const turnId = newId();

const env: Record<string, string | undefined> = { ...process.env, PATH: augmentedPath(), NPM_CONFIG_LOGLEVEL: "error" };
const env: Record<string, string | undefined> = {
...process.env,
...input.environment,
PATH: augmentedPath(),
NPM_CONFIG_LOGLEVEL: "error",
};
// the CLI owns its own ChatGPT login; a leaked API key silently flips
// billing to pay-as-you-go (agentcal)
delete env.OPENAI_API_KEY;

const child = spawnCli(config.cli, ["app-server"], {
const child = spawnCli(config.cli, ["app-server", ...codexLocalProviderArgs(env, turn.model)], {
cwd: turn.cwd ?? homedir(),
env,
stdio: ["pipe", "pipe", "pipe"],
Expand Down
Loading
Loading