feat: tell Codex to run contextbridge open outside the sandbox#115
Conversation
contextbridge open launches a local browser window, which is blocked by
Codex's default sandbox: $planbridge-open prints the listening URL but
the browser never opens automatically. The fix is content the Codex agent
reads — its SKILL.md needs to tell it to escalate the sandbox.
To deliver that content without leaking a hardcoded Codex appendix into
the renderer, this change makes SKILL.md bodies Handlebars templates:
* render() compiles the body and evaluates it with { harness } as data.
* One helper, eq (strict equality), enables call-site conditionals like
{{#if (eq harness.id "codex")}}…{{/if}}.
* Reusable per-harness snippets live at sources/_partials/<harnessId>/
<topic>.md and are registered as Handlebars partials named after their
path under _partials/ minus the .md extension.
* loadAllFrom skips underscore-prefixed directories so the partials tree
is invisible to the skill loader and orphan-detection pass.
* Render errors throw with the skill name + harness id, with the original
error attached as cause.
The new codex/sandbox-escalation partial cites the parameters Codex's
exec_command/shell tool actually accepts — sandbox_permissions:
\"require_escalated\", a justification, and a suggested prefix_rule of
[\"contextbridge\", \"open\"] — verified against
codex-rs/core/src/tools/handlers/shell_spec.rs in the codex repo.
The open SKILL.md gains one templated block at the call site:
{{#if (eq harness.id \"codex\")}}
{{> codex/sandbox-escalation}}
{{/if}}
Generated harnessIntegrations/<id>/skills/<n>/SKILL.md files are added
to .prettierignore — the renderer is the source of truth for their
shape, and we already follow this pattern for CHANGELOG.md,
projen-managed files, and harnessIntegrations/claude/.claude-plugin/
plugin.json.
Closes #110.
Run rendered outputs through prettier in `targetsFor` so the extra blank line Handlebars leaves where a non-matching conditional sat is collapsed; drop the harnessIntegrations/*/skills/**/SKILL.md exclusion from .prettierignore since the generator now produces prettier- conformant output (drift check still guards against hand-edits).
The partial referenced `contextbridge open`, its browser-window behavior, and a hardcoded `prefix_rule: ["contextbridge", "open"]`. Rewrite it to describe the codex escalation pattern generically so future skills with other commands can pull it in unchanged.
| const safeReaddir = fromThrowable((dir: string) => readdirSync(dir, { withFileTypes: true })); | ||
|
|
||
| function main(): void { | ||
| async function main(): Promise<void> { |
There was a problem hiding this comment.
async now because we prettier-format the skills (which is an async call)
| SKILL_RENDERABLE_HARNESSES.map(async (harness) => { | ||
| const path = join(outDirFor(harness), harness.skillRendering.installName(skill.frontmatter.name), 'SKILL.md'); | ||
| const config = await prettier.resolveConfig(path); | ||
| const body = await prettier.format(render(skill, harness), { ...config, filepath: path }); |
There was a problem hiding this comment.
The new partial was adding unneeded newlines, which prettier rightly removes.
| @@ -0,0 +1,3 @@ | |||
| ## Running this from Codex | |||
There was a problem hiding this comment.
Many skills will need this, so introducing the idea of a partial
|
|
||
| All files are committed. `bun run skills:check` (wired into `just verify` and the lint CI job) regenerates in-memory and fails on byte-diff. | ||
|
|
||
| ## Templating |
There was a problem hiding this comment.
I figure we'll have this with other harnesses eventually
| function walkPartials(rootDir: string): LoadedPartial[] { | ||
| return walk(rootDir).map((absolutePath) => ({ | ||
| name: relative(rootDir, absolutePath).replace(/\.md$/, '').split(sep).join('/'), | ||
| source: readFileSync(absolutePath, 'utf8'), | ||
| })); | ||
| } | ||
|
|
||
| function walk(dir: string): string[] { | ||
| let entries; | ||
| try { | ||
| entries = readdirSync(dir, { withFileTypes: true }); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; | ||
| throw err; | ||
| } | ||
| return entries.flatMap((entry) => { | ||
| const full = join(dir, entry.name); | ||
| if (entry.isDirectory()) return walk(full); | ||
| if (entry.isFile() && entry.name.endsWith('.md')) return [full]; | ||
| return []; | ||
| }); | ||
| } |
There was a problem hiding this comment.
https://bun.com/reference/node/fs/readdir -- Bun/Node have a built in fn to recursively list files, wondering if we could use that vs rolling our own visitor/walk pattern here?
There was a problem hiding this comment.
Great callout! I wonder if we should have some agent skill that guides it to use more native bun solutions (like this, macros in your previous DB PR, etc.)
There was a problem hiding this comment.
Filed as #120 — we'll handle the agent rule there.
| function compileBody(env: HandlebarsEnv, skill: Skill, harness: HarnessDescriptor): string { | ||
| try { | ||
| return env.compile(skill.body, { noEscape: true })({ harness }); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| throw new Error(`failed to render skill '${skill.frontmatter.name}' for harness '${harness.id}': ${message}`, { | ||
| cause: err, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Should this return Result or did you prefer the throw style ?
There was a problem hiding this comment.
Sure we can neverthrow it. Do you think there's a way we can guide agents to do this? It's definitely not in their training data...
There was a problem hiding this comment.
Yea over in cb-bot, ContextBot realized we do this and wrote us a rule for it here: https://github.com/contextbridge/cb-bot/blob/91dc39e5d48316db36b657365fae0482c14c3373/.contextbridge/rules/neverthrow-error-handling.md -- so I suppose the easiest thing to do would be to copy that over into our Claude rules here.
There was a problem hiding this comment.
Interestingly, cb-bot did that here, too, but it's not being paid attention to. https://github.com/contextbridge/planbridge/blob/main/.contextbridge/rules/error-handling-neverthrow.md
I can't remember off the top of my head if we can edit these files? I don't think so.
There was a problem hiding this comment.
Ah interesting ... Yea currently .contextbridge/ files are assumed to be owned by cb-bot and not-editable.
This is a good usecase for a janitor agent that reviews the diff and fixes it w/ a fresh context window. We could either do that locally or via cb-bot on a schedule (i.e. violation fix)
Scripts were using console.* directly. Wire them through a minimal BaseContext (logger + telemetry-disabled) so diagnostics flow through pino like the rest of the project.
🤖 I have created a release *beep* *boop* --- ## [0.6.0](v0.5.0...v0.6.0) (2026-05-14) ### Features * add global feedback submit shortcut ([#116](#116)) ([21b14a1](21b14a1)) * allow configuring PlanBridge port ([#95](#95)) ([bf53148](bf53148)) * tell Codex to run contextbridge open outside the sandbox ([#115](#115)) ([6cff1e9](6cff1e9)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: contextbridge-pr-automation[bot] <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com>
Summary
contextbridge openlaunches a local browser, which Codex's default sandbox blocks —$planbridge-openprints the listening URL but the browser never opens. The fix is content the agent reads: the Codex SKILL.md needs to tell it to setsandbox_permissions: "require_escalated"(with ajustificationand optionally a reusableprefix_rule) when invoking the command.To deliver harness-specific content without leaking a hardcoded Codex appendix into the renderer, this PR makes SKILL.md bodies Handlebars templates with
{ harness }as the data context and a singleeqhelper, and introduces partials underpackages/skills/sources/_partials/<harnessId>/<topic>.md. Thecodex/sandbox-escalationpartial is the first consumer;open/SKILL.mdgains one templated block at the call site. Rendered outputs are formatted through Prettier (markdown parser) before being written, which collapses the extra blank line Handlebars leaves where a non-matching conditional sat — keeping per-harness diffs clean and letting theharnessIntegrations/*/skills/**/SKILL.mdexclusion drop from.prettierignore. Closes #110.Review focus
exec_commandparameters cited in the partial are verified against the Codex source —sandbox_permissions: "require_escalated",justification, andprefix_rule: ["contextbridge", "<subcommand>"]all come fromcodex-rs/core/src/tools/handlers/shell_spec.rs::create_approval_parameters.require_escalatedis the right mode for a browser launch (notwith_additional_permissions, which is for additive sandbox grants).causeso the missing-partial Handlebars error surfaces with context:failed to render skill 'open' for harness 'codex': The partial codex/<missing> could not be found.contextbridge openand a specificprefix_rule, so a future skill wrapping a different subcommand can pull the same partial in unchanged.targetsFor(viaprettier.resolveConfig(path)+prettier.format(body, { ...config, filepath: path })) so config inherits from the destination's location in the repo.skills:checkformats the same way, so byte-diff drift detection still works.Commits
e66197d— Handlebars templating +_partials/registry in@contextbridge/skills;codex/sandbox-escalationpartial;openSKILL.md uses it; docs updated.0bc46bc— Format rendered SKILL.md through Prettier intargetsFor; drop theharnessIntegrations/*/skills/**/SKILL.mdexclusion from.prettierignore(drift check still guards against hand-edits).1836cc9— Rewrite the codex sandbox-escalation partial to dropcontextbridge open-specific text so future skills with other commands can reuse it unchanged.56531a0— PR feedback: hoist the localtoErrorinrenderTargets.tsto the shared@contextbridge/shared/errorsimplementation; replace the manual recursivereaddirSyncwalk inhandlebars.tswithBun.Glob('**/*.md').scanSync().c679f6c— Inject a minimalBaseContext(logger + telemetry-disabled) into theskills:generateandskills:checkscripts so diagnostics flow through pino instead ofconsole.*.