Skip to content

fix(pixel): route a new project's rooted /<name>/<file> write like <name>/<file>; the generic refusal says "Not written." - #6765

Open
Lightheartdevs wants to merge 3 commits into
mainfrom
claude/pixel-rooted-project-path
Open

Lightheartdevs wants to merge 3 commits into
mainfrom
claude/pixel-rooted-project-path

Conversation

@Lightheartdevs

Copy link
Copy Markdown
Collaborator

Problem

This comes from the open-prompt sweep on build bfb62a2bc (Qwen3.6-35B-A3B INT4). The evidence is in run strixy-int4-bfb62a2b-sweep, folders 04-snake-game and 03-coffee-landing.

04-snake-game ("make me a snake game") failed with nothing saved. Session file sha256 570e57ca….

# call recorded result
1 write /snake-game/index.html (7285 chars) Refused with only the generic CORRECTION ("For a new project, use a workspace-relative path such as Playground/snake-game/index.html …"). The text never says the file was not written.
2 pixel_ods_workspace_preview snake-game Refused because no index.html had been created. The model assumed the write had worked.
3 read /snake-game/index.html Core refused it: "Path escapes sandbox root".
4 read snake-game/index.html Fourth consecutive failure, so the progress fuse stopped the run.
5-6 ls snake-game/ "This response was stopped …"

03-coffee-landing hit the same refusal on /bean-there/index.html. It recovered only by generating the whole page again as Playground/bean-there/index.html.

Why the rooted path is refused on main:

So a rooted /<name>/<file> gets the generic correction every time.

Fix (plugin/playground-projects.mjs)

  1. Rooted project reading (rootedProjectPath). A rooted /<name>/<file…> is read as the workspace-relative <name>/<file…> only when all of the following hold:
    • <name> passes descriptiveFolder: not GENERIC, not a role, test or sample name, and at least 3 letters.
    • <name> is not a top-level host, container or notebook directory: bin boot dev etc home lib* media mnt opt proc root run sbin snap srv sys tmp usr var nix private cores net afs Users Library Applications System Volumes Network Windows ProgramData workspace(s) sandbox data content output(s).
    • <name> does not match /playground/i. Those spellings belong to fix(pixel): route Playground misspellings of a new project instead of refusing them #6746, so /playgrounds/… stays refused.
    • <name> is not a component of the configured workspace path, compared case-insensitively.
    • Nothing named <name> exists at the filesystem root of the workspace's volume, or in the workspace. An entry that cannot be checked (EACCES, EBUSY) counts as present. An existing workspace folder may be an owner project, so a rooted spelling is never read onto it; the write keeps today's refusal.
  2. First write of a fresh project. The reading applies only when the path is not already workspace-relative (and is not a Playground spelling), and the model has not read that rooted path successfully as written. The write then routes exactly as <name>/<file…> does today:
    • Playground/<name> is reserved, with the usual -2 suffix on a collision.
    • The write params are rewritten into it.
    • The follow-up preview "<name>" maps to the same folder through the existing binding.
  3. Later calls. A rooted /<source>… spelling of the bound project routes like <source>… for write, edit, read, preview, exec workdir and apply_patch headers. This applies unless the model already read that rooted path as written. Any other rooted path keeps today's handling (unchanged, so core refuses it).
  4. Generic write refusal. It now starts with Not written. These texts do not change:
    • the suggestion refusal (Not written: … Call write again now with path …);
    • the exec-before-write text;
    • CORRECTION itself.

Unchanged:

  • Owner-named paths (ownerNamesPath) and non-project requests are still preserved and never routed.
  • Refusals are charged exactly as before. RUN_PROGRESS_LIMITS and FREE_CORRECTIONS_PER_KIND do not change.
  • No new location is granted. Every rooted path this reads was already refused by the router on main, in every access mode. It now becomes a workspace write that the core tools still check.

The Not written assertion in playground_guidance.test.mjs

#6751 added assert.doesNotMatch(reason, /Not written|Call write/) to the test "role, generic and multi-segment names keep only the generic correction". At the time, "Not written" appeared only in the suggestion prefix, so the assertion proved that those names get no suggested path.

That distinction is kept. The test now expects the reason to start with Not written. For a new project …, and it still rejects Not written: (the suggestion prefix), Call write and with path. playground_spelling.test.mjs also expects the new prefix.

Tests

New tests/playground_rooted_paths.test.mjs (7 tests). The first replay drives createToolLoopGuard through before_tool_call and after_tool_call against a real temporary workspace, for both the sandbox and gateway hosts.

  • Replay of the recorded snake-game write and preview:
    • the write lands in Playground/snake-game/index.html, unchanged;
    • the preview snake-game executes as Playground/snake-game;
    • the recorded follow-up reads (/snake-game/index.html, snake-game/index.html) return the saved file;
    • no failure is charged.
  • Coffee-landing: with Playground/bean-there already present (as in the recorded workspace), /bean-there/index.html lands in Playground/bean-there-2, where the recorded retry landed.
  • Routes like the relative path: /snake-game/index.html, /weather-tool/main.py, /photo-renamer/src/rename.py and \snake-game\index.html route and bind exactly as their relative spellings do, both direct and wrapped.
  • Stays refused with Not written., with no binding and no state.failed:
    • host paths such as /home/…, /etc/passwd, /tmp/x/y.py, /usr/…, /var/…, /opt/…, /srv/…, /mnt/…, /media/…, /root/…, /Users/…, /Library/…, /Applications/…, /Volumes/…, /System/…, /workspaces/…, /data/…, /content/…;
    • generic and role names: /src/… (src is GENERIC, so it is refused whether or not a host has /src), /app/…, /public/…, /project/…, /demo/…, /ab/…, /CON/…;
    • Playground spellings: /playgrounds/…, /my-playground/…, /PlaygroundX/…;
    • escapes and malformed paths: ../, /../, //, /./, // inside the path, and a bare /snake-game or /snake-game/;
    • components of the configured workspace path.
  • Host-root property: no entry at the host filesystem root (other than the /workspace alias and /playground spelling) is ever read as a new project.
  • Nothing is relocated:
    • an existing workspace snake-game/ keeps the refusal and its file is untouched;
    • a rooted path the model read stays refused;
    • owner-named and non-project intents pass through unchanged.
  • Later-call scope: covers write, edit, read, preview, exec workdir and patch, with and without a collision suffix. Other rooted names, system paths and a rooted path already read are unchanged.

Before the fix, 6 of the 7 new tests fail on main. The seventh (the host-root property) holds on main too. Mutation checks: removing any one guard fails at least one test. That covers the system list, the playground exclusion, the root components, the host-root check, the workspace check, the read-as-spelled check, the later mapping, its source restriction and the patch mapping.

Full Linux pixel-agent suite (node --test tests/*.test.mjs):

ref tests pass fail
head 319299eaa 3210 3172 0
head + main 0a632581e (local merge, not pushed) 3212 3174 0

Recorded-trace replay. All 210 claude-open-prompts session traces were replayed through main's and this branch's guard, each under sandbox and gateway (420 runs), with simulated receipts. Decisions differ in 4 traces:

  • 04-snake-game: the write saves, the preview publishes and the reads resolve, so the run no longer reaches the fuse.
  • 03-coffee-landing: the first write saves. The recorded mkdir and retry write that followed were reactions to the old refusal.
  • 2 traces with text-only changes: the same block, now prefixed Not written. (/workspace/.openclaw/tmp/… and a host-root-prefixed path that only the replay's temporary root leaves unstripped).
  • 07-photo-renamer from the same sweep: no decision changes. That run is ended by exec-before-write refusals, which this PR does not touch.

Across all 210 traces, the only rooted first writes are the two above plus one /home/… write, which stays refused.

Open PRs: git merge-tree of this head with #6743, #6747, #6748, #6750 (now merged), #6752, #6753, #6754, #6755, #6716, #6722, #6732, #6756, #6759 and #6760 finds no new conflict.

🤖 Generated with Claude Code

Lightheartdevs and others added 3 commits September 26, 2026 03:51
…ame>/<file>, and say "Not written." on the generic refusal

Open-prompt sweep on build bfb62a2, prompt 04 "make me a snake game"
(Qwen3.6-35B-A3B INT4): the first write was /snake-game/index.html (7285
chars). relative() strips only the configured root, /workspace/ and ./,
#6746 reads only a first segment spelled playground, and #6751's
suggestion strips one leading / only when a single file name remains, so
the write got the generic correction. That text did not say the file was
not written, the model published "snake-game" (refused: no index.html),
read /snake-game/index.html (outside the sandbox root) and
snake-game/index.html, and the fourth consecutive failure stopped the run
with nothing saved. Prompt 03 hit the same refusal with
/bean-there/index.html and recovered only after generating the whole page
again.

playground-projects.mjs:
- rootedProjectPath reads a rooted /<name>/<file...> as the
  workspace-relative <name>/<file...> only when <name> is a descriptive
  project folder (descriptiveFolder), not a host, container or notebook
  top-level directory (home, etc, tmp, usr, var, opt, Users, Library,
  Volumes, workspace, data, content and similar), does not match
  /playground/i (#6746 owns those spellings), is not a component of the
  configured workspace path, and no entry with that name exists at the
  filesystem root or in the workspace (an entry that cannot be checked
  counts as present). An existing workspace folder may be an owner
  project, so a rooted spelling is never read onto it.
- A fresh-project first write uses that reading when the path is not
  otherwise workspace-relative and the model did not read that rooted
  path. It then routes exactly like <name>/<file...>: Playground/<name>
  is reserved (with the usual -2 suffix on collision) and the write is
  rewritten into it, so the follow-up preview "<name>" maps to the same
  folder.
- After binding, a rooted /<source>... spelling of the bound project
  routes like <source>... for write, edit, read, preview, exec workdir and
  apply_patch headers. Any other rooted path keeps today's handling.
- The generic write refusal now starts with "Not written." The
  suggestion refusal ("Not written: ... Call write again now with path
  ...") is unchanged, as are the exec-before-write text and the
  CORRECTION text itself.

Tests: new tests/playground_rooted_paths.test.mjs replays the recorded
snake-game write and preview through createToolLoopGuard for sandbox and
gateway hosts (saved in Playground/snake-game, preview maps, no failure
charged) and the coffee-landing write (lands in bean-there-2 when
Playground/bean-there exists, as the recorded retry did), and covers
/home/..., /etc/passwd, /tmp/x/y.py, /src/..., /playgrounds/...,
/Users/..., ../ escapes, host-root entries, configured-root components,
an existing workspace folder, a read rooted path, owner-named paths and
later-call scope. The generic-refusal assertion in
playground_guidance.test.mjs existed to prove role, generic and
multi-segment names get no suggested path; it now expects the
"Not written." prefix and still rejects "Not written:", "Call write" and
"with path". playground_spelling.test.mjs expects the same prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A later rooted /<source>/... spelling of the bound project routes into the
project only when the model has not already read that rooted path
successfully as written, the same rule the first write applies. A
successful read means the path resolved outside the workspace, so it is
left exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he host-root property

/workspace/ is the sandbox alias and /playground/ is #6746's Playground
spelling on every host, so neither is a rooted project reading. A test
host whose filesystem root holds either entry no longer fails the
host-root property spuriously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant