permissions off wholesale, by every documented mechanism, plus a backstop
read the documentation instead of the binary, which is what should have happened
hours ago. it says three things this code was getting wrong.
- "permission": "allow" — a BARE STRING, not the object form. that is the
documented way to say "no prompting at all".
- most permissions already default to allow. the exceptions are
`external_directory` and `doom_loop`, plus `read` on `.env`. those are what
was actually stopping turns.
- an agent's own rules take precedence over the global config, and an agent
that declares none inherits the built-in defaults — so a global allow does
not reach it.
so: `permission: "allow"` in the serve config, AND the agent moved to the
markdown FILE form with `permission: allow` in its frontmatter. the file form is
what the docs recommend "for anything non-trivial", and it is the only place an
agent's permissions are honoured — the inline definition ignores both `permission`
and `prompt`, while accepting `description` and `mode`, which is the worst kind of
partial.
WHAT I COULD NOT MAKE TRUE. with all of that applied, the serve still reports
`external_directory: ask`, `doom_loop: ask` and `read *.env: ask` on the resolved
agent. i cannot get that list to come back clean, and i am not going to guess at
it again.
which is why the event-stream approver stays and is the thing actually doing the
work: whatever opencode asks, the platform answers `always`. that is the analogue
of the claude harness's `bypassPermissions` and it does not depend on predicting
the shape of the next gate. the config above is the belt; that is the braces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -5,9 +5,10 @@ import { serveConfigContent, affordancesPath, agentFile } from './serve-env';
|
||||
// no error, no warning, just an agent that never sees a skill. So the keys are asserted literally.
|
||||
|
||||
describe('serveConfigContent', () => {
|
||||
test('allows the skill tool, because nothing here can answer a prompt', () => {
|
||||
const parsed = JSON.parse(serveConfigContent([], '/tmp/p.md'));
|
||||
expect(parsed.permission).toEqual({ skill: { '*': 'allow' } });
|
||||
// The bare string, not an object. The object form is accepted and the serve asks anyway — and an
|
||||
// unanswerable prompt here is a hang, not an error.
|
||||
test('turns permissions off wholesale', () => {
|
||||
expect(JSON.parse(serveConfigContent([], '/tmp/p.md')).permission).toBe('allow');
|
||||
});
|
||||
|
||||
test('names instructions and the prompt file exactly', () => {
|
||||
@@ -40,6 +41,8 @@ describe('serveConfigContent', () => {
|
||||
const out = agentFile('YOU ARE OFFICER');
|
||||
expect(out.startsWith('---\n')).toBe(true);
|
||||
expect(out).toContain('mode: primary');
|
||||
// Frontmatter is the only place an agent's permissions are honoured — inline config is ignored.
|
||||
expect(out).toContain('permission: allow');
|
||||
expect(out.split('---')[2]?.trim()).toBe('YOU ARE OFFICER');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,8 +47,9 @@ import { capabilityRoots } from '../../capability-sources';
|
||||
*
|
||||
* - `name` in a SKILL.md must match `^[a-z0-9]+(-[a-z0-9]+)*$` AND the directory name. Every skill
|
||||
* here carried a human title there, so every one was listed and none could be loaded.
|
||||
* - The `skill` permission defaults to asking, and a server session has nobody to ask, so the request
|
||||
* errors. `{"skill": {"*": "allow"}}` is what makes it usable headless.
|
||||
* - Permissions must be turned off WHOLESALE — `"permission": "allow"`, the bare string. A server
|
||||
* session has nobody to answer a prompt, so anything left at "ask" does not fail, it hangs.
|
||||
* `external_directory` and `doom_loop` are the two that default to asking.
|
||||
*
|
||||
* ── Affordances: one composed file, because they are not optional reading ──
|
||||
*
|
||||
@@ -106,6 +107,33 @@ import { capabilityRoots } from '../../capability-sources';
|
||||
/** Where the composed affordances land. Beside the serve's own state, not in anyone's home. */
|
||||
export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-affordances.md');
|
||||
|
||||
/**
|
||||
* The agent as a markdown file — the form the documentation recommends "for anything non-trivial", and
|
||||
* the only one that carries a permission set.
|
||||
*
|
||||
* The inline definition is honoured for `description` and `mode` and silently not for the two that
|
||||
* matter: `prompt` needs `{file:}`, and `permission` does not apply at all — with `"permission": "allow"`
|
||||
* in config AND spelled out on the inline agent, the resolved agent still reported `external_directory:
|
||||
* ask` and `doom_loop: ask`. Frontmatter is where an agent's permissions actually live.
|
||||
*/
|
||||
export const agentFilePath = () => join(homedir(), '.config', 'opencode', 'agent', `${OFFICER_AGENT}.md`);
|
||||
|
||||
/** Frontmatter, then the body — and the body IS the system prompt. */
|
||||
export const agentFile = (prompt: string) =>
|
||||
[
|
||||
'---',
|
||||
"description: Officer's own assistant: this machine, not just its code.",
|
||||
'mode: primary',
|
||||
// Everything, because nothing at this end of the socket can answer a prompt. `question`,
|
||||
// `plan_enter` and `plan_exit` keep their built-in DENY: those genuinely need a person, and failing
|
||||
// the call immediately beats waiting on a dialog Officer cannot show.
|
||||
'permission: allow',
|
||||
'---',
|
||||
'',
|
||||
prompt,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** Marks a copy as ours, so a hand-made skill in the same directory is never clobbered. */
|
||||
const MANAGED = '.officer-managed';
|
||||
|
||||
@@ -158,21 +186,6 @@ export function copySkills(roots: string[]): number {
|
||||
/** Where the agent's system prompt is written, for `{file:}` to reference. */
|
||||
export const agentPromptPath = (serveCwd: string) => join(serveCwd, 'officer-agent-prompt.md');
|
||||
|
||||
/** The agent definition OpenCode actually reads. Ours entirely — rewritten from scratch each boot. */
|
||||
export const agentFilePath = () => join(homedir(), '.config', 'opencode', 'agent', `${OFFICER_AGENT}.md`);
|
||||
|
||||
/** Frontmatter, then the body — and the body IS the system prompt. */
|
||||
export const agentFile = (prompt: string) =>
|
||||
[
|
||||
'---',
|
||||
"description: Officer's own assistant: this machine, not just its code.",
|
||||
'mode: primary',
|
||||
'---',
|
||||
'',
|
||||
prompt,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the
|
||||
* cost of getting a key name wrong is silence — no error, just an agent that never sees a skill.
|
||||
@@ -220,14 +233,35 @@ export function officerAgentPrompt(skills: string | null): string {
|
||||
|
||||
export function serveConfigContent(instructions: string[], promptFile: string): string {
|
||||
return JSON.stringify({
|
||||
// Nothing here may stop to ask a person: there is nobody at this end of the socket, and a `skill`
|
||||
// permission left at its default of "ask" fails the call instead of waiting.
|
||||
permission: { skill: { '*': 'allow' } },
|
||||
/*
|
||||
EVERYTHING, and as a bare string — the documented form for "no prompting at all". The object form
|
||||
`{skill: {"*": "allow"}}` was tried first and the serve asked anyway.
|
||||
|
||||
This is the same stance the Claude harness takes with `bypassPermissions`, for the same stated
|
||||
reason: on the owner's own machine an unsandboxed agent is the point. Most permissions already
|
||||
default to allow; the two that do not are `external_directory` and `doom_loop`, and the first is
|
||||
what silently hung a turn on `skill calendar-and-contacts` — a permission request nothing in
|
||||
Officer can display, so no error, no log, just a spinner.
|
||||
*/
|
||||
permission: 'allow',
|
||||
...(instructions.length ? { instructions } : {}),
|
||||
agent: {
|
||||
[OFFICER_AGENT]: {
|
||||
description: "Officer's own assistant: this machine, not just its code.",
|
||||
mode: 'primary',
|
||||
/*
|
||||
Repeated on the AGENT, because agent rules take precedence over the global `permission` and an
|
||||
agent that sets none inherits the built-ins instead. Verified: with `permission: "allow"` in
|
||||
the config alone, this agent still reported `external_directory: ask`, `doom_loop: ask` and
|
||||
`read *.env: ask`. Each of those is a hang here, not a refusal.
|
||||
|
||||
`question`, `plan_enter` and `plan_exit` are deliberately left at their built-in DENY. They
|
||||
are the ones that need a person, and a denial fails the call immediately where an "ask" would
|
||||
wait for a dialog Officer cannot show. Fail fast beats hang, and neither is usable headless.
|
||||
*/
|
||||
// The bare string here too. The object form — even spelling out `external_directory` and
|
||||
// `doom_loop` — left both reporting "ask" on the resolved agent.
|
||||
permission: 'allow',
|
||||
// `{file:}`, per the documentation. Inline prompt text is parsed and never applied.
|
||||
prompt: `{file:${promptFile}}`,
|
||||
},
|
||||
@@ -292,6 +326,9 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
|
||||
try {
|
||||
mkdirSync(serveCwd, { recursive: true });
|
||||
writeFileSync(agentPromptPath(serveCwd), officerAgentPrompt(skills));
|
||||
const file = agentFilePath();
|
||||
mkdirSync(join(file, '..'), { recursive: true });
|
||||
writeFileSync(file, agentFile(officerAgentPrompt(skills)));
|
||||
} catch (err) {
|
||||
console.error('[opencode] could not write the agent prompt — turns will use the built-in agent', err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user