Merge pull request #1831 from paperclipai/pr/pap-891-opencode-headless-prompts
fix(opencode): support headless permission prompt configuration
This commit is contained in:
commit
aa5b2be907
9 changed files with 652 additions and 430 deletions
|
|
@ -22,6 +22,7 @@ Core fields:
|
||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||||
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||||
|
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- command (string, optional): defaults to "opencode"
|
- command (string, optional): defaults to "opencode"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
|
@ -40,4 +41,7 @@ Notes:
|
||||||
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
|
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
|
||||||
writing an opencode.json config file into the project working directory. Model \
|
writing an opencode.json config file into the project working directory. Model \
|
||||||
selection is passed via the --model CLI flag instead.
|
selection is passed via the --model CLI flag instead.
|
||||||
|
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
|
||||||
|
runtime config with \`permission.external_directory=allow\` so headless runs do \
|
||||||
|
not stall on approval prompts.
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
|
@ -177,231 +178,239 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
const runtimeEnv = Object.fromEntries(
|
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
||||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
try {
|
||||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
const runtimeEnv = Object.fromEntries(
|
||||||
),
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
||||||
);
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
),
|
||||||
|
|
||||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
|
||||||
model,
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
env: runtimeEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
||||||
const canResumeSession =
|
|
||||||
runtimeSessionId.length > 0 &&
|
|
||||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
|
||||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
||||||
);
|
);
|
||||||
}
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
const resolvedInstructionsFilePath = instructionsFilePath
|
model,
|
||||||
? path.resolve(cwd, instructionsFilePath)
|
command,
|
||||||
: "";
|
|
||||||
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
|
||||||
let instructionsPrefix = "";
|
|
||||||
if (resolvedInstructionsFilePath) {
|
|
||||||
try {
|
|
||||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
|
||||||
instructionsPrefix =
|
|
||||||
`${instructionsContents}\n\n` +
|
|
||||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
||||||
} catch (err) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandNotes = (() => {
|
|
||||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
|
||||||
if (instructionsPrefix.length > 0) {
|
|
||||||
return [
|
|
||||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
|
||||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
||||||
];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
||||||
const templateData = {
|
|
||||||
agentId: agent.id,
|
|
||||||
companyId: agent.companyId,
|
|
||||||
runId,
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
};
|
|
||||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
|
||||||
const renderedBootstrapPrompt =
|
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
||||||
: "";
|
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
||||||
const prompt = joinPromptSections([
|
|
||||||
instructionsPrefix,
|
|
||||||
renderedBootstrapPrompt,
|
|
||||||
sessionHandoffNote,
|
|
||||||
renderedPrompt,
|
|
||||||
]);
|
|
||||||
const promptMetrics = {
|
|
||||||
promptChars: prompt.length,
|
|
||||||
instructionsChars: instructionsPrefix.length,
|
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
|
||||||
const args = ["run", "--format", "json"];
|
|
||||||
if (resumeSessionId) args.push("--session", resumeSessionId);
|
|
||||||
if (model) args.push("--model", model);
|
|
||||||
if (variant) args.push("--variant", variant);
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
return args;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
|
||||||
const args = buildArgs(resumeSessionId);
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "opencode_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandNotes,
|
|
||||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
prompt,
|
|
||||||
promptMetrics,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
stdin: prompt,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onSpawn,
|
|
||||||
onLog,
|
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
proc,
|
|
||||||
rawStderr: proc.stderr,
|
|
||||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toResult = (
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
attempt: {
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
const extraArgs = (() => {
|
||||||
rawStderr: string;
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
},
|
return asStringArray(config.args);
|
||||||
clearSessionOnMissingSession = false,
|
})();
|
||||||
): AdapterExecutionResult => {
|
|
||||||
if (attempt.proc.timedOut) {
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
return {
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
exitCode: attempt.proc.exitCode,
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
signal: attempt.proc.signal,
|
const canResumeSession =
|
||||||
timedOut: true,
|
runtimeSessionId.length > 0 &&
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||||
clearSession: clearSessionOnMissingSession,
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
};
|
if (runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSessionId =
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
attempt.parsed.sessionId ??
|
const resolvedInstructionsFilePath = instructionsFilePath
|
||||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
? path.resolve(cwd, instructionsFilePath)
|
||||||
const resolvedSessionParams = resolvedSessionId
|
: "";
|
||||||
? ({
|
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
||||||
sessionId: resolvedSessionId,
|
let instructionsPrefix = "";
|
||||||
cwd,
|
if (resolvedInstructionsFilePath) {
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
try {
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
instructionsPrefix =
|
||||||
} as Record<string, unknown>)
|
`${instructionsContents}\n\n` +
|
||||||
: null;
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||||
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
const commandNotes = (() => {
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
const notes = [...preparedRuntimeConfig.notes];
|
||||||
const rawExitCode = attempt.proc.exitCode;
|
if (!resolvedInstructionsFilePath) return notes;
|
||||||
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
if (instructionsPrefix.length > 0) {
|
||||||
const fallbackErrorMessage =
|
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
|
||||||
parsedError ||
|
notes.push(
|
||||||
stderrLine ||
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
);
|
||||||
const modelId = model || null;
|
return notes;
|
||||||
|
}
|
||||||
|
notes.push(
|
||||||
|
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
);
|
||||||
|
return notes;
|
||||||
|
})();
|
||||||
|
|
||||||
return {
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||||
exitCode: synthesizedExitCode,
|
const templateData = {
|
||||||
signal: attempt.proc.signal,
|
agentId: agent.id,
|
||||||
timedOut: false,
|
companyId: agent.companyId,
|
||||||
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
runId,
|
||||||
usage: {
|
company: { id: agent.companyId },
|
||||||
inputTokens: attempt.parsed.usage.inputTokens,
|
agent,
|
||||||
outputTokens: attempt.parsed.usage.outputTokens,
|
run: { id: runId, source: "on_demand" },
|
||||||
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
context,
|
||||||
},
|
};
|
||||||
sessionId: resolvedSessionId,
|
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||||
sessionParams: resolvedSessionParams,
|
const renderedBootstrapPrompt =
|
||||||
sessionDisplayId: resolvedSessionId,
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
provider: parseModelProvider(modelId),
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
: "";
|
||||||
model: modelId,
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
billingType: "unknown",
|
const prompt = joinPromptSections([
|
||||||
costUsd: attempt.parsed.costUsd,
|
instructionsPrefix,
|
||||||
resultJson: {
|
renderedBootstrapPrompt,
|
||||||
stdout: attempt.proc.stdout,
|
sessionHandoffNote,
|
||||||
stderr: attempt.proc.stderr,
|
renderedPrompt,
|
||||||
},
|
]);
|
||||||
summary: attempt.parsed.summary,
|
const promptMetrics = {
|
||||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
promptChars: prompt.length,
|
||||||
|
instructionsChars: instructionsPrefix.length,
|
||||||
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
const initialFailed =
|
const args = ["run", "--format", "json"];
|
||||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
if (resumeSessionId) args.push("--session", resumeSessionId);
|
||||||
if (
|
if (model) args.push("--model", model);
|
||||||
sessionId &&
|
if (variant) args.push("--variant", variant);
|
||||||
initialFailed &&
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
return args;
|
||||||
) {
|
};
|
||||||
await onLog(
|
|
||||||
"stdout",
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
const args = buildArgs(resumeSessionId);
|
||||||
);
|
if (onMeta) {
|
||||||
const retry = await runAttempt(null);
|
await onMeta({
|
||||||
return toResult(retry, true);
|
adapterType: "opencode_local",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandNotes,
|
||||||
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||||
|
env: redactEnvForLogs(preparedRuntimeConfig.env),
|
||||||
|
prompt,
|
||||||
|
promptMetrics,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env: runtimeEnv,
|
||||||
|
stdin: prompt,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onSpawn,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
proc,
|
||||||
|
rawStderr: proc.stderr,
|
||||||
|
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toResult = (
|
||||||
|
attempt: {
|
||||||
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||||
|
rawStderr: string;
|
||||||
|
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||||
|
},
|
||||||
|
clearSessionOnMissingSession = false,
|
||||||
|
): AdapterExecutionResult => {
|
||||||
|
if (attempt.proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
clearSession: clearSessionOnMissingSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSessionId =
|
||||||
|
attempt.parsed.sessionId ??
|
||||||
|
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const rawExitCode = attempt.proc.exitCode;
|
||||||
|
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
||||||
|
const modelId = model || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: synthesizedExitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||||
|
usage: {
|
||||||
|
inputTokens: attempt.parsed.usage.inputTokens,
|
||||||
|
outputTokens: attempt.parsed.usage.outputTokens,
|
||||||
|
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||||
|
},
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
|
provider: parseModelProvider(modelId),
|
||||||
|
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
||||||
|
model: modelId,
|
||||||
|
billingType: "unknown",
|
||||||
|
costUsd: attempt.parsed.costUsd,
|
||||||
|
resultJson: {
|
||||||
|
stdout: attempt.proc.stdout,
|
||||||
|
stderr: attempt.proc.stderr,
|
||||||
|
},
|
||||||
|
summary: attempt.parsed.summary,
|
||||||
|
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId);
|
||||||
|
const initialFailed =
|
||||||
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
initialFailed &&
|
||||||
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(initial);
|
||||||
|
} finally {
|
||||||
|
await preparedRuntimeConfig.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return toResult(initial);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||||
|
|
||||||
|
const cleanupPaths = new Set<string>();
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
[...cleanupPaths].map(async (filepath) => {
|
||||||
|
await fs.rm(filepath, { recursive: true, force: true });
|
||||||
|
cleanupPaths.delete(filepath);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeConfigHome(initialConfig?: Record<string, unknown>) {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-"));
|
||||||
|
cleanupPaths.add(root);
|
||||||
|
const configDir = path.join(root, "opencode");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
if (initialConfig) {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, "opencode.json"),
|
||||||
|
`${JSON.stringify(initialConfig, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("prepareOpenCodeRuntimeConfig", () => {
|
||||||
|
it("injects an external_directory allow rule by default", async () => {
|
||||||
|
const configHome = await makeConfigHome({
|
||||||
|
permission: {
|
||||||
|
read: "allow",
|
||||||
|
},
|
||||||
|
theme: "system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepared = await prepareOpenCodeRuntimeConfig({
|
||||||
|
env: { XDG_CONFIG_HOME: configHome },
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
|
||||||
|
|
||||||
|
expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome);
|
||||||
|
const runtimeConfig = JSON.parse(
|
||||||
|
await fs.readFile(
|
||||||
|
path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
expect(runtimeConfig).toMatchObject({
|
||||||
|
theme: "system",
|
||||||
|
permission: {
|
||||||
|
read: "allow",
|
||||||
|
external_directory: "allow",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prepared.cleanup();
|
||||||
|
cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME);
|
||||||
|
await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit opt-out", async () => {
|
||||||
|
const configHome = await makeConfigHome();
|
||||||
|
const prepared = await prepareOpenCodeRuntimeConfig({
|
||||||
|
env: { XDG_CONFIG_HOME: configHome },
|
||||||
|
config: { dangerouslySkipPermissions: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome });
|
||||||
|
expect(prepared.notes).toEqual([]);
|
||||||
|
await prepared.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { asBoolean } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
type PreparedOpenCodeRuntimeConfig = {
|
||||||
|
env: Record<string, string>;
|
||||||
|
notes: string[];
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveXdgConfigHome(env: Record<string, string>): string {
|
||||||
|
return (
|
||||||
|
(typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) ||
|
||||||
|
(typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) ||
|
||||||
|
path.join(os.homedir(), ".config")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonObject(filepath: string): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filepath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return isPlainObject(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareOpenCodeRuntimeConfig(input: {
|
||||||
|
env: Record<string, string>;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}): Promise<PreparedOpenCodeRuntimeConfig> {
|
||||||
|
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
|
||||||
|
if (!skipPermissions) {
|
||||||
|
return {
|
||||||
|
env: input.env,
|
||||||
|
notes: [],
|
||||||
|
cleanup: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
|
||||||
|
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
|
||||||
|
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
|
||||||
|
const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json");
|
||||||
|
|
||||||
|
await fs.mkdir(runtimeConfigDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.cp(sourceConfigDir, runtimeConfigDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
errorOnExist: false,
|
||||||
|
dereference: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingConfig = await readJsonObject(runtimeConfigPath);
|
||||||
|
const existingPermission = isPlainObject(existingConfig.permission)
|
||||||
|
? existingConfig.permission
|
||||||
|
: {};
|
||||||
|
const nextConfig = {
|
||||||
|
...existingConfig,
|
||||||
|
permission: {
|
||||||
|
...existingPermission,
|
||||||
|
external_directory: "allow",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
...input.env,
|
||||||
|
XDG_CONFIG_HOME: runtimeConfigHome,
|
||||||
|
},
|
||||||
|
notes: [
|
||||||
|
"Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.",
|
||||||
|
],
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.rm(runtimeConfigHome, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
asBoolean,
|
||||||
asString,
|
asString,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
parseObject,
|
parseObject,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
import { parseOpenCodeJsonl } from "./parse.js";
|
import { parseOpenCodeJsonl } from "./parse.js";
|
||||||
|
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||||
|
|
||||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
if (checks.some((check) => check.level === "error")) return "fail";
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
|
@ -92,224 +94,236 @@ export async function testEnvironment(
|
||||||
|
|
||||||
// Prevent OpenCode from writing an opencode.json into the working directory.
|
// Prevent OpenCode from writing an opencode.json into the working directory.
|
||||||
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
||||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
||||||
|
if (asBoolean(config.dangerouslySkipPermissions, true)) {
|
||||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
|
||||||
if (cwdInvalid) {
|
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_skipped",
|
code: "opencode_headless_permissions_enabled",
|
||||||
level: "warn",
|
level: "info",
|
||||||
message: "Skipped command check because working directory validation failed.",
|
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
|
||||||
detail: command,
|
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
try {
|
try {
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));
|
||||||
|
|
||||||
|
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||||
|
if (cwdInvalid) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_resolvable",
|
code: "opencode_command_skipped",
|
||||||
level: "info",
|
level: "warn",
|
||||||
message: `Command is executable: ${command}`,
|
message: "Skipped command check because working directory validation failed.",
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_command_unresolvable",
|
|
||||||
level: "error",
|
|
||||||
message: err instanceof Error ? err.message : "Command is not executable",
|
|
||||||
detail: command,
|
detail: command,
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
}
|
try {
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
const canRunProbe =
|
|
||||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
|
||||||
|
|
||||||
let modelValidationPassed = false;
|
|
||||||
const configuredModel = asString(config.model, "").trim();
|
|
||||||
|
|
||||||
if (canRunProbe && configuredModel) {
|
|
||||||
try {
|
|
||||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
|
||||||
if (discovered.length > 0) {
|
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_models_discovered",
|
code: "opencode_command_resolvable",
|
||||||
level: "info",
|
level: "info",
|
||||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
message: `Command is executable: ${command}`,
|
||||||
});
|
});
|
||||||
} else {
|
} catch (err) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_models_empty",
|
code: "opencode_command_unresolvable",
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "OpenCode returned no models.",
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
hint: "Run `opencode models` and verify provider authentication.",
|
detail: command,
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
|
||||||
level: "warn",
|
|
||||||
message: "The configured model was not found by the provider.",
|
|
||||||
detail: errMsg,
|
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_models_discovery_failed",
|
|
||||||
level: "error",
|
|
||||||
message: errMsg || "OpenCode model discovery failed.",
|
|
||||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (canRunProbe && !configuredModel) {
|
|
||||||
try {
|
const canRunProbe =
|
||||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||||
if (discovered.length > 0) {
|
|
||||||
checks.push({
|
let modelValidationPassed = false;
|
||||||
code: "opencode_models_discovered",
|
const configuredModel = asString(config.model, "").trim();
|
||||||
level: "info",
|
|
||||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
if (canRunProbe && configuredModel) {
|
||||||
});
|
try {
|
||||||
|
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||||
|
if (discovered.length > 0) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_models_discovered",
|
||||||
|
level: "info",
|
||||||
|
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_models_empty",
|
||||||
|
level: "error",
|
||||||
|
message: "OpenCode returned no models.",
|
||||||
|
hint: "Run `opencode models` and verify provider authentication.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
|
level: "warn",
|
||||||
|
message: "The configured model was not found by the provider.",
|
||||||
|
detail: errMsg,
|
||||||
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_models_discovery_failed",
|
||||||
|
level: "error",
|
||||||
|
message: errMsg || "OpenCode model discovery failed.",
|
||||||
|
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} else if (canRunProbe && !configuredModel) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
try {
|
||||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||||
checks.push({
|
if (discovered.length > 0) {
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
checks.push({
|
||||||
level: "warn",
|
code: "opencode_models_discovered",
|
||||||
message: "The configured model was not found by the provider.",
|
level: "info",
|
||||||
detail: errMsg,
|
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
});
|
||||||
});
|
}
|
||||||
} else {
|
} catch (err) {
|
||||||
checks.push({
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
code: "opencode_models_discovery_failed",
|
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||||
level: "warn",
|
checks.push({
|
||||||
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
level: "warn",
|
||||||
});
|
message: "The configured model was not found by the provider.",
|
||||||
|
detail: errMsg,
|
||||||
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_models_discovery_failed",
|
||||||
|
level: "warn",
|
||||||
|
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
||||||
|
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||||
if (!configuredModel && !modelUnavailable) {
|
if (!configuredModel && !modelUnavailable) {
|
||||||
// No model configured – skip model requirement if no model-related checks exist
|
// No model configured – skip model requirement if no model-related checks exist
|
||||||
} else if (configuredModel && canRunProbe) {
|
} else if (configuredModel && canRunProbe) {
|
||||||
try {
|
try {
|
||||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
model: configuredModel,
|
model: configuredModel,
|
||||||
command,
|
command,
|
||||||
cwd,
|
|
||||||
env: runtimeEnv,
|
|
||||||
});
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_model_configured",
|
|
||||||
level: "info",
|
|
||||||
message: `Configured model: ${configuredModel}`,
|
|
||||||
});
|
|
||||||
modelValidationPassed = true;
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_model_invalid",
|
|
||||||
level: "error",
|
|
||||||
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
|
||||||
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canRunProbe && modelValidationPassed) {
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
const variant = asString(config.variant, "").trim();
|
|
||||||
const probeModel = configuredModel;
|
|
||||||
|
|
||||||
const args = ["run", "--format", "json"];
|
|
||||||
args.push("--model", probeModel);
|
|
||||||
if (variant) args.push("--variant", variant);
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const probe = await runChildProcess(
|
|
||||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
{
|
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
timeoutSec: 60,
|
});
|
||||||
graceSec: 5,
|
checks.push({
|
||||||
stdin: "Respond with hello.",
|
code: "opencode_model_configured",
|
||||||
onLog: async () => {},
|
level: "info",
|
||||||
},
|
message: `Configured model: ${configuredModel}`,
|
||||||
);
|
});
|
||||||
|
modelValidationPassed = true;
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_model_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
||||||
|
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
if (canRunProbe && modelValidationPassed) {
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
const extraArgs = (() => {
|
||||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
const variant = asString(config.variant, "").trim();
|
||||||
|
const probeModel = configuredModel;
|
||||||
|
|
||||||
if (probe.timedOut) {
|
const args = ["run", "--format", "json"];
|
||||||
checks.push({
|
args.push("--model", probeModel);
|
||||||
code: "opencode_hello_probe_timed_out",
|
if (variant) args.push("--variant", variant);
|
||||||
level: "warn",
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
message: "OpenCode hello probe timed out.",
|
|
||||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
try {
|
||||||
});
|
const probe = await runChildProcess(
|
||||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
const summary = parsed.summary.trim();
|
command,
|
||||||
const hasHello = /\bhello\b/i.test(summary);
|
args,
|
||||||
checks.push({
|
{
|
||||||
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
cwd,
|
||||||
level: hasHello ? "info" : "warn",
|
env: runtimeEnv,
|
||||||
message: hasHello
|
timeoutSec: 60,
|
||||||
? "OpenCode hello probe succeeded."
|
graceSec: 5,
|
||||||
: "OpenCode probe ran but did not return `hello` as expected.",
|
stdin: "Respond with hello.",
|
||||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
onLog: async () => {},
|
||||||
...(hasHello
|
},
|
||||||
? {}
|
);
|
||||||
: {
|
|
||||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||||
}),
|
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||||
});
|
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||||
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
|
||||||
checks.push({
|
if (probe.timedOut) {
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
checks.push({
|
||||||
level: "warn",
|
code: "opencode_hello_probe_timed_out",
|
||||||
message: "The configured model was not found by the provider.",
|
level: "warn",
|
||||||
...(detail ? { detail } : {}),
|
message: "OpenCode hello probe timed out.",
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||||
});
|
});
|
||||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||||
checks.push({
|
const summary = parsed.summary.trim();
|
||||||
code: "opencode_hello_probe_auth_required",
|
const hasHello = /\bhello\b/i.test(summary);
|
||||||
level: "warn",
|
checks.push({
|
||||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
||||||
...(detail ? { detail } : {}),
|
level: hasHello ? "info" : "warn",
|
||||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
message: hasHello
|
||||||
});
|
? "OpenCode hello probe succeeded."
|
||||||
} else {
|
: "OpenCode probe ran but did not return `hello` as expected.",
|
||||||
|
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||||
|
...(hasHello
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
|
level: "warn",
|
||||||
|
message: "The configured model was not found by the provider.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
|
});
|
||||||
|
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_auth_required",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_failed",
|
||||||
|
level: "error",
|
||||||
|
message: "OpenCode hello probe failed.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_hello_probe_failed",
|
code: "opencode_hello_probe_failed",
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "OpenCode hello probe failed.",
|
message: "OpenCode hello probe failed.",
|
||||||
...(detail ? { detail } : {}),
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_failed",
|
|
||||||
level: "error",
|
|
||||||
message: "OpenCode hello probe failed.",
|
|
||||||
detail: err instanceof Error ? err.message : String(err),
|
|
||||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await preparedRuntimeConfig.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||||
if (v.model) ac.model = v.model;
|
if (v.model) ac.model = v.model;
|
||||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||||
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||||
ac.timeoutSec = 0;
|
ac.timeoutSec = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import type { AdapterConfigFieldsProps } from "../types";
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
|
ToggleField,
|
||||||
DraftInput,
|
DraftInput,
|
||||||
|
help,
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
|
|
@ -19,31 +21,52 @@ export function OpenCodeLocalConfigFields({
|
||||||
mark,
|
mark,
|
||||||
hideInstructionsFile,
|
hideInstructionsFile,
|
||||||
}: AdapterConfigFieldsProps) {
|
}: AdapterConfigFieldsProps) {
|
||||||
if (hideInstructionsFile) return null;
|
|
||||||
return (
|
return (
|
||||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
{!hideInstructionsFile && (
|
||||||
<DraftInput
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
value={
|
<div className="flex items-center gap-2">
|
||||||
isCreate
|
<DraftInput
|
||||||
? values!.instructionsFilePath ?? ""
|
value={
|
||||||
: eff(
|
isCreate
|
||||||
"adapterConfig",
|
? values!.instructionsFilePath ?? ""
|
||||||
"instructionsFilePath",
|
: eff(
|
||||||
String(config.instructionsFilePath ?? ""),
|
"adapterConfig",
|
||||||
)
|
"instructionsFilePath",
|
||||||
}
|
String(config.instructionsFilePath ?? ""),
|
||||||
onCommit={(v) =>
|
)
|
||||||
isCreate
|
}
|
||||||
? set!({ instructionsFilePath: v })
|
onCommit={(v) =>
|
||||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
isCreate
|
||||||
}
|
? set!({ instructionsFilePath: v })
|
||||||
immediate
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
className={inputClass}
|
}
|
||||||
placeholder="/absolute/path/to/AGENTS.md"
|
immediate
|
||||||
/>
|
className={inputClass}
|
||||||
<ChoosePathButton />
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
</div>
|
/>
|
||||||
</Field>
|
<ChoosePathButton />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<ToggleField
|
||||||
|
label="Skip permissions"
|
||||||
|
hint={help.dangerouslySkipPermissions}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? values!.dangerouslySkipPermissions
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"dangerouslySkipPermissions",
|
||||||
|
config.dangerouslySkipPermissions !== false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslySkipPermissions: v })
|
||||||
|
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,8 @@ export function OnboardingWizard() {
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
url,
|
url,
|
||||||
dangerouslySkipPermissions: adapterType === "claude_local",
|
dangerouslySkipPermissions:
|
||||||
|
adapterType === "claude_local" || adapterType === "opencode_local",
|
||||||
dangerouslyBypassSandbox:
|
dangerouslyBypassSandbox:
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
|
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const help: Record<string, string> = {
|
||||||
model: "Override the default model used by the adapter.",
|
model: "Override the default model used by the adapter.",
|
||||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||||
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
||||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
|
||||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||||
search: "Enable Codex web search capability during runs.",
|
search: "Enable Codex web search capability during runs.",
|
||||||
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue