Merge remote-tracking branch 'public-gh/master'

* public-gh/master:
  Fix review feedback: duplicate wizard entry, command resolution, @types/node
  Fix server: remove DEFAULT_OPENCODE_LOCAL_MODEL from agents route
  Fix TS errors: remove DEFAULT_OPENCODE_LOCAL_MODEL references
  Regenerate pnpm-lock.yaml after PR #62 merge
  fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode
  fix: parseBooleanFromEnv silently treats common truthy values as false
  `onboard` now derives defaults from env vars before writing config
  Use precomputed runtime env in OpenCode execute
  Fix remaining OpenCode review comments
  Address PR feedback for OpenCode integration
  Add OpenCode provider integration and strict model selection
This commit is contained in:
Dotta
2026-03-06 11:56:42 -06:00
41 changed files with 1468 additions and 630 deletions

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
@@ -8,9 +9,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetCodexModelsCacheForTests();
resetCursorModelsCacheForTests();
setCursorModelsRunnerForTests(null);
resetOpenCodeModelsCacheForTests();
vi.restoreAllMocks();
});
@@ -60,6 +63,7 @@ describe("adapter model listing", () => {
expect(models).toEqual(codexFallbackModels);
});
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
setCursorModelsRunnerForTests(() => ({
status: null,
@@ -90,4 +94,11 @@ describe("adapter model listing", () => {
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
expect(first.some((model) => model.id === "composer-1")).toBe(true);
});
it("returns no opencode models when opencode command is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual([]);
});
});

View File

@@ -18,11 +18,14 @@ import {
} from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
import {
execute as opencodeExecute,
testEnvironment as opencodeTestEnvironment,
sessionCodec as opencodeSessionCodec,
execute as openCodeExecute,
testEnvironment as openCodeTestEnvironment,
sessionCodec as openCodeSessionCodec,
listOpenCodeModels,
} from "@paperclipai/adapter-opencode-local/server";
import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local";
import {
agentConfigurationDoc as openCodeAgentConfigurationDoc,
} from "@paperclipai/adapter-opencode-local";
import {
execute as openclawExecute,
testEnvironment as openclawTestEnvironment,
@@ -58,16 +61,6 @@ const codexLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: codexAgentConfigurationDoc,
};
const opencodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: opencodeExecute,
testEnvironment: opencodeTestEnvironment,
sessionCodec: opencodeSessionCodec,
models: opencodeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: opencodeAgentConfigurationDoc,
};
const cursorLocalAdapter: ServerAdapterModule = {
type: "cursor",
execute: cursorExecute,
@@ -89,8 +82,19 @@ const openclawAdapter: ServerAdapterModule = {
agentConfigurationDoc: openclawAgentConfigurationDoc,
};
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
sessionCodec: openCodeSessionCodec,
models: [],
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
);
export function getServerAdapter(type: string): ServerAdapterModule {

View File

@@ -37,7 +37,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
@@ -198,15 +198,34 @@ export function agentRoutes(db: Db) {
}
return next;
}
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
}
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
return next;
}
async function assertAdapterConfigConstraints(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
}
}
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
const trimmed = candidatePath.trim();
if (path.isAbsolute(trimmed)) return trimmed;
@@ -338,7 +357,9 @@ export function agentRoutes(db: Db) {
}
});
router.get("/adapters/:type/models", async (req, res) => {
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const type = req.params.type as string;
const models = await listAdapterModels(type);
res.json(models);
@@ -592,6 +613,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
hireInput.adapterType,
normalizedAdapterConfig,
);
const normalizedHireInput = {
...hireInput,
adapterConfig: normalizedAdapterConfig,
@@ -727,6 +753,11 @@ export function agentRoutes(db: Db) {
requestedAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
req.body.adapterType,
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
...req.body,
@@ -906,6 +937,27 @@ export function agentRoutes(db: Db) {
);
}
const requestedAdapterType =
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
rawEffectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,
effectiveAdapterConfig,
);
}
const actor = getActorInfo(req);
const agent = await svc.update(id, patchData, {
recordRevision: {