mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Run OpenCode agents under the permission rules they were given (#5296)
* fix(opencode): set permission rules on the OpenCode session OpenCode stores permission rules on the session and its prompt_async request schema has no permission field, so rules built from provider options and Hub tool grants were dropped on every turn: an agent configured with external_directory: allow still asked before reading outside its worktree. Send the rules where OpenCode reads them — on session.create, and on session.update when resuming a session Paseo did not just create — and stop attaching them to prompts. A resume that cannot apply its rules now fails instead of running under a more permissive policy than configured. The real-server test is taken from Gurvan Campion's #5295, which reached the same diagnosis independently. Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com> * test(opencode): use function declarations for the permission test helpers --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
This commit is contained in:
co-authored by
Gurvan Campion
parent
222d45a2fc
commit
2c7b38bc7e
@@ -2385,49 +2385,41 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
|
||||
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||
stream: {
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
|
||||
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
|
||||
await waitForAbort(signal);
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
session: { promptAsync },
|
||||
} as never;
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/test",
|
||||
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", question: "deny" } },
|
||||
toolPolicy: {
|
||||
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||
},
|
||||
},
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
await session.startTurn("finish");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permission: [
|
||||
});
|
||||
try {
|
||||
const created = openCode.calls.sessionCreate[0] as {
|
||||
permission?: Array<{ permission: string; pattern: string; action: string }>;
|
||||
};
|
||||
expect(created.permission).toEqual([
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "hub_reply", pattern: "*", action: "deny" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
await session.close();
|
||||
{ permission: "question", pattern: "*", action: "deny" },
|
||||
]);
|
||||
expect(created.permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
|
||||
@@ -6733,3 +6725,124 @@ describe("OpenCode snapshot summary false-idle regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode session permission rules", () => {
|
||||
const logger = createTestLogger();
|
||||
function permissionConfig(cwd: string): AgentSessionConfig {
|
||||
return {
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", external_directory: "allow" } },
|
||||
toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] },
|
||||
};
|
||||
}
|
||||
// OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and
|
||||
// drops an unknown `permission` field on prompt_async, so rules sent with a prompt never
|
||||
// reach the server.
|
||||
const expectedRules = [
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
];
|
||||
|
||||
test("creates the session with the agent's permission rules", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession(permissionConfig(cwd));
|
||||
try {
|
||||
expect(openCode.calls.sessionCreate[0]).toEqual(
|
||||
expect.objectContaining({ permission: expectedRules }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the agent's permission rules to a resumed session", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
);
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionID: "ses_resumed_rules",
|
||||
permission: expectedRules,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails the resume when the rules cannot be applied", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } };
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
// Resuming without the rules would run the agent under a more permissive policy than
|
||||
// the one it was configured with, which is the failure this guards.
|
||||
await expect(
|
||||
client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_archived", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
),
|
||||
).rejects.toThrow("Failed to apply OpenCode session permission rules");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves a session without rules untouched", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession({
|
||||
provider: "opencode",
|
||||
sessionId: "ses_resumed_no_rules",
|
||||
metadata: { cwd },
|
||||
});
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1452,12 +1452,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
// OpenCode stores permission rules on the session, so they are set here and on resume
|
||||
// rather than sent with each prompt, which drops them.
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
openCodeConfig.providerOptions,
|
||||
openCodeConfig.toolPolicy,
|
||||
);
|
||||
|
||||
try {
|
||||
// Creating the first session for a directory is part of OpenCode coming up, so it
|
||||
// shares the server startup budget instead of a shorter one that fails agent
|
||||
// creation on contended cold starts.
|
||||
const response = await withTimeout(
|
||||
client.session.create({ directory: openCodeConfig.cwd }),
|
||||
client.session.create({
|
||||
directory: openCodeConfig.cwd,
|
||||
...(permission ? { permission } : {}),
|
||||
}),
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
|
||||
`OpenCode session.create timed out after ${Math.round(
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
|
||||
@@ -1527,6 +1537,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
});
|
||||
|
||||
try {
|
||||
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
|
||||
|
||||
@@ -1550,6 +1561,23 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async applySessionPermissionRules(
|
||||
client: OpencodeClient,
|
||||
config: OpenCodeAgentConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy);
|
||||
if (!permission) return;
|
||||
const response = readOpenCodeRecord(
|
||||
await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }),
|
||||
);
|
||||
if (response?.error) {
|
||||
throw new Error(
|
||||
`Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private acquireServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -3510,10 +3538,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
|
||||
@@ -3525,7 +3549,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
messageID: promptId,
|
||||
parts,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
@@ -3850,10 +3873,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -3868,7 +3887,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
: {}),
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
|
||||
@@ -11,6 +11,52 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { OpenCodeBridge } from "./opencode/bridge.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
|
||||
test("real OpenCode server persists provider permissions across creation and resume", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const logger = createTestLogger();
|
||||
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
|
||||
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
|
||||
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
|
||||
|
||||
try {
|
||||
await mkdir(cwd);
|
||||
session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { external_directory: "allow" } },
|
||||
});
|
||||
inspection = await manager.acquireCurrent();
|
||||
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
|
||||
async function readPermission(sessionId: string) {
|
||||
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
|
||||
if (response.error) throw new Error(JSON.stringify(response.error));
|
||||
return response.data?.permission;
|
||||
}
|
||||
const handle = session.describePersistence()!;
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
// OpenCode appends session-update rules and the last matching rule wins, so a resume
|
||||
// with a different policy leaves both entries in order.
|
||||
session = await client.resumeSession(handle, {
|
||||
providerOptions: { permission: { external_directory: "deny" } },
|
||||
});
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
{ permission: "external_directory", pattern: "*", action: "deny" },
|
||||
]);
|
||||
} finally {
|
||||
await inspection?.release();
|
||||
await session?.close();
|
||||
await manager.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
|
||||
const firstCwd = path.join(root, "first");
|
||||
|
||||
Reference in New Issue
Block a user