Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e07c876
Guard OpenAI orphan tool outputs
ajonkisz Jun 21, 2026
eabb2c5
Repair orphan transcript tool results
ajonkisz Jun 21, 2026
e405e6d
Merge transcript orphan repair
ajonkisz Jun 21, 2026
94139eb
Merge OpenAI orphan guard
ajonkisz Jun 21, 2026
04e53e8
Add orphan tool result lifecycle E2E
ajonkisz Jun 21, 2026
8155222
Merge transcript orphan repair
ajonkisz Jun 21, 2026
682e6be
Merge OpenAI orphan guard
ajonkisz Jun 21, 2026
5496dcc
Merge orphan tool result E2E coverage
ajonkisz Jun 21, 2026
71e27ea
Secure OpenAI orphan guard extension mount
ajonkisz Jun 21, 2026
ad79dd1
Merge OpenAI guard mount security fix
ajonkisz Jun 21, 2026
adf2f94
Fix transcript sanitizer review gaps
ajonkisz Jun 21, 2026
bc24b81
Merge sanitizer review gap fixes
ajonkisz Jun 21, 2026
a03298e
Document orphan tool-result hardening
ajonkisz Jun 21, 2026
e39a454
Merge orphan tool result documentation
ajonkisz Jun 21, 2026
a275c30
Refresh orphan tool-result docs
ajonkisz Jun 21, 2026
b3e6895
Merge stale orphan hardening doc fixes
ajonkisz Jun 21, 2026
692b7dc
fix(sanitizer): clear retained tool-call ids at compaction boundary
ajonkisz Jun 22, 2026
033a4f7
Merge compaction boundary sanitizer fix
ajonkisz Jun 22, 2026
c02d377
fix(sanitizer): tolerant toolCall id + retained-range compaction reset
ajonkisz Jun 22, 2026
87edeec
Merge retained compaction range sanitizer fixes
ajonkisz Jun 22, 2026
3ed7959
Fix firstKept compaction boundary handling
ajonkisz Jun 22, 2026
71fae0c
Merge firstKept retained-boundary fix
ajonkisz Jun 22, 2026
87143db
Merge remote-tracking branch 'origin/master' into goal/fix-orphan-too…
ajonkisz Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/add-project-preflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,13 @@ The allowlist covers (paths are relative to `<rootPath>/.bobbit/`):
`state/sessions.json`, `state/projects.json`.
- TLS / DNS challenge state — `state/tls/`, `state/desec.json`.
- Restart caches — `state/tool-docs/`, `state/mcp-tool-docs/`.
- Per-session scratch — `state/preview/`, `state/tool-guard/`,
`state/mcp-extensions/`, `state/html-snapshots/`, `state/proposal-drafts/`,
`state/sessions/`, `state/session-prompts/`, `state/system-project/`, and
- Per-session scratch and generated extension state — `state/preview/`,
`state/preview-artifacts/`, `state/tool-guard/`, `state/provider-bridge/`,
`state/google-code-assist/`, `state/openai-orphan-tool-result/`,
`state/mcp-extensions/`, `state/html-snapshots/`, `state/gate-diagnostics/`,
`state/proposal-drafts/`, `state/pr-walkthrough/`, `state/sessions/`,
`state/session-prompts/`, `state/sandbox-agent-auth/`,
`state/system-project/`, `state/marketplace-cache/`, and
`state/model-name-*` files.

When `bobbit.gateway-owned` is `pass` (the common case — the gateway runs
Expand Down
11 changes: 9 additions & 2 deletions docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ The pill strip above the composer (`AgentInterface._renderPillStrip`, `_measureP
- Tool-call messages stay in streaming until the next message starts.
- See [docs/internals.md — Reducer ordering invariant](internals.md#reducer-ordering-invariant) for the single-sort-key contract.

## OpenAI/Codex session stuck on orphan tool results

- **Symptom**: an OpenAI Responses or Codex session fails every later turn after restore/compaction/abort because the request contains a `function_call_output` without a matching retained `function_call`.
- **Where to look**: restore-boundary repair in `transcript-sanitizer.ts`; request preflight in `openai-orphan-tool-result-extension.ts`; sandbox mount policy in `docker-args.ts`.
- **Key detail**: valid tool-call/result pairs stay byte-identical, but results following aborted/errored assistant tool-call rows, compaction boundaries, or missing calls are dropped/filtered with bounded count-only diagnostics.
- **Reference**: [Orphan tool-result hardening](orphan-tool-result-hardening.md).

## Blob stuck idle while streaming (zzz visible with stop button)

- **Symptom**: chat blob shows the desaturated idle sprite with floating `zzz` while the agent is actively streaming (stop button visible, tool calls running). Stays wrong until the next `isStreaming` transition. Most reproducible by sending a new message immediately after the previous turn ends.
Expand Down Expand Up @@ -1308,5 +1315,5 @@ Pinned by `tests/error-modal-call-sites.test.ts` (enumerates every modal call si
- **Symptom**: sending a prompt with only an image/attachment and no typed text fails with `Validation error: the text field in the ContentBlock at messages … is blank.` The session then stays broken — even retries that include text re-fail with the same error.
- **Cause**: the model API rejects a user message whose `ContentBlock` has a blank `text` field (next to an image, or standalone). The blank block was committed to the agent's `.jsonl` transcript, so every later turn replayed it. See [docs/image-attachment-only-prompts.md](image-attachment-only-prompts.md) for the full design.
- **Source-prevention fix**: `synthesizeAttachmentText` (`rpc-bridge.ts`) substitutes the synthetic body `ATTACHMENT_ONLY_TEXT` (`"Attachments:"`) when text is blank/whitespace AND an image/attachment is present. Applied at the dispatch boundary in `SessionManager.enqueuePrompt` so direct/queued/recovery/retry paths all inherit valid text; backstopped in `RpcBridge.prompt` for the image case.
- **Recovery fix (already-broken sessions)**: `isBlankContentBlockError` detects the poison; the transcript sanitizer (`transcript-sanitizer.ts`) rewrites blank-text user messages to `"Attachments:"` at the rehydration boundary (it never touches `tool_result` user messages and hardens the write path against symlink/traversal); `_recoverBlankTextPoison` respawns the live agent so it rehydrates from the clean transcript.
- **Pinning tests**: `tests/synthesize-attachment-text.test.ts`, `tests/image-only-prompt-dispatch.test.ts`, `tests/image-only-prompt-unstick-recovery.test.ts`, `tests/transcript-sanitizer.test.ts`.
- **Recovery fix (already-broken sessions)**: `isBlankContentBlockError` detects the poison; the transcript sanitizer (`transcript-sanitizer.ts`) rewrites blank-text user messages to `"Attachments:"` at the rehydration boundary, preserves valid matched `tool_result` user messages, and separately drops/filters orphan tool results with bounded repair-count diagnostics; `_recoverBlankTextPoison` respawns the live agent so it rehydrates from the clean transcript. See [docs/orphan-tool-result-hardening.md](orphan-tool-result-hardening.md) for the orphan repair contract.
- **Pinning tests**: `tests/synthesize-attachment-text.test.ts`, `tests/image-only-prompt-dispatch.test.ts`, `tests/image-only-prompt-unstick-recovery.test.ts`, `tests/transcript-sanitizer.test.ts`, `tests/openai-orphan-tool-result-guard.test.ts`.
19 changes: 13 additions & 6 deletions docs/image-attachment-only-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,18 @@ prior turn was poisoned by a blank block specifically (vs. any other API error).
leading text block is inserted), and non-string/non-array content. Every other
line is left **byte-identical**, including trailing-newline shape, so
re-running is a no-op.
- **Tool results are never touched.** Tool results are also persisted as
`role:"user"` messages with a `tool_result` / `toolResult` block and no text —
that is valid history. Rewriting them to `"Attachments:"` would corrupt
tool-call history and break tool-result ordering, so any user message
carrying a tool-result block is left byte-identical.
- **Valid matched tool results are never rewritten as attachment text.** Tool
results are also persisted as `role:"user"` messages with a `tool_result` /
`toolResult` block and no text — that is valid history when the retained
transcript still contains the matching assistant tool call. Rewriting a valid
matched tool result to `"Attachments:"` would corrupt tool-call history and
break tool-result ordering, so those rows stay byte-identical.
- **Orphan tool results are repaired before blank-text rewriting.** The same
sanitizer now drops message-level tool-result rows and filters user-message
result blocks whose tool-call id is not present in retained valid assistant
history. If filtering leaves ordinary blank user content, the attachment-only
rewrite still applies. See [orphan-tool-result-hardening.md](orphan-tool-result-hardening.md)
for the orphan repair contract and diagnostics.

`sanitizeAgentTranscriptFile(...)` wraps the pure function with I/O, called at
the **rehydration boundary** in `session-setup.ts` — just before the
Expand Down Expand Up @@ -165,7 +172,7 @@ invalid content.
| `synthesizeAttachmentText` rule (blank/whitespace/text/no-attachment) | `tests/synthesize-attachment-text.test.ts` |
| Dispatch boundary: empty/whitespace text + image → non-blank dispatch; stuck-session retry preserves image | `tests/image-only-prompt-dispatch.test.ts` |
| Stuck-session recovery via respawn + sanitized rehydrate; legacy non-image case; non-poison errors unaffected | `tests/image-only-prompt-unstick-recovery.test.ts` |
| Sanitizer correctness, idempotency, tool-result protection, path-safety guards | `tests/transcript-sanitizer.test.ts` |
| Sanitizer correctness, idempotency, valid matched tool-result preservation, orphan tool-result repair, path-safety guards | `tests/transcript-sanitizer.test.ts` |

## Related

Expand Down
2 changes: 1 addition & 1 deletion docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -2286,7 +2286,7 @@ The existing session restore path (on gateway restart) also benefits from worktr

### Security summary

- Container sees `/workspace`, `/workspace-wt/`, `/agent-modules` (ro), `/tools` (ro), `/bobbit-state/{sessions,tool-guard,html-snapshots}/` (selective mounts - the host gateway token, TLS keys, and other sensitive state files are not mounted), and `/bobbit/preview` (per-session bind-mount of `<stateDir>/preview/<sid>/` - or `/bobbit/preview-root` for the per-project shared parent; see [`docs/preview-architecture.md`](preview-architecture.md))
- Container sees `/workspace`, `/workspace-wt/`, `/agent-modules` (ro), `/tools` (ro), `/bobbit-state/{sessions,tool-guard,html-snapshots}/`, read-only `/bobbit-state/{google-code-assist,openai-orphan-tool-result}/` generated-extension mounts (selective mounts - the host gateway token, TLS keys, and other sensitive state files are not mounted), and `/bobbit/preview` (per-session bind-mount of `<stateDir>/preview/<sid>/` - or `/bobbit/preview-root` for the per-project shared parent; see [`docs/preview-architecture.md`](preview-architecture.md))
- Runs as `node` user (uid=1000), no Docker socket
- Mount paths validated against blocklist (`/proc`, `/sys`, `/.ssh`, `/.aws`, etc.)
- Credential keys sanitized (`^[A-Za-z_][A-Za-z0-9_]*$`)
Expand Down
107 changes: 107 additions & 0 deletions docs/orphan-tool-result-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Orphan tool-result hardening

Bobbit persists agent transcripts as `.jsonl` and later rehydrates them into provider requests. A valid tool result is only valid when the retained context also contains the assistant tool call that produced it. If compaction, abort, or an errored turn leaves a `toolResult` / `tool_result` / OpenAI `function_call_output` without that matching call, OpenAI Responses and Codex can reject every later request and wedge the session.

This note documents the two-layer repair path that prevents that failure while preserving normal tool history byte-for-byte.

## Where the fix sits

There are two defensive boundaries:

1. **Restore-boundary transcript sanitizer** — `src/server/agent/transcript-sanitizer.ts` repairs persisted agent `.jsonl` before restore / `switch_session` rehydrates the transcript.
2. **OpenAI Responses request guard** — `src/server/agent/openai-orphan-tool-result-extension.ts` writes a generated pi extension that runs immediately before a provider request and filters orphan OpenAI `function_call_output` items from `payload.input`.

The sanitizer fixes durable history. The OpenAI guard is a final provider-specific preflight for anything still orphaned at request construction time. Together they cover restored sessions, force-abort respawns, compaction boundaries, and late tool results from discarded turns.

## Restore-boundary sanitizer behavior

`sanitizeTranscriptContent()` walks the transcript once from oldest to newest and keeps a set of retained, valid tool-call ids.

It adds assistant tool-call ids only from assistant rows that are not terminally bad:

- `stopReason: "aborted"` is ignored.
- `stopReason: "error"` is ignored.
- Non-aborted / non-errored assistant rows contribute tool-call ids from the supported shapes (`toolCall`, `tool_use`, and pi-shaped `toolCallId`).

Then it repairs result shapes against that retained set:

- Message-level `role: "toolResult"` rows are kept only when `toolCallId` is in the retained set; otherwise the whole row is dropped.
- User-message content blocks with `type: "tool_result"` or `type: "toolResult"` are filtered block-by-block using `tool_use_id`, `toolCallId`, or `id`.
- If filtering removes every content block from a user row, the row is dropped.
- If filtering leaves non-tool content, the existing blank-user-message sanitizer still runs so image/attachment-only prompts are repaired in the same pass.

Compaction uses the exact retained range when Pi provides one. If an inline `type: "compaction"` entry has a resolvable `firstKeptEntryId`, the sanitizer resolves that id against parsed JSONL entry ids and drops only tracked tool-call ids whose originating assistant line is before that first-kept entry. Tool calls at or after the first-kept entry remain valid even when they appear before the inline marker. If `firstKeptEntryId` is absent or cannot be resolved, the sanitizer uses the legacy marker fallback and clears tracked tool-call ids at the compaction marker, so later result rows cannot match calls from the discarded pre-marker history.

## Valid-pair preservation and idempotence

Valid tool-call/tool-result pairs are deliberately not normalized. When an assistant tool call is retained and its result follows it, the sanitizer leaves both rows byte-identical, including blank text that is part of a valid tool-result user message.

The sanitizer is idempotent:

- A clean transcript returns `changed: false` and unchanged content.
- A repaired transcript becomes clean after one pass.
- Re-running on repaired output is a no-op.
- Non-JSON lines, non-message records, and unrelated message rows are preserved.

This matters because the restore path can run more than once across restart, refresh, and respawn flows; repeated restores must not keep rewriting history.

## Aborted and errored assistant tool calls

An aborted or errored assistant row may still contain a tool-call-looking block. Bobbit does not treat those ids as valid producers because the provider turn did not complete normally. Any late result for those ids is dropped on restore.

This handles the common stuck-session shape:

1. Assistant starts a tool call.
2. The turn is aborted or errors.
3. A late tool result is appended after the assistant row.
4. Restore would otherwise rehydrate a result whose producing call is not valid retained history.

The assistant row itself is preserved for UI/user history; only the invalid result side is removed.

## OpenAI Responses / Codex request guard

OpenAI Responses represents tool calls as `function_call` items and tool results as `function_call_output` items in `payload.input`. The generated guard extension registers pi's `before_provider_request` hook and performs a payload-local scan:

- Track `function_call.call_id` values already seen in `payload.input`.
- Keep a `function_call_output` only if it has a string `call_id` already seen in that same input array.
- Drop outputs with missing/non-string ids or outputs that appear before their call.
- Preserve all other items, valid pairs, and ordering.
- No-op for non-object payloads, payloads without array `input`, and non-Responses-shaped requests.

The extension is added during normal session setup and mirrored in restore / role-reassignment / force-abort respawn paths, so OpenAI and Codex sessions keep the same preflight guard across lifecycle transitions.

## Bounded diagnostics

Repairs are not silent, but logs are bounded and do not include raw tool output.

- Transcript sanitizer success logs include counts such as dropped orphan tool-result rows and filtered orphan result blocks.
- Transcript sanitizer read/write/path failures log warnings and remain non-fatal, so a defensive repair failure does not prevent session restore.
- The OpenAI guard logs only the number of dropped `function_call_output` items with the `[bobbit-openai-orphan-guard]` prefix.

This gives operators enough signal to spot transcript repair without leaking large or sensitive tool-result payloads into logs.

## Dedicated read-only sandbox mount for the OpenAI guard

The OpenAI guard is generated source loaded by sandboxed agents via `--extension`. It is content-addressed under:

```text
.bobbit/state/openai-orphan-tool-result/<hash>/extension.ts
```

and exposed inside sandbox containers as:

```text
/bobbit-state/openai-orphan-tool-result/<hash>/extension.ts
```

It intentionally does **not** live under the existing `tool-guard` state directory. `tool-guard` must stay writable for other session-scoped guard material, while this provider guard is always loaded and its generated source can be reused by later sessions. If a compromised sandbox could write to that reused extension source, it could tamper with future provider requests.

For that reason `docker-args.ts` mounts generated-extension state subdirectories, including `openai-orphan-tool-result`, read-only (`:ro`). Bobbit still avoids mounting the full state directory, which contains secrets such as gateway tokens and TLS material. The writer also revalidates cached file contents before reuse as defense-in-depth.

## Regression coverage

The behavior is pinned by focused unit coverage:

- `tests/transcript-sanitizer.test.ts` — valid pair preservation, idempotence, aborted/errored assistant tool-call handling, compaction-boundary orphan result dropping, and mixed user-message result-block filtering.
- `tests/openai-orphan-tool-result-guard.test.ts` — OpenAI payload guard behavior, bounded diagnostics, generated extension source, and dedicated state subdir placement.
- `tests/docker-args.test.ts` and `tests/container-path-translation.test.ts` — sandbox mount creation, read-only generated-extension mounts, and container/host path translation.
1 change: 1 addition & 0 deletions src/server/agent/bobbit-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const GATEWAY_OWNED_FILES: readonly string[] = [
"state/tool-guard/", // src/server/agent/tool-activation.ts
"state/provider-bridge/", // src/server/agent/provider-bridge-extension.ts
"state/google-code-assist/", // src/server/agent/google-code-assist-provider-extension.ts
"state/openai-orphan-tool-result/", // src/server/agent/openai-orphan-tool-result-extension.ts
"state/mcp-extensions/", // src/server/agent/tool-activation.ts, rpc-bridge.ts
"state/html-snapshots/", // server.ts
"state/gate-diagnostics/", // src/server/agent/gate-diagnostics-cleanup.ts
Expand Down
29 changes: 12 additions & 17 deletions src/server/agent/docker-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,30 +205,25 @@ export function buildDockerRunArgs(config: DockerRunConfig): string[] {
// Bind mount ONLY specific state subdirectories — never the full state dir,
// which contains the host gateway token, TLS keys, sessions.json, etc.
//
// `google-code-assist` holds the generated pi-coding-agent provider
// extension (`<hash>/provider.ts`, written by
// google-code-assist-provider-extension.ts) that registers the
// `google-code-assist` API provider inside the spawned agent. Sandboxed
// sessions load it via `--extension`, whose host path remapArgsForContainer
// rewrites to `/bobbit-state/google-code-assist/...`; that container path
// only resolves if the subdir is bind-mounted here. The dir contains only
// generated extension source (no secrets — the runtime token is fetched
// per-request from the gateway), so mounting it is safe.
// `google-code-assist` and `openai-orphan-tool-result` hold generated
// pi-coding-agent extensions loaded by sandboxed sessions via `--extension`.
// Host paths are remapped to `/bobbit-state/<subdir>/...` by
// remapArgsForContainer, so those container paths only resolve if the subdirs
// are bind-mounted here. They contain generated extension source only — no
// secrets are baked into either file.
//
// `google-code-assist` is mounted READ-ONLY (`:ro`): the sandboxed agent
// only ever *loads* the generated `provider.ts` via `--extension`, never
// writes it. A writable mount would let a compromised sandbox tamper with
// the generated extension source, which is then reused (content-addressed,
// shared across sessions) for a subsequent run — a sandbox-escape vector.
// The `:ro` flag closes that hole at the kernel mount level; the gateway
// also revalidates cached contents before reuse as defense-in-depth
// (see google-code-assist-provider-extension.ts).
// Both generated-extension subdirs are mounted READ-ONLY (`:ro`): sandboxed
// agents only ever *load* them. A writable mount would let a compromised
// sandbox tamper with content-addressed source reused by later sessions.
// The gateway also revalidates cached contents before reuse as
// defense-in-depth (see the corresponding extension writer modules).
if (stateDir) {
const sandboxStateDirs: Array<{ sub: string; readOnly?: boolean }> = [
{ sub: "sessions" },
{ sub: "tool-guard" },
{ sub: "html-snapshots" },
{ sub: "google-code-assist", readOnly: true },
{ sub: "openai-orphan-tool-result", readOnly: true },
];
for (const { sub, readOnly } of sandboxStateDirs) {
const hostPath = path.join(stateDir, sub);
Expand Down
Loading