Files
open-design/apps/daemon/tests/skill-write-guidance.test.ts
lefarcenandGitHub c5ae6292c4 fix(daemon): state the skill read-only boundary instead of letting agents guess (#7720)
* fix(daemon): state the skill read-only boundary instead of letting agents guess

A user asked the agent to update one of his own skills. Every write to the
skill directory came back `Operation not permitted` — by design:
`resolveChatExtraAllowedDirs` keeps skill roots out of the writable
allowlist, and Codex is given no skill directory at all because it treats
`--add-dir` as write access (#622).

Nothing in the composed system prompt said so. With no stated boundary and
no stated exit, the agent invented one: it told the user to open Open
Design's settings and add the directory to a "文件系统 / 工作区 / 可写目录 /
writable roots" option. No such setting exists — not for writable roots, not
for a sandbox mode, not for an approval policy. The user went looking for
it, could not find it, and filed a diagnostics bundle (0.21.0, packaged
stable).

Adds an `## Editing skills` section that states the boundary and its
symptom, names the real edit surface (the Integration view's Skills tab —
NOT a "Settings > Skills" page, which does not exist), and forbids inventing
a settings path to explain the failure. The missing settings are worded as a
statement about the current build rather than a refusal, so contributors do
not read it as "we will never build this"; a controlled skill-write path is
still on the table.

Gated on `executionProfile === 'filesystem'`. Deliberately NOT gated on
`isSlimCore` — slim is the production default, so that gate would ship this
to nobody — nor on an active skill, since "update my skill" arrives on turns
where no skill is bound. Ask mode and API/BYOK mode have no file tools and
so cannot hit the boundary; the matrix snapshot pins that (18 of 20
scenarios receive it, `ask-mode-full-context` and `api-mode-byok` do not).

Red spec: tests/skill-write-guidance.test.ts, 5/5 red on this branch with
the `parts.push` removed, and 1/5 red (the wording guard alone) when
"does not currently expose" is rewritten to an absolute "we do not support".
Assertions are scoped to the extracted section rather than the whole prompt:
an earlier draft asserted /today/i document-wide and passed with no
implementation at all.

Note: like any prompt change this shifts the stable-prompt hash once, so
existing conversations take a single `stable-prompt-changed` cache miss on
first turn after upgrade.

Tested: pnpm --filter @open-design/daemon exec vitest run tests/skill-write-guidance.test.ts tests/system-prompt-template.test.ts tests/prompt-templates.test.ts tests/intent-signal-stable-prompt-cache.test.ts tests/official-system-prompt.test.ts tests/prompt-telemetry.test.ts tests/prompts tests/chat-route.test.ts (20 files, 355 tests)
Tested: pnpm --filter @open-design/daemon build
Tested: pnpm guard
Tested: pnpm typecheck
Not-tested: live packaged client — prompt-only change, no runtime surface

* fix(daemon): name the staged .od-skills copy as a non-persisting write

The boundary section told agents that writing a skill returns `Operation
not permitted`. That is true for the real skill root, and false for the
one skill path the prompt stack actually advertises:
`withSkillRootPreamble` names `<cwd>/.od-skills/<folder>/` as the primary
**Skill root**, and it sits inside the agent's writable project cwd. A
`Write`/`Edit`/`Bash` against `.od-skills/<id>/SKILL.md` therefore
succeeds, reads as an install, and is discarded when `stageActiveSkill`
re-stages the copy on the next turn — the same misdirection the section
was written to stop, minus the error that would have stopped it.

The handover line made that landing spot likely rather than incidental:
"write the proposed `SKILL.md` into the project folder" pointed at a
folder that already contains a directory labelled the skill root.

So: name the staged copy and say a write there does not persist, and
send the proposal to a new `.md` file in the project folder, explicitly
not under `.od-skills/`. The two new assertions are section-scoped and
go red against the previous wording — `/operation not permitted/` could
not, because on this path nothing is denied.

* fix(od-next): state the same skill read-only boundary on the strategy path

`server.ts` picks one composer per run:

    const prompt = odNextStrategyRecipe
      ? composeOdNextStrategyCorePromptV2(odNextStrategyRecipe)
      : composeSystemPrompt(systemPromptInputs);

so everything the previous commit added lives on the classic branch only.
When the OD Next strategy is assigned, `composeSystemPrompt` is never
called and there is no always-resident block underneath — the boundary
section disappears wholesale, and the reported failure comes back.

OD Next is not a path where this is hypothetical. It is the path with the
sharper version of the same trap: `materializeFrozenSkillPackage` writes
the frozen Skill package into `<cwd>/.od-skills/`, and the strategy prompt
hands the agent those exact roots — `materializedRoot` in the Skill roster
(`renderFrozenSkillRosterContext`) and `Frozen side-file root:` in each
Skill body (`resolveFrozenSkillBundleBodies`). Writable directory,
advertised path, no stated boundary, and a write there additionally breaks
the frozen identity the daemon verifies.

So the same four statements land in the core strategy prompt's
`## Agent and runtime boundaries`: the boundary, the materialized roots
that accept a write without persisting it, the real Skills-tab exit, and
the prohibition on inventing a sandbox / writable-roots / approval-policy
setting.

`assets.core.version` moves 2.1.0 -> 2.2.0 with the text. Package identity
is content-hashed from these bytes at apply time
(`buildStrategyPackageIdentity`), and `plugins-strategy-recipe.test.ts`
copies the real bundled folder, so that suite exercises identity against
the edited asset rather than a fixture.

The new assertions are scoped to the section (`.od-skills` also appears in
the task-profile assets, so a document-wide match could not go red) and
whitespace-normalized, since the asset is hard-wrapped prose. Five of the
six go red against the previous content; the sixth is labelled in-file as a
locator check that stays green on `main` by design.
2026-09-06 11:02:40 +00:00

213 lines
9.9 KiB
TypeScript

import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { composeSystemPrompt } from '../src/prompts/system.js';
// Regression cover for the "skills are read-only and the agent was never told"
// report (0.21.0, packaged stable). A user asked the agent to update one of his
// own skills under the daemon data dir. Every write came back `Operation not
// permitted` — by design: `resolveChatExtraAllowedDirs` deliberately keeps skill
// roots out of the agent's writable allowlist, and Codex gets no skill dir at
// all (PR #622: "Do not add read-only resource/reference directories to Codex
// --add-dir").
//
// Nothing in the composed system prompt said so. With no stated boundary and no
// stated exit, the agent invented one: it told the user to open Open Design's
// settings and add the directory to a "文件系统 / 工作区 / 可写目录 / writable
// roots" option. No such setting exists — not for writable roots, not for a
// sandbox mode, not for an approval policy. The user hunted for it, could not
// find it, and filed a diagnostics bundle.
//
// These tests pin the three things the prompt must state so the failure mode
// cannot come back:
// 1. the boundary — skill directories are not writable through file tools;
// 2. the staged copy — `.od-skills/<folder>/` is the one skill path the
// permission error does NOT cover. `withSkillRootPreamble` advertises it
// as the primary **Skill root** and it lives inside the agent's writable
// project cwd, so a write there succeeds, looks like an install, and is
// wiped when `stageActiveSkill` re-stages next turn — the same
// misdirection as the invented setting, without the error to stop it;
// 3. the exit — where a human actually edits a skill in the UI;
// 4. the prohibition on inventing product settings, worded so it stays a
// statement about today rather than a promise we never make one.
//
// The assertions match on meaning rather than importing the implementation
// constant: a same-file copy would go green against any wording, including a
// wording that reintroduces the bug.
//
// Every assertion runs against the extracted section, never the whole prompt.
// Scoping matters: an earlier draft asserted `/today/i` over the full document
// and passed on `main` with no implementation at all, because some unrelated
// section happened to contain the word. A guard that cannot go red is not a
// guard.
const SECTION_HEADING = '## Editing skills';
function skillGuidanceSection(prompt: string): string {
const start = prompt.indexOf(SECTION_HEADING);
if (start < 0) return '';
const rest = prompt.slice(start + SECTION_HEADING.length);
const end = rest.search(/\n(?:#{1,3} |---)/u);
return end < 0 ? rest : rest.slice(0, end);
}
function composedSkillGuidance(): string {
return skillGuidanceSection(composeSystemPrompt({ metadata: { kind: 'prototype' } }));
}
describe('composeSystemPrompt — skill write guidance', () => {
it('emits a dedicated skill-editing section', () => {
expect(composedSkillGuidance()).not.toBe('');
});
it('states that skill directories are not writable through file tools', () => {
const section = composedSkillGuidance();
// The boundary itself, and the observable symptom the agent will hit.
expect(section).toMatch(
/skill (directories|folders|roots)[^.]*(are not|is not|aren't|cannot be)[^.]*writ/i,
);
expect(section).toMatch(/operation not permitted|permission denied|will fail/i);
});
it('names the staged .od-skills copy as a write that does not persist', () => {
const section = composedSkillGuidance();
// `/operation not permitted/` above cannot go red on this path: the staged
// copy is inside the writable project cwd, so the write succeeds. The
// section has to say so explicitly, or "write it into the project folder"
// reads as an invitation to install into `.od-skills/<id>/SKILL.md`.
expect(section).toMatch(/\.od-skills/);
expect(section).toMatch(/(appear to succeed|succeed[^.]*(but|while)|does not (update|persist))/i);
expect(section).toMatch(/(do not|don't|never) edit that path/i);
});
it('keeps the handover file out of the staged copy', () => {
// The proposal is a new file in the project folder, explicitly not under
// the staged skill root.
expect(composedSkillGuidance()).toMatch(/never under `\.od-skills\/`/i);
});
it('points at the real UI location where a skill is edited', () => {
// The actual entry point is the Integration view's Skills tab
// (`IntegrationsView.tsx`, `integrations.tabLabel.skills`) — NOT a
// "Settings > Skills" page, which does not exist. Naming the wrong
// surface here would reproduce the original bug in our own voice.
expect(composedSkillGuidance()).toMatch(/Integration[^.]{0,40}Skills/i);
});
it('forbids inventing sandbox / writable-root / approval-policy settings', () => {
const section = composedSkillGuidance();
// Hyphen-tolerant: the prose reads "writable-directory" / "approval-policy"
// in attributive position and "writable roots" when naming the setting.
expect(section).toMatch(/writable[- ](root|director)/i);
expect(section).toMatch(/approval[- ]polic/i);
expect(section).toMatch(
/(do not|don't|never)[^.]*(invent|fabricate|make up|claim|tell the user to)/i,
);
});
it('words the missing settings as "not today" rather than "never"', () => {
const section = composedSkillGuidance();
// Product asked for this explicitly: contributors read an absolute "we do
// not have this" as "this is rejected". Keep it a statement about the
// current build so a future writable-roots surface is not pre-refused.
expect(section).toMatch(/do(es)? not currently|not yet|current build/i);
expect(section).not.toMatch(
/(will never|never going to|has no plans|not planned|we do not (support|intend))/i,
);
});
});
// The same boundary, on the OTHER prompt path.
//
// `server.ts` picks exactly one composer per run:
//
// const prompt = odNextStrategyRecipe
// ? composeOdNextStrategyCorePromptV2(odNextStrategyRecipe)
// : composeSystemPrompt(systemPromptInputs);
//
// so everything the suite above pins is absent whenever the OD Next strategy
// is assigned — `composeSystemPrompt` is not called at all, and there is no
// third always-resident block to fall back on. OD Next is not a path where
// this is hypothetical: `materializeFrozenSkillPackage` writes the frozen
// Skill package into `<cwd>/.od-skills/`, and the strategy prompt hands the
// agent those exact roots (`materializedRoot` in the Skill roster,
// `Frozen side-file root:` in each Skill body). Writable directory,
// advertised path, no stated boundary — the original report's setup, with a
// frozen-identity break on top.
//
// The OD Next core prompt is a bundled plugin asset rather than a TypeScript
// constant, so this reads the shipped file. Content identity is computed from
// these bytes at apply time (`buildStrategyPackageIdentity`), which is why the
// manifest's declared `assets.core.version` moves with the text.
const OD_NEXT_CORE_PROMPT = path.resolve(
import.meta.dirname,
'../../../plugins/_official/scenarios/od-next-strategy/assets/core-system-prompt.md',
);
function odNextBoundarySection(): string {
const text = readFileSync(OD_NEXT_CORE_PROMPT, 'utf8');
const heading = '## Agent and runtime boundaries';
const start = text.indexOf(heading);
if (start < 0) return '';
const rest = text.slice(start + heading.length);
const end = rest.search(/\n## /u);
const section = end < 0 ? rest : rest.slice(0, end);
// This asset is hard-wrapped prose, so a sentence the assertions below look
// for is routinely split across two lines. Collapse runs of whitespace so a
// reflow (which changes no meaning) cannot turn a guard red, while a deleted
// or reworded sentence still does.
return section.replaceAll(/\s+/gu, ' ');
}
describe('OD Next core strategy prompt — skill write guidance', () => {
// Locator sanity only: the heading predates this change, so this one stays
// green on `main`. The guards that can actually go red are below it.
it('locates the section the guidance belongs to', () => {
expect(odNextBoundarySection()).not.toBe('');
});
it('states that skill directories are not writable through file tools', () => {
const section = odNextBoundarySection();
expect(section).toMatch(
/skill (directories|folders|roots)[^.]*(are not|is not|aren't|cannot be)[^.]*writ/i,
);
expect(section).toMatch(/operation not permitted|permission denied|will fail/i);
});
it('names the materialized .od-skills roots as writes that do not persist', () => {
const section = odNextBoundarySection();
// Scoped to this section, not the whole file: `.od-skills` appears in the
// task-profile assets too, so a document-wide match could not go red.
expect(section).toMatch(/\.od-skills/);
expect(section).toMatch(/appear to succeed while updating nothing/i);
expect(section).toMatch(/never write them|do not write them/i);
});
it('keeps the handover file out of the materialized copy', () => {
expect(odNextBoundarySection()).toMatch(/never under `\.od-skills\/`/i);
});
it('points at the real UI location where a skill is edited', () => {
expect(odNextBoundarySection()).toMatch(/Integration[^.]{0,40}Skills/i);
});
it('forbids inventing sandbox / writable-root / approval-policy settings', () => {
const section = odNextBoundarySection();
expect(section).toMatch(/writable[- ](root|director)/i);
expect(section).toMatch(/approval[- ]polic/i);
expect(section).toMatch(
/(do not|don't|never)[^.]*(invent|fabricate|make up|claim|tell the user to)/i,
);
expect(section).toMatch(/do(es)? not currently|not yet|current build/i);
expect(section).not.toMatch(
/(will never|never going to|has no plans|not planned|we do not (support|intend))/i,
);
});
});