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.
|
// no error, no warning, just an agent that never sees a skill. So the keys are asserted literally.
|
||||||
|
|
||||||
describe('serveConfigContent', () => {
|
describe('serveConfigContent', () => {
|
||||||
test('allows the skill tool, because nothing here can answer a prompt', () => {
|
// The bare string, not an object. The object form is accepted and the serve asks anyway — and an
|
||||||
const parsed = JSON.parse(serveConfigContent([], '/tmp/p.md'));
|
// unanswerable prompt here is a hang, not an error.
|
||||||
expect(parsed.permission).toEqual({ skill: { '*': 'allow' } });
|
test('turns permissions off wholesale', () => {
|
||||||
|
expect(JSON.parse(serveConfigContent([], '/tmp/p.md')).permission).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('names instructions and the prompt file exactly', () => {
|
test('names instructions and the prompt file exactly', () => {
|
||||||
@@ -40,6 +41,8 @@ describe('serveConfigContent', () => {
|
|||||||
const out = agentFile('YOU ARE OFFICER');
|
const out = agentFile('YOU ARE OFFICER');
|
||||||
expect(out.startsWith('---\n')).toBe(true);
|
expect(out.startsWith('---\n')).toBe(true);
|
||||||
expect(out).toContain('mode: primary');
|
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');
|
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
|
* - `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.
|
* 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
|
* - Permissions must be turned off WHOLESALE — `"permission": "allow"`, the bare string. A server
|
||||||
* errors. `{"skill": {"*": "allow"}}` is what makes it usable headless.
|
* 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 ──
|
* ── 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. */
|
/** 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');
|
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. */
|
/** Marks a copy as ours, so a hand-made skill in the same directory is never clobbered. */
|
||||||
const MANAGED = '.officer-managed';
|
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. */
|
/** Where the agent's system prompt is written, for `{file:}` to reference. */
|
||||||
export const agentPromptPath = (serveCwd: string) => join(serveCwd, 'officer-agent-prompt.md');
|
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
|
* 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.
|
* 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 {
|
export function serveConfigContent(instructions: string[], promptFile: string): string {
|
||||||
return JSON.stringify({
|
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.
|
EVERYTHING, and as a bare string — the documented form for "no prompting at all". The object form
|
||||||
permission: { skill: { '*': 'allow' } },
|
`{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 } : {}),
|
...(instructions.length ? { instructions } : {}),
|
||||||
agent: {
|
agent: {
|
||||||
[OFFICER_AGENT]: {
|
[OFFICER_AGENT]: {
|
||||||
description: "Officer's own assistant: this machine, not just its code.",
|
description: "Officer's own assistant: this machine, not just its code.",
|
||||||
mode: 'primary',
|
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.
|
// `{file:}`, per the documentation. Inline prompt text is parsed and never applied.
|
||||||
prompt: `{file:${promptFile}}`,
|
prompt: `{file:${promptFile}}`,
|
||||||
},
|
},
|
||||||
@@ -292,6 +326,9 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
|
|||||||
try {
|
try {
|
||||||
mkdirSync(serveCwd, { recursive: true });
|
mkdirSync(serveCwd, { recursive: true });
|
||||||
writeFileSync(agentPromptPath(serveCwd), officerAgentPrompt(skills));
|
writeFileSync(agentPromptPath(serveCwd), officerAgentPrompt(skills));
|
||||||
|
const file = agentFilePath();
|
||||||
|
mkdirSync(join(file, '..'), { recursive: true });
|
||||||
|
writeFileSync(file, agentFile(officerAgentPrompt(skills)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[opencode] could not write the agent prompt — turns will use the built-in agent', 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