Skip to content
Closed
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
63 changes: 50 additions & 13 deletions src/adapters/codebuddy/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AdapterRequest, ProviderAdapter } from "../base";
import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt } from "../coding-agent/protocol";
Expand Down Expand Up @@ -35,7 +38,12 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record
* would require authorization is blocked. The turn is a single text/reasoning pass over stream-json;
* Codex's tool catalog is not advertised in v1 (the control-protocol tool bridge is a fast-follow).
*/
export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] {
export function buildArgs(
profile: CodeBuddyProfile,
parsed: OcxParsedRequest,
provider: OcxProviderConfig,
systemPromptFile?: string,
): string[] {
const args: string[] = [
"-p",
"--output-format", "stream-json",
Expand All @@ -50,8 +58,9 @@ export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, p
];
const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
if (effort) args.push("--effort", effort);
const system = buildSystemPrompt(parsed);
if (system) args.push("--append-system-prompt", system);
// The vendor CLI documents no file-backed append flag, so the staged prompt replaces the default.
// That default targets interactive tool use, which this adapter disables end to end.
if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// profile is retained for symmetry with the region-isolated design and future per-region flags.
void profile;
return args;
Expand All @@ -71,16 +80,44 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu
},

async runTurn(parsed, incoming, emit): Promise<void> {
await runCodingAgentTurn({
profiles: CODEBUDDY_PROFILES,
provider,
parsed,
incoming,
emit: guardCodeBuddyScaffolding(emit),
buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov),
buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey),
deps,
});
// argv is world-readable via process listing, so the folded system+developer prompt is
// staged in a private temp file and passed by path instead of embedded in the arguments.
const system = buildSystemPrompt(parsed);
let promptDir: string | undefined;
let promptFile: string | undefined;
if (system) {
try {
promptDir = await mkdtemp(join(tmpdir(), "ocx-codebuddy-prompt-"));
promptFile = join(promptDir, "system-prompt.txt");
await writeFile(promptFile, system, { encoding: "utf8", mode: 0o600, flag: "wx" });
} catch {
if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {});
emit({
type: "error",
message: "CodeBuddy system prompt could not be staged securely.",
status: 500,
errorType: "upstream_error",
code: "system_prompt_staging_failed",
retryable: false,
});
return;
}
}

try {
await runCodingAgentTurn({
profiles: CODEBUDDY_PROFILES,
provider,
parsed,
incoming,
emit: guardCodeBuddyScaffolding(emit),
buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov, promptFile),
buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey),
deps,
});
} finally {
if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}
53 changes: 40 additions & 13 deletions src/adapters/qoder/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AdapterRequest, ProviderAdapter } from "../base";
import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt } from "../coding-agent/protocol";
Expand All @@ -13,7 +16,7 @@ export function buildQoderChildEnv(profile: QoderProfile, apiKey: string): Recor
}

/** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */
export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] {
export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig, systemPromptFile?: string): string[] {
const args = [
"-p",
"--output-format", "stream-json",
Expand All @@ -27,8 +30,7 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo
];
const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
if (effort) args.push("--reasoning-effort", effort);
const system = buildSystemPrompt(parsed);
if (system) args.push("--append-system-prompt", system);
if (systemPromptFile) args.push("--append-system-prompt-file", systemPromptFile);
return args;
}

Expand Down Expand Up @@ -123,16 +125,41 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
});
return;
}
await runCodingAgentTurn({
profiles: QODER_PROFILES,
provider,
parsed,
incoming,
emit: guardQoderScaffolding(emit),
buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov),
buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
deps,
});
// argv is world-readable via process listing, so the folded system+developer prompt is
// staged in a private temp file and passed by path instead of embedded in the arguments.
const system = buildSystemPrompt(parsed);
let promptDir: string | undefined;
let promptFile: string | undefined;
try {
promptDir = system ? await mkdtemp(join(tmpdir(), "ocx-qoder-prompt-")) : undefined;
promptFile = promptDir ? join(promptDir, "system-prompt.txt") : undefined;
if (promptFile) await writeFile(promptFile, system!, { encoding: "utf8", mode: 0o600 });
} catch {
if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {});
emit({
type: "error",
message: "Qoder system prompt could not be prepared securely.",
status: 500,
errorType: "upstream_error",
code: "prompt_file_failed",
retryable: false,
});
return;
}
try {
await runCodingAgentTurn({
profiles: QODER_PROFILES,
provider,
parsed,
incoming,
emit: guardQoderScaffolding(emit),
buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov, promptFile),
buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
deps,
});
} finally {
if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}
36 changes: 33 additions & 3 deletions tests/providers/codebuddy-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync, statSync } from "node:fs";
import { EventEmitter } from "node:events";
import { Readable, Writable } from "node:stream";
import type { ChildProcess } from "node:child_process";
Expand Down Expand Up @@ -115,14 +116,16 @@ describe("codebuddy headless arguments keep tool ownership with Codex", () => {
expect(args[args.indexOf("--model") + 1]).toBe("glm-5.3");
});

test("maps Codex reasoning effort onto --effort and folds the system prompt", () => {
test("maps Codex reasoning effort and references a private system-prompt file", () => {
const args = buildArgs(
CODEBUDDY_GLOBAL_PROFILE,
parsed({ options: { reasoning: "high" }, context: { systemPrompt: ["Be terse."], messages: [] } }),
provider(),
"/private/system-prompt.txt",
);
expect(args[args.indexOf("--effort") + 1]).toBe("high");
expect(args[args.indexOf("--append-system-prompt") + 1]).toBe("Be terse.");
expect(args[args.indexOf("--system-prompt-file") + 1]).toBe("/private/system-prompt.txt");
expect(args).not.toContain("Be terse.");
});
});

Expand Down Expand Up @@ -186,13 +189,18 @@ describe("codebuddy runTurn fails closed before any spawn", () => {
let command = "";
let args: readonly string[] = [];
let options: import("node:child_process").SpawnOptions | undefined;
let promptFile = "";
const adapter = createCodeBuddyAdapter(provider(), {
platform: "win32",
which: () => "C:\\npm\\codebuddy.cmd",
spawn: (seenCommand, seenArgs, seenOptions) => {
command = seenCommand;
args = seenArgs;
options = seenOptions;
const commandLine = seenArgs[3] ?? "";
const match = commandLine.match(/--system-prompt-file\s+"([^"]+)"/);
promptFile = match?.[1] ?? "";
expect(readFileSync(promptFile, "utf8")).toBe('Say "hello" & stop');
return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess;
},
killGraceMs: 20,
Expand All @@ -202,8 +210,30 @@ describe("codebuddy runTurn fails closed before any spawn", () => {
expect(command.toLowerCase()).toContain("cmd.exe");
expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(args[3]).toContain("codebuddy.cmd");
expect(args[3]).toContain("Say");
expect(args[3]).not.toContain("Say");
expect(options?.windowsVerbatimArguments).toBe(true);
expect(existsSync(promptFile)).toBe(false);
});

test("keeps request-derived prompts out of argv and removes the private staging file", async () => {
let promptFile = "";
const secret = "private-system-instruction";
const adapter = createCodeBuddyAdapter(provider(), {
which: () => "/usr/bin/codebuddy",
spawn: (_command, args) => {
expect(args).not.toContain(secret);
const index = args.indexOf("--system-prompt-file");
expect(index).toBeGreaterThanOrEqual(0);
promptFile = args[index + 1] ?? "";
expect(readFileSync(promptFile, "utf8")).toBe(secret);
if (process.platform !== "win32") expect(statSync(promptFile).mode & 0o777).toBe(0o600);
return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess;
},
killGraceMs: 20,
});

await run(adapter, parsed({ context: { systemPrompt: [secret], messages: [] } }));
expect(existsSync(promptFile)).toBe(false);
});
});

Expand Down
32 changes: 32 additions & 0 deletions tests/providers/qoder-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { Readable, Writable } from "node:stream";
import { readFile } from "node:fs/promises";
import type { ChildProcess } from "node:child_process";
import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter";
import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../../src/adapters/qoder/profiles";
Expand Down Expand Up @@ -44,6 +45,37 @@ describe("qoder adapter", () => {
expect(args).not.toContain("--dangerously-skip-permissions");
});

test("keeps system and developer prompts out of child-process arguments", async () => {
const secretSystem = "private system instructions";
const secretDeveloper = "private developer context";
let args: readonly string[] = [];
let promptFromFile: Promise<string> | undefined;
const adapter = createQoderAdapter(provider(), {
which: () => "/bin/qoder",
spawn: (_command, childArgs) => {
args = childArgs;
const flag = childArgs.indexOf("--append-system-prompt-file");
promptFromFile = readFile(childArgs[flag + 1]!, "utf8");
return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']);
},
});
await adapter.runTurn!(parsed({
context: {
systemPrompt: [secretSystem],
messages: [
{ role: "developer", content: secretDeveloper, timestamp: 0 },
{ role: "user", content: "hello", timestamp: 0 },
],
},
}), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, () => {});

const promptPath = args[args.indexOf("--append-system-prompt-file") + 1]!;
expect(args.join(" ")).not.toContain(secretSystem);
expect(args.join(" ")).not.toContain(secretDeveloper);
expect(await readFile(promptPath, "utf8").catch(() => "removed")).toBe("removed");
expect(await promptFromFile).toBe(`${secretSystem}\n\n${secretDeveloper}`);
});

test("keeps Global and CN profiles, executables, destinations, and PAT variables isolated", async () => {
expect(resolveQoderProfile("https://qoder.com/")).toBe(QODER_GLOBAL_PROFILE);
expect(resolveQoderProfile("https://qoder.cn/")).toBe(QODER_CN_PROFILE);
Expand Down
Loading