Commit Graph

2724 Commits

Author SHA1 Message Date
tvskart 5b0ec394bb fix: extra args field mangles comma-separated values during typing (#2125)
### Thinking Path

- Paperclip manages AI agent configuration via `AgentConfigForm`
- Agents can be given extra CLI args via a comma-separated input field
- `DraftInput` with `immediate` mode commits on every keystroke
- The `parseCommaArgs` → `formatArgList` round-trip resets the draft via
`useEffect`, corrupting mid-edit input
- Removing `immediate` defers parsing to blur, fixing the corruption
- This matches how all other transformed fields in the form already
behave

### What changed

Removed the `immediate` prop from the Extra args `DraftInput` so the
value is only parsed on blur instead of every keystroke.

### Note

Upon further investigation, the bug only affects **typing** into the
field character by character — the round-trip reformats on each
keystroke and corrupts the value. **Copy-pasting** works fine on master
since it's a single input event. Still worth fixing since users who type
args manually (or edit an existing value) will hit the corruption.

### Test plan

- [ ] Go to any agent's Configuration tab
- [ ] **Type** comma-separated args character by character in the "Extra
args" field (e.g. `--verbose, --foo=bar`)
- [ ] Click elsewhere to blur, then Save
- [ ] Verify via API that `adapterConfig.extraArgs` is a properly split
array (not a mangled single string)

🤖 Generated with [Claude Code](https://claude.com/claude-code)


### Before
trying to type in a comma -> will not appear
copy paste into element -> i guess this works out

![dunder-extra-args-before](https://github.com/user-attachments/assets/1a1b37d3-ce25-4998-972e-c98d3d382a33)

### After
typing works and copy paste works as well

![dunder-extra-args-after](https://github.com/user-attachments/assets/1aad368d-e5d8-4c37-9ab3-8f72470bdfbc)

Co-authored-by: tvskart <tvskart@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-17 20:57:05 -05:00
Alex b18669452f Add Novita sandbox provider plugin (#7595)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip already separates agent adapters from execution
environments, so agents can run locally, over SSH, or through sandbox
providers.
> - Sandbox provider plugins let Paperclip add new cloud runtimes
without changing each agent adapter.
> - Novita Agent Sandbox is a cloud runtime for AI agent workloads with
isolated filesystems, command execution, templates, timeout controls,
and pause/resume behavior.
> - Paperclip currently has sandbox provider examples for Daytona and
Cloudflare, but not Novita.
> - This pull request adds a Novita sandbox provider plugin using the
existing provider-plugin lifecycle.
> - The benefit is that Paperclip users can run existing adapters such
as Codex, Claude, Gemini, OpenCode, Cursor, or ACPX inside Novita Agent
Sandbox environments.

## Linked Issues or Issue Description

Fixes #7596

## What Changed

- Added `packages/plugins/sandbox-providers/novita` as a standalone
sandbox provider plugin package.
- Registered provider key `novita` with `kind: "sandbox_provider"` and
`environment.drivers.register` capability.
- Implemented Novita environment lifecycle hooks: validate config,
probe, acquire lease, resume lease, release lease, destroy lease,
realize workspace, and execute commands.
- Added config support for `apiKey`, `domain`, `template`,
`requestedCwd`, `timeoutMs`, `requestTimeoutMs`, `secure`, `autoPause`,
and `reuseLease`.
- Added README documentation for setup, configuration, and lifecycle
behavior.
- Added tests for manifest shape, config parsing, safe shell command
wrapping, stdin delimiter safety, and env-key validation.

## Verification

From `packages/plugins/sandbox-providers/novita`:

- `pnpm typecheck`
- `pnpm test`

The tests avoid live Novita API calls and cover the provider's static
contract and command-wrapping behavior. Live end-to-end verification
requires a Paperclip instance with the plugin installed and a Novita API
key configured as either a Paperclip secret or `NOVITA_API_KEY` in the
worker environment.

## Risks

- This adds a new direct dependency on the Novita Sandbox JS SDK
(`novita-sandbox`). Socket/Snyk should review the package as part of
normal dependency checks.
- The implementation relies on Novita SDK command execution semantics;
live provider behavior should be verified with a real Novita sandbox
before marking the plugin production-ready.
- `reuseLease` maps Paperclip release behavior to Novita `betaPause()`.
If pause is unavailable for a selected template, the plugin falls back
to best-effort kill during release.
- Low migration risk for existing users because this is a new standalone
provider plugin and does not change existing adapters or built-in
providers.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via Codex CLI, with repository file access, shell
command execution, GitHub CLI/API usage, and local TypeScript/Vitest
verification. Web and local documentation context were used for Novita
Sandbox SDK/API behavior.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-17 18:34:12 -07:00
Devin Foley f3e01c63bd fix(environments): partial unique index to dedup managed sandbox rows (#8247)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The `server/services/environments.ts` module lazily provisions a
managed Kubernetes sandbox environment for each company on first
heartbeat. Idempotency relies on `ensureKubernetesEnvironment` returning
the single managed row per company.
> - The `(company_id, driver)` unique index in the `environments` schema
is partial on `driver='local'` only, so two concurrent callers (e.g.
simultaneous first heartbeats from a freshly synced tenant) can both
insert a `driver='sandbox'` row before either sees the other.
> - The function tried to converge after the race by re-reading, picking
the oldest managed row as winner, and deleting the loser. Under
autocommit + read-committed, each post-insert SELECT is a fresh snapshot
— A may not see B's row, B may not see A's, so both pick their own and
neither deletes. Two rows survive.
> - The same race fired in CI as `expected 2 to be 1` at
`environment-service.test.ts:293`, gating multiple unrelated PRs on
retries.
> - This pull request encodes the operator-level invariant ("at most one
Paperclip-managed sandbox row per company") at the DB layer with a
partial unique index, then switches `ensureKubernetesEnvironment` to
`INSERT … ON CONFLICT DO NOTHING` keyed on that index. Losers re-read
the surviving row.
> - The benefit is the race is impossible by construction — no
application-side convergence loop, no test flake, and any future
`ensureXyzSandboxEnvironment` that sets `managedByPaperclip=true`
inherits the invariant for free.

## Linked Issues or Issue Description

Paperclip issue: PAPA-783 — implement managed-sandbox dedup fix (phase 2
of the approved plan on the parent flake-investigation issue).

This is the phase-2 fix for the flaky `environmentService > deduplicates
concurrent managed Kubernetes environment creation` test introduced by
`4ad94d0bd` (PR #7938). Failing CI runs since then on at least PRs
#7595, #8233, #8215, #8212. The plan was reviewed and approved on the
parent issue before implementation.

Closely related (not duplicates):
- PR #7938 — introduced the test and the in-process convergence loop
being replaced here.
- PR #7595, PR #8233, PR #8215, PR #8212 — downstream PRs affected by
the flake; one of them will be rebased onto this fix as the acceptance
gate.

## What Changed

- `packages/db/src/schema/environments.ts`: added
`environments_company_managed_sandbox_idx`, a partial unique index on
`(company_id) WHERE driver='sandbox' AND
(metadata->>'managedByPaperclip')::boolean = true`. The umbrella
`managedByPaperclip` predicate covers any current or future
Paperclip-managed sandbox flavor without needing a new index per
provider.
- `packages/db/src/migrations/0102_managed_sandbox_dedup_index.sql`:
one-shot dedup `DELETE` keeping the oldest managed-sandbox row per
`company_id` (scoped to `driver='sandbox' AND managedByPaperclip=true`),
`RAISE NOTICE` if any duplicates were removed, then `CREATE UNIQUE INDEX
IF NOT EXISTS environments_company_managed_sandbox_idx`. `CONCURRENTLY`
is omitted because the codebase's migration runner wraps each file in a
transaction (see `applyPendingMigrationsManually`); the table holds 1–3
rows per company, so the short lock is acceptable and consistent with
every other migration in the repo.
- `server/src/services/environments.ts`: `ensureKubernetesEnvironment`
now uses `INSERT … ON CONFLICT DO NOTHING` keyed on the new index. On
conflict it re-reads the surviving managed-sandbox row and returns it.
Drops the post-insert convergence (re-read by `createdAt ASC, id ASC`,
delete the loser) and the trailing comment that flagged "until a partial
unique index is added via migration" as the proper long-term fix.
- Unused `asc` import removed from
`server/src/services/environments.ts`.

## Verification

Local (matches the success criteria in the issue body):

```
$ cd server
$ passes=0; for i in $(seq 1 20); do
    pnpm vitest run src/__tests__/environment-service.test.ts -t "deduplicates concurrent" \
      && passes=$((passes+1)) || break
  done; echo "$passes/20"
20/20

$ passes=0; for i in $(seq 1 10); do
    pnpm vitest run src/__tests__/environment-service.test.ts \
      && passes=$((passes+1)) || break
  done; echo "$passes/10"
10/10
```

Adversarial fan-out stress (temporarily bumped `Array.from({ length: 8
}, …)` to `length: 32` on both dedup tests; reverted before commit):

```
$ # both dedup tests fan-out-of-32, 10 iterations
10/10
```

Two ensure paths exist in the parent plan, but only
`ensureKubernetesEnvironment` is on `master`.
`ensureManagedSandboxEnvironment` (referenced by the approved plan as
commit `dce9a9622`) lives only on an unmerged feature branch, not
master. The plan's helper-extraction and symmetric dedup test for that
path are deferred to whichever PR lands the second ensure path — it
inherits the same DB invariant by setting `managedByPaperclip=true`.
Discrepancy was flagged on the issue thread before implementation.

Typecheck:

```
$ pnpm -C server typecheck
ok
```

## Risks

Low risk.

- **Migration safety.** `IF NOT EXISTS` on the index makes the migration
idempotent. The dedup `DELETE` is bounded to rows matching the
managed-sandbox predicate; in production this should be a no-op (no race
has been reported in the wild — only in CI). On dev/CI DBs that already
accumulated duplicates, the migration emits a `NOTICE` reporting the
count.
- **No `CONCURRENTLY`.** The migration runner wraps each `.sql` file in
a transaction, which is incompatible with `CREATE INDEX CONCURRENTLY`.
The `environments` table holds 1–3 rows per company and the row count is
bounded by company count; the short ACCESS EXCLUSIVE during `CREATE
UNIQUE INDEX` is acceptable here and matches every other index migration
in the repo.
- **Predicate scope.** The partial index predicate matches exactly the
rows that `ensureKubernetesEnvironment` writes (`driver='sandbox'` with
`metadata.managedByPaperclip=true`). Tenant-created sandbox envs (via
`svc.create`) do not set this marker and are not covered — no false
positives, no surprise constraint violations on unrelated inserts.

## Model Used

Claude (Anthropic), `claude-opus-4-7`. Tool use: code edit + bash +
filesystem search; no extended-thinking mode.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-17 16:09:45 -07:00
Dotta e59eb1080d [codex] Hide conference room experimental toggle (#8237)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Instance experimental settings are where operators opt into unstable
product surfaces before they become defaults.
> - Conference Room Chat is still experimental and is intended to come
back later, but users should not be able to enable it right now.
> - Removing the setting outright would risk losing the surrounding
implementation and stored flag behavior that future work will need.
> - This pull request hides the user-facing opt-in toggle behind a
temporary local gate while leaving Conference Room functionality and
flag plumbing intact.
> - The benefit is that operators can no longer enable the feature from
Instance Experimental Settings, while the team can restore the control
later with a small, explicit change.

## Linked Issues or Issue Description

Paperclip issue: PAP-11233 — comment out the conference-room
experimental setting _just for now_.

Feature request template:

**Subsystem affected**

ui/ — React + Vite board UI

**Problem or motivation**

Conference Room Chat is still present in the codebase and is expected to
return later, but the Instance Experimental Settings page currently
exposes a user-facing toggle that lets operators enable it. For now,
that opt-in path should be removed without deleting the underlying
feature code, stored flag type, or runtime gates.

**Proposed solution**

Hide the Conference Room Chat experimental setting card from Instance
Experimental Settings behind a temporary local gate set to `false`. Keep
the existing JSX, mutation payload, shared flag type, and downstream
Conference Room behavior intact so the setting can be restored
intentionally later.

**Alternatives considered**

- Delete the Conference Room setting and related flag plumbing: rejected
because the issue explicitly says not to remove Conference Room
functionality.
- Force-reset `enableConferenceRoomChat` at the API/service layer:
rejected because this task is about removing the ability for users to
enable it from settings, not changing existing stored instance state.

**Roadmap alignment**

This is a narrow product-polish and release-control change, not new
roadmap-level core feature work. `ROADMAP.md` was checked; it mentions
CEO Chat as future direction, but this PR only hides a temporary
experimental opt-in for the existing Conference Room surface.

**Additional context**

No GitHub issue exists for PAP-11233. This PR is opened from the
Paperclip internal task at the requester’s direction.

## What Changed

- Added a temporary `SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING = false`
gate around the Conference Room Chat experimental setting card.
- Left the existing Conference Room Chat setting JSX, toggle mutation,
stored flag type, and runtime gates intact.
- Updated the focused settings-page test to assert that the Conference
Room Chat setting and toggle are not rendered, even if the stored flag
is currently enabled.

## Verification

- `pnpm vitest run ui/src/pages/InstanceExperimentalSettings.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- PR checks are green for `cd2c54fb087541006eac4fd91ab5ac978d85ba5b`,
including `verify`, e2e, build, general tests, serialized server suites,
commitperclip review, Greptile, Socket, Snyk, CodeRabbit, and
security-review.

Screenshots: not included; this change removes a settings-row control
and is covered by the focused DOM test above.

## Risks

Low risk. The change only hides the user-facing opt-in card. Existing
installations with `enableConferenceRoomChat` already set are not
force-reset by this PR, and the underlying Conference Room
implementation remains in place for later re-enabling work.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent based on GPT-5, with terminal, git, and GitHub
tool use. Context window details were not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-06-17 13:00:32 -05:00
Dotta 6a684a5053 fix(adapters): pass Hermes custom providers as args (#8231)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters translate Paperclip configuration into the
CLI/runtime flags needed by each provider.
> - Hermes custom providers are represented as `custom:*` values, but
the adapter registry was not passing that provider value through as
Hermes CLI arguments.
> - The fix is isolated to adapter argument construction and its
regression tests.
> - This pull request extracts only the Hermes custom-provider
pass-through fix onto `origin/master`.
> - The benefit is a small adapter PR that can merge independently from
UI, skills catalog, and migration work.

## Linked Issues or Issue Description

No GitHub issue exists for this branch split. Internal source task:
[PAP-11234](/PAP/issues/PAP-11234).

Problem/motivation:
- Hermes `custom:*` providers need to reach the Hermes process as
`--provider <value>`.
- The adapter should preserve existing auth injection and avoid adding
duplicate provider flags when the user already supplied one.

Proposed solution:
- Detect Hermes `custom:*` provider values in adapter registry argument
construction.
- Add `--provider <custom value>` unless an explicit provider arg
already exists.
- Cover both spaced and equals-style existing provider args in
regression tests.

Related PR search:
- Found related Hermes adapter PRs such as #7544 and #3027, but none
duplicates this specific custom-provider arg pass-through behavior.

Roadmap alignment:
- Checked `ROADMAP.md`; no duplicate planned item was found for this
adapter fix.

## What Changed

- Updated Hermes adapter registration argument construction to pass
`custom:*` providers through `extraArgs` as `--provider <value>`.
- Preserved existing auth injection behavior.
- Avoided duplicate provider arguments when `--provider value` or
`--provider=value` is already present.
- Added adapter registry regression coverage.

## Verification

- `CI=true NODE_ENV=development pnpm install --frozen-lockfile
--prefer-offline`
- `pnpm exec vitest server/src/__tests__/adapter-registry.test.ts --run`
— 1 file, 20 tests passed.

## Risks

- Low risk; change is scoped to Hermes adapter argument construction.
- Review should confirm the provider flag syntax matches current Hermes
CLI expectations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via Paperclip `codex_local` / CodexCoder, GPT-5-class
coding model with tool use and shell execution. Exact runtime snapshot
and context-window setting were not exposed by the Paperclip run
context.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details available from the run context)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A; no UI change)
- [x] I have updated relevant documentation to reflect my changes (N/A;
no docs behavior changed)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending on draft PR)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending follow-up loop)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-17 11:21:33 -05:00
br-creative 1b89a8e3b8 fix: remove VOLUME keyword for Railway compatibility (#2619)
Railway bans VOLUME in Dockerfiles — persistent storage is handled via
Railway volumes instead.

## Thinking Path

<!--
Required. Trace your reasoning from the top of the project down to this
  specific change. Start with what Paperclip is, then narrow through the
  subsystem, the problem, and why this PR exists. Use blockquote style.
  Aim for 5–8 steps. See CONTRIBUTING.md for full examples.
-->

> - Paperclip orchestrates AI agents for zero-human companies
> - [Which subsystem or capability is involved]
> - [What problem or gap exists]
> - [Why it needs to be addressed]
> - This pull request ...
> - The benefit is ...

## What Changed

<!-- Bullet list of concrete changes. One bullet per logical unit. -->

-

## Verification

<!--
  How can a reviewer confirm this works? Include test commands, manual
  steps, or both. For UI changes, include before/after screenshots.
-->

-

## Risks

<!--
  What could go wrong? Mention migration safety, breaking changes,
  behavioral shifts, or "Low risk" if genuinely minor.
-->

-

## Model Used

<!--
  Required. Specify which AI model was used to produce or assist with
  this change. Be as descriptive as possible — include:
    • Provider and model name (e.g., Claude, GPT, Gemini, Codex)
• Exact model ID or version (e.g., claude-opus-4-6,
gpt-4-turbo-2024-04-09)
    • Context window size if relevant (e.g., 1M context)
• Reasoning/thinking mode if applicable (e.g., extended thinking,
chain-of-thought)
• Any other relevant capability details (e.g., tool use, code execution)
  If no AI model was used, write "None — human-authored".
-->

-

## Checklist

- [ ] I have included a thinking path that traces from project context
to this change
- [ ] I have specified the model used (with version and capability
details)
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-15 14:33:53 -05:00
Devin Foley 412a04c964 fix(ui): keep desktop shell pinned when scrollIntoView walks past body (#8071)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The desktop app shell uses a fixed-height `h-dvh` flex column with
`<body>` pinned to `overflow: hidden` so the page itself doesn't scroll
— only `#main-content` does
> - On long threads, submitting a comment fires `scrollIntoView({ block:
"start" })` on the new optimistic message, which by spec walks every
ancestor scroll container and adjusts each
> - With `<body>` (and historically the two shell `<div>`s) marked
`overflow: hidden`, the browser treats them as scroll containers per the
CSS spec; `scrollIntoView` reaches them and scrolls them, visually
shifting the entire shell — sidebar and header included — off the top of
the viewport
> - `overflow: clip` is visually identical to `hidden` but is explicitly
*not* a scroll container, so converting the shell divs and body to
`clip` removes them from `scrollIntoView`'s ancestor walk
> - Chrome additionally drives `document.scrollingElement.scrollTop` via
its internal C++ smooth-scroll algorithm, which bypasses both the JS
`scrollTop` setter and the CSS `overflow` on the root element — so even
with the root viewport clipped, `<html>` still scrolls
> - This PR layers the CSS/JS overflow-clip changes with a capture-phase
`scroll` listener that snaps `documentElement.scrollTop` and
`body.scrollTop` back to 0 whenever they drift, defeating the
root-viewport scroll the browser performs natively

## Linked Issues or Issue Description

Refs #8041 (`fix(ui): don't window-scroll the desktop shell on comment
submit`) — that PR gated the JS-initiated `window.scrollBy(...)` path in
`restoreComposerViewportSnapshot`. The bug this PR fixes is a separate,
browser-internal scroll path triggered by `scrollIntoView`'s ancestor
walk, which the #8041 gate doesn't reach (no JS scroll call to gate).
This PR is complementary, not a revert.

**Bug description (no existing tracked issue):** After submitting a
comment on a long thread in the desktop app, the entire app shell —
sidebar, top nav, everything — visually shifts upward by ~300px, putting
items above "Tasks" in the sidebar off-screen. A soft refresh (Cmd+R)
doesn't fix it; only a hard URL navigation does. Verified the bug
reproduces only on threads long enough that `#main-content.scrollHeight
> clientHeight` by a wide margin.

**Repro:** Open a long-thread issue → submit a one-word comment →
observe sidebar items above the active selection fall off the top of the
viewport.

## What Changed

- `ui/src/components/Layout.tsx`:
- Static desktop-shell `<div>`s: `overflow-hidden` → `overflow-clip` on
both the outer flex column and inner flex row (lines 459, 470 in the
original numbering). Removes them from the scroll-container set.
- JS-applied body overflow: `document.body.style.overflow = isMobile ?
"visible" : "hidden"` → `... : "clip"`. Same rationale, applied to
`<body>`.
- New `useEffect` that registers a capture-phase `scroll` listener on
`window`. On every scroll event, if `documentElement.scrollTop` or
`body.scrollTop` has drifted from 0, it gets snapped back. Gated on
`!isMobile` so the mobile shell (which uses `min-h-dvh` and
intentionally scrolls window) is unaffected.

## Verification

**Reviewer steps:**

1. Open a long-thread issue in the desktop UI (anything where
`#main-content.scrollHeight > clientHeight` substantially — most active
issues qualify).
2. Submit a one-word comment.
3. Confirm the sidebar stays put — items above the active item remain
visible, top nav stays at the top of the viewport.
4. Repeat several times; confirm the shell never drifts upward.

**Tests:** Existing tests for `issue-chat-scroll.ts` and
`IssueChatThread.tsx` still pass (59/59 in
`ui/src/lib/issue-chat-scroll.test.ts` +
`ui/src/components/IssueChatThread.test.tsx`).

**Bisect proof:** During development I confirmed by selectively
reverting the capture-phase scroll listener that the bug returns
immediately when only the listener is removed (with the two
`overflow-clip` changes still in place) — proving the listener is the
decisive fix for the browser-internal root-viewport scroll path. The
probe trace showed `documentElement.scrollTop` animating from 0 →
307.5px over ~400ms during the smooth `scrollIntoView` even with `html {
overflow: clip }` applied in computed style.

## Risks

Low.

- The two `overflow-hidden` → `overflow-clip` swaps are visually
identical and have well-defined browser support (Chrome 90+, Firefox
81+, Safari 16+). Paperclip's desktop targets are well within those
ranges.
- The capture-phase scroll listener fires only when
`documentElement.scrollTop !== 0` or `body.scrollTop !== 0`. On a
correctly-behaving page neither should ever be non-zero on desktop, so
the listener is effectively a no-op for everything except the bug it's
reverting. It's gated behind `!isMobile`.
- Mobile uses `min-h-dvh` (no overflow set) and intentionally scrolls
window for the standard mobile chrome behavior; the listener is gated
off there so mobile UX is unaffected.

## Model Used

Claude Opus 4.7 (`claude-opus-4-7`), 200k context, extended thinking
enabled. Tool use: file edits, bash for git/test execution. Diagnosis
was driven by an instrumented in-browser probe (built and HMR'd into
`Layout.tsx` during development, removed before this PR) that traced
every `scrollBy`/`scrollTo`/`scrollIntoView` call and the
`Element.prototype.scrollTop` setter; the decisive trace identified the
browser-internal scroll path as the unguarded root cause.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable — no new tests; the
bug is a browser-internal scroll behavior that's not exercisable in
jsdom (which doesn't implement the smooth-scroll animation that drives
the bug). Existing snapshot/restore tests in `issue-chat-scroll.test.ts`
cover the JS-initiated paths.
- [ ] If this change affects the UI, I have included before/after
screenshots — see the description text above; the bug manifests as the
entire shell translating up by ~300px on long-thread comment submit
- [x] I have updated relevant documentation to reflect my changes — N/A,
behavior change is non-user-facing CSS/event-handling
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — will confirm on this PR
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
will iterate
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-06-12 17:31:16 -07:00
Jannes Stubbemann cd1b4f275d feat(ui): default to system prefers-color-scheme for first-time visitors (supersedes part of #3732) (#5873)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The UI ships a dark/light theme toggle and persists the user's
explicit choice in `localStorage`
> - For first-time visitors with no stored choice, the pre-React
bootstrap script in `ui/index.html` hardcoded `"dark"` and never
consulted the OS preference
> - As a result, users on a light-themed OS were forced into dark mode
until they clicked the toggle once — a first-impression friction with no
compensating benefit
> - This pull request makes the bootstrap respect `prefers-color-scheme`
for first-time visitors and adds a `matchMedia` listener so the in-app
theme auto-follows OS changes until the user makes an explicit choice
> - The benefit is a friction-free first visit that matches every other
modern web app, with zero impact on users who have already chosen a
theme

## Linked Issues or Issue Description

No existing issue covers this directly — problem described in-PR (bug
shape):

- For first-time visitors with no stored theme choice, the pre-React
bootstrap script in `ui/index.html` hardcoded `"dark"` and never
consulted the OS `prefers-color-scheme` preference.
- Users on a light-themed OS were forced into dark mode until they
clicked the toggle once — first-impression friction with no compensating
benefit.
- This PR supersedes part of PR #3732 (the `prefers-color-scheme`
bootstrap slice); no standalone issue was filed for it.

## What Changed

- **`ui/index.html`** — the pre-React bootstrap script now computes a
`prefersDark` fallback via `matchMedia("(prefers-color-scheme: dark)")`,
guarded by a `typeof window.matchMedia === "function"` feature-detection
check, in place of the hardcoded `"dark"` default. A stored
`localStorage` value still takes precedence.
- **`ui/src/context/ThemeContext.tsx`** — tracks an `hasExplicitChoice`
flag. While `false`, a `MediaQueryList` `change` listener keeps the
in-app theme in sync with OS theme switches. Once `setTheme` /
`toggleTheme` runs, the choice is persisted and the listener is removed.

## Verification

- `pnpm --filter @paperclipai/ui run typecheck` — clean.
- `npx vitest run src/components/SidebarAccountMenu.test.tsx` — 1/1 pass
(the one test that exercises `ThemeContext`).
- Manual: launched the Vite UI dev server with a mocked
`/api/auth/get-session` 401, navigated to `/auth` with no
`localStorage.theme` set, and toggled the browser's emulated
`prefers-color-scheme` between `dark` and `light`. Bootstrap renders the
matching theme without a flash. Screenshots committed.

**Screenshots — first-visit (no stored theme) with system pref:**

| System dark | System light |
| --- | --- |
<img width="1280" height="800" alt="first-visit-prefers-dark"
src="https://github.com/user-attachments/assets/c30274d5-a2e0-4ed8-b7ef-b96b8caac5ca"
/>
<img width="1280" height="800" alt="first-visit-prefers-light"
src="https://github.com/user-attachments/assets/ebd8e9cf-0be1-4b36-a6f8-eb38b00dff5c"
/>


(Without this PR, both screenshots would have rendered dark.)

## Risks

Low. Purely additive:

- A stored `localStorage` value still takes precedence over the OS
preference, so users who have already picked a theme are unaffected.
- SSR-safe: both new code paths are gated on `typeof window !==
"undefined"` (the inline script only runs in the browser, and the React
`matchMedia` listener is attached inside `useEffect`).
- No new dependencies, no API surface change.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
- [x] Tests run locally and pass
- [x] No new test cases — the change is observable only via real
`matchMedia` events, which jsdom does not faithfully implement; manual
verification above
- [x] UI change — before/after screenshots in
`docs/pr-screenshots/pr-5873/`
- [x] No documentation updates required
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Supersedes part of #3732 (the `prefers-color-scheme` bootstrap slice).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-12 14:03:54 -07:00
Devin Foley 4c26b984a7 fix(routines): detect variables when underscores are markdown-escaped (#8056)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Routines let users templatize titles and instructions with
`{{name}}` placeholders that get prompted for at run time
> - A user reported that `{{pr_url}}` typed into a routine description
was never detected as a variable, while camelCase placeholders worked
> - Root cause: the routine description is edited via MDXEditor (WYSIWYG
markdown). On save its serializer (`mdast-util-to-markdown`) defensively
escapes intraword underscores, so a user-typed `{{pr_url}}` is stored as
`{{pr\_url}}`. The variable matcher only accepted `[A-Za-z0-9_]` inside
the placeholder, so the backslash broke the match and the variable was
silently dropped
> - This PR widens the matcher to tolerate `\_` and unescapes it back to
`_` on capture, in both extraction and interpolation, so a single name
is detected and resolved regardless of whether the source markdown was
hand-typed or round-tripped through a WYSIWYG editor
> - The benefit is that snake_case placeholders behave the same in the
routine UI and the executor, removing a silent failure mode

## Linked Issues or Issue Description

Refs PAPA-771 — "Fix example for variable_name". Initially scoped as a
docs-copy fix (change the example to camelCase), but investigation
showed the underlying bug was that the parser could not see the variable
when it was authored as `{{pr_url}}` in a WYSIWYG-edited description.
This PR addresses the bug directly so snake_case names work as users
expect. (Supersedes the now-closed PR #8054 — same commit, fresh branch
so the diff is visible.)

## What Changed

- `packages/shared/src/routine-variables.ts`:
- Widened the `ROUTINE_VARIABLE_MATCHER` regex to accept `\_` inside
placeholder names (in addition to `[A-Za-z0-9_]`)
- Added `unescapeRoutineVariableName` and applied it on capture in both
`extractRoutineVariableNames` and `interpolateRoutineTemplate`, so the
looked-up variable name is normalized regardless of the markdown escape
- `packages/shared/src/routine-variables.test.ts`:
- Added tests covering plain snake_case (`{{pr_url}}`), markdown-escaped
(`{{pr\_url}}`), multi-underscore (`{{pr\_url\_v2}}`),
sync-with-template, and interpolation

## Verification

- `pnpm --filter @paperclipai/shared exec vitest run routine-variables`
→ 13 passed (4 new), confirming both plain and escaped underscore
placeholders are detected and interpolated as the same variable name
- Reproduced the root cause out-of-band with `mdast-util-from-markdown`
+ `mdast-util-to-markdown` to confirm the editor's serializer is the
source of the `\_` escape

## Risks

- Low. Change is confined to `packages/shared/src/routine-variables.ts`;
the regex is strictly more permissive in a tightly bounded way (only
`\_` is newly allowed) and the captured name is normalized.
`isValidRoutineVariableName` is unchanged, so stored names are still
strict identifiers.

## Model Used

- Claude (Anthropic), `claude-opus-4-7` (Opus 4.7), via Claude Code

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above — only my own closed #8054 supersedes/duplicates this one.
Open PRs touching `routine-variables.ts` (#7184, #7186, #6993, #7187)
are all for unrelated concerns (server-side PATCH persistence, GH#6525)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass — `routine-variables` suite
passes (13/13)
- [x] I have added or updated tests where applicable — 4 new tests
covering snake_case + escaped forms
- [ ] If this change affects the UI, I have included before/after
screenshots — no UI change in this PR
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — pending CI re-run after the
push for the Greptile blank-line nit
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
initial review was 5/5; the one P2 (missing blank line) is addressed in
the latest push
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-12 13:02:12 -07:00
Jannes Stubbemann 8ddd735a7a feat(ui): theme toggle on unauthenticated auth page (supersedes part of #3732) (#5874)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Operators sign in via the `/auth` page, which renders before any
session exists
> - The signed-in app has a theme toggle inside `SidebarAccountMenu`,
but the signed-out `/auth` page has none — first-time visitors are stuck
in whichever theme was hardcoded at boot
> - Master's existing toggle was inline inside `SidebarAccountMenu.tsx`
as a `MenuAction` row, not exported as a reusable widget; round-1 of
this PR added a standalone `ThemeToggle` but punted on unifying the two
surfaces
> - This pull request makes `ThemeToggle` the canonical theme widget
(one source of truth for label, icon, and toggle behaviour), used both
as a compact icon button on `/auth` and as a full-width menu row in
`SidebarAccountMenu`
> - The benefit is a working pre-auth theme switch and zero risk of the
two call sites drifting out of sync as the theme model evolves

## Linked Issues or Issue Description

No existing issue covers this directly — problem described in-PR
(feature-gap shape):

- The signed-in app has a theme toggle inside `SidebarAccountMenu`, but
the signed-out `/auth` page has none — first-time visitors are stuck in
whichever theme was hardcoded at boot.
- The existing toggle lived inline in `SidebarAccountMenu.tsx` as a
`MenuAction` row, not exported as a reusable widget, so the two surfaces
could drift apart as the theme model evolves.
- This PR supersedes part of PR #3732 (the auth-page toggle slice); no
standalone issue was filed for it.

Duplicate-PR search: related open theme PRs #2769 and #4666 add in-app
three-state system-theme toggles — different surface from this PR
(unauthenticated auth page); sibling PR in this series: #5873.

## What Changed

- **`ui/src/components/ThemeToggle.tsx`** — accepts `variant: "icon" |
"menu-action"` (default `"icon"`) and an `onAfterToggle` callback. Both
variants share `useTheme` and the same label/icon derivation. The
`menu-action` variant matches the existing `MenuAction` row styling.
- **`ui/src/components/SidebarAccountMenu.tsx`** — drops its inline
`useTheme()` + `MenuAction`-for-the-theme-row in favor of `<ThemeToggle
variant="menu-action" onAfterToggle={() => setOpen(false)} />`. Sun/Moon
icon imports and theme state move with it.
- **`ui/src/pages/Auth.tsx`** — unchanged from round 0; renders
`<ThemeToggle />` at top-right of the `/auth` page (already using the
default `icon` variant).
- **`ui/src/components/ThemeToggle.test.tsx`** (new) — covers both
variants, the `onAfterToggle` callback, and the label/icon flip across
themes.
- **`ui/src/components/SidebarAccountMenu.test.tsx`** — unchanged; its
`ThemeContext` mock still works because `ThemeToggle` uses the same
hook.

## Verification

- `pnpm --filter @paperclipai/ui run typecheck` — clean.
- `npx vitest run src/components/ThemeToggle.test.tsx
src/components/SidebarAccountMenu.test.tsx` — 5 passed (4 new + 1
existing).
- Manual: launched `pnpm dev` for the UI, mocked `/api/auth/get-session`
401, navigated to `/auth` — toggle is visible top-right, click flips
light/dark. Screenshots committed.

## Risks

Low. The change is structural — both call sites render the same widget
that already worked on each surface independently.
`SidebarAccountMenu`'s popover behaviour is preserved via
`onAfterToggle`, and `ThemeContext` is untouched.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
- [x] Tests run locally and pass
- [x] Added tests for the new ThemeToggle component (both variants)
- [x] UI change — before/after screenshots in
`docs/pr-screenshots/pr-5874/`
- [x] No documentation updates required (purely internal refactor + new
component)
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Supersedes part of #3732.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-12 12:59:14 -07:00
Jannes Stubbemann 362c30ccdc feat(server): opt-in OpenTelemetry auto-instrumentation (#3735)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Production self-hosters increasingly expect telemetry out of the box
— Jaeger, Tempo, Honeycomb, Datadog, Grafana Cloud, Dynatrace all speak
OTLP
> - Today there is no OpenTelemetry bootstrap in the server, so
operators who want traces have to patch their fork or run a sidecar that
captures only HTTP-level info
> - An opt-in bootstrap that costs nothing when disabled is the
minimum-viable surface for this audience
> - The OpenTelemetry packages are heavyweight enough that we don't want
them in the default dependency graph — they should load only when the
operator configures an OTLP endpoint
> - This pull request adds a self-contained
`server/src/instrumentation.ts` that dynamically imports the OTel SDK
and starts it when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, and is a
complete no-op otherwise

## Linked Issues or Issue Description

No existing issue covers this directly — feature-gap description
following the feature-request template:

**Problem or motivation**

Production self-hosters increasingly expect telemetry out of the box —
Jaeger, Tempo, Honeycomb, Datadog, Grafana Cloud, Dynatrace all speak
OTLP — but the server has no OpenTelemetry bootstrap. Operators who want
traces today must patch their fork or run a sidecar that captures only
HTTP-level information.

**Proposed solution**

An opt-in OTel bootstrap gated on `OTEL_EXPORTER_OTLP_ENDPOINT`, loaded
via dynamic `import()` only when configured, so the heavyweight OTel
packages stay out of the default dependency graph.

**Alternatives considered**

Related open PRs found during the duplicate-PR search approach
observability differently: #4894 adds OTLP instrumentation to Paperclip
core unconditionally, and #3752 proposes an observability plugin. Not
duplicates — different layering: this PR keeps the default install
dependency-free via opt-in dynamic import.

## What Changed

- New `server/src/instrumentation.ts` — opt-in OpenTelemetry
auto-instrumentation. Gated on `OTEL_EXPORTER_OTLP_ENDPOINT`. Respects
the standard OTel env vars (`OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`,
`OTEL_EXPORTER_OTLP_ENDPOINT`). Skips the fs/dns/net
auto-instrumentations (too chatty). `sdk.start()` is wrapped in
try/catch so a bad endpoint or missing native bindings doesn't crash the
server. `process.once("SIGTERM" / "SIGINT", …)` for clean shutdown on
the first signal only. OTel packages are loaded via dynamic `import()`
so they are true optional runtime dependencies — no entries in
`package.json`, no lockfile churn.
- `server/src/index.ts` — import `./instrumentation.js` as the very
first statement so auto-instrumentation can patch `http` / `express` /
`pg` before they are evaluated by downstream modules.

## Verification

- `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 pnpm start` after
`pnpm install
@opentelemetry/{sdk-node,auto-instrumentations-node,exporter-trace-otlp-grpc,resources,semantic-conventions}`
in `server/` — traces show up in the configured collector; HTTP,
Express, and Postgres spans are populated.
- `OTEL_EXPORTER_OTLP_ENDPOINT` unset — server starts with no
OTel-shaped output in logs, no behavior change.
- `OTEL_EXPORTER_OTLP_ENDPOINT=…` set but packages not installed —
single `console.warn` at startup telling the operator which packages to
install.

## Risks

Low. No behavior change unless the env var is set. The bootstrap never
throws into the caller; every failure path ends in `console.warn` /
`console.error` and falls through to non-traced operation.

## Model Used

Claude Opus 4.6 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Tests run locally and pass
- [x] CI green
- [x] Greptile review addressed

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:44:22 -07:00
Jannes Stubbemann 937fe62d10 feat(server): TRUST_PROXY supports CIDR list + named subnets (supersedes #3729) (#5872)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Express looks at incoming `X-Forwarded-For` headers only when
`app.set("trust proxy", …)` says it should, and uses that resolved
client IP downstream for rate-limiting, audit logging, and any
auth/abuse signal that ties back to source IP
> - The original PR #3729 added `TRUST_PROXY` accepting only `"true"` or
a positive integer, which forces operators to pick between two unsafe
defaults: hop-count (brittle if topology changes) or boolean-true (any
client can spoof `X-Forwarded-For` and bypass rate-limits or pollute
audit logs)
> - `trust proxy: true` is one of the most common Express
misconfigurations and trivially exploitable for IP-spoofing-based
rate-limit bypass; the safest config — trust only the LB's actual CIDR
or only loopback — was unreachable with the previous parser
> - This pull request replaces the parser with full Express 5 support —
unset / `false` / `0` (Express default), positive integer hop count,
comma-separated CIDR list, named subnets (`loopback`, `linklocal`,
`uniquelocal`) — and emits a startup error naming the offending token on
invalid input
> - The benefit is that operators can now trust *only* their actual
ingress and close the spoofing window without leaking client-IP
integrity to downstream layers, while preserving every
previously-working config as a strict superset

## Linked Issues or Issue Description

Refs #1690 — login returns 500 behind a reverse proxy because Express
`trust proxy` is not enabled; this PR ships the configuration surface
(`TRUST_PROXY` with CIDR lists and named subnets) that lets operators
enable it safely. It does not change the default, so #1690 still
requires the operator to set `TRUST_PROXY` — hence Refs, not Fixes.

No other existing issue covers this directly — remaining problem
described in-PR:

- The original `TRUST_PROXY` parser (PR #3729, which this PR supersedes)
accepted only `"true"` or a hop count, forcing operators to choose
between brittle hop-counting and the spoofable `trust proxy: true`.
- `trust proxy: true` lets any client spoof `X-Forwarded-For` and bypass
rate limits or pollute audit logs; the safest config — trusting only the
LB's actual CIDR or only loopback — was unreachable with the previous
parser.

Duplicate-PR search: #1854 / #1714 are earlier minimal trust-proxy
enablement PRs; this PR supersedes #3729 and generalizes beyond a
boolean enable (CIDR lists + named subnets).

## What Changed

- **`server/src/middleware/trust-proxy.ts`** — new helper exposing
`parseTrustProxyEnv` (testable) and `applyTrustProxy(app)` (one-call
boot wiring). Surface:
- Unset / `""` / `false` / `0` → no `app.set("trust proxy", …)` (Express
default: trust nothing).
- `true` → `app.set("trust proxy", true)`. Documented as unsafe in
untrusted-LB deployments.
- Positive integer (e.g. `"2"`) → hop count. Strict parse: rejects
`"01"`, leading/trailing whitespace.
- Comma-separated list of CIDRs and/or named subnets (e.g.
`"loopback,uniquelocal,10.0.0.0/8,fd00::/8"`) → array passed to
`app.set("trust proxy", [...])`.
  - Anything else → startup error naming the offending token.
- **`server/src/app.ts`** — one import + one call to
`applyTrustProxy(app)`.
- **`server/src/__tests__/trust-proxy.test.ts`** — 12 cases: unset,
`"true"`, `"0"`, `"2"`, `"01"` rejected, `" 2 "` rejected, `"loopback"`,
`"loopback,uniquelocal"`, `"10.0.0.0/8"`, `"10.0.0.0/8,fd00::/8"`,
`"bogus"` rejected (error names the bad token), mixed-list with one bad
token rejected (error names the offending token specifically).

## Verification

- `pnpm --filter @paperclipai/server run typecheck` — clean.
- `npx vitest run trust-proxy` — 12/12 pass.

## Risks

- **No new required env vars.** Unset means default Express behavior
(trust nothing). Pure superset of #3729's surface — anything that worked
under #3729 still works here.
- **Strict parse.** `"01"` and `" 2 "` are rejected on purpose so
configuration mistakes surface at startup, not as silently-degraded
auth/rate-limit behavior. The error message names the offending token.
- **No runtime cost** — the parse runs once at boot. The downstream
`trust proxy` setting is internal to Express.
- Single-tenant local-first deploys unaffected by default.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
- [x] Tests run locally and pass (`trust-proxy` 12/12)
- [x] Added boundary cases (leading-zero, whitespace, unknown token,
mixed-list-with-bad-token)
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Closes #3729.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:37:55 -07:00
Jannes Stubbemann 3701be76fa fix: read-only agent config/skill endpoints should not require agents:create (#3725)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Access to agents, skills, and configurations is governed by a
per-company permission system
> - `agents:create` is a mutation-tier permission that controls who can
create or modify agents
> - `assertCanReadConfigurations` delegates to
`assertCanCreateAgentsForCompany`, effectively requiring `agents:create`
just to *read* agent configs, skills, and config revisions
> - That's a permission regression: any company member without
`agents:create` hits 403 on the Skills tab, agent config pages, and
revision history — but those responses are already secret-redacted
> - This pull request loosens the read gate to company membership only,
while keeping every mutation-adjacent gate at `agents:create`

## Linked Issues or Issue Description

No existing issue covers this directly — problem described in-PR
following the bug-report template:

**What happened**

`assertCanReadConfigurations` delegates to
`assertCanCreateAgentsForCompany`, effectively requiring the
mutation-tier `agents:create` permission just to *read* agent configs,
skills, and config revisions. Any company member without `agents:create`
hits 403 on the Skills tab, agent config pages, and revision history —
even though those responses are already secret-redacted
(`redactAgentConfiguration`, `redactConfigRevision`).

**Expected behavior**

Read-only configuration/skill/revision endpoints are readable by any
company member; only mutation-adjacent endpoints require
`agents:create`.

**Steps to reproduce**

As a company member without `agents:create`, open the Skills tab or an
agent config page (or `GET` the config/skill/revision endpoints) — the
request fails with 403.

## What Changed

- `server/src/routes/agents.ts`:
- `assertCanReadConfigurations` now requires company membership only
(plus the existing agent-key cross-company check). Previously it
required `agents:create`.
- `actorCanReadConfigurationsForCompany` (the boolean twin, used by `GET
/agents/:id` to decide whether to return a restricted detail) now uses
the standard try/catch-around-`assertCompanyAccess` pattern.
- `POST /companies/:companyId/adapters/:type/test-environment` is not a
pure read (it exercises adapter secrets) and now calls
`assertCanCreateAgentsForCompany` directly instead of going through
`assertCanReadConfigurations`. Behavior for this endpoint is unchanged.

## Verification

- Existing tests pass.
- Manual: log in as a company member without an `agents:create` grant.
Visit the Skills tab on an agent and the agent configuration panel —
both load. Try to edit the agent — blocked, as before.
- Manual: POST to
`/companies/:companyId/adapters/:type/test-environment` as the same user
— still 403.

## Risks

Low. The only behavior change is on read endpoints whose responses were
already redacted (\`redactAgentConfiguration\`,
\`redactConfigRevision\`). No secret escapes anywhere.

## Model Used

Claude Opus 4.6 (1M context), extended thinking mode.

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Tests run locally and pass
- [x] CI green
- [x] Greptile review addressed

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:36:49 -07:00
Jannes Stubbemann c21f70ef1c fix: skip gosu when already running as target user (#2908)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The reference container image must be deployable on both Docker
Compose (where it starts as root and `gosu`'s a `USER_UID`/`USER_GID`
switch) and Kubernetes (where the pod is typically constrained by
PodSecurity)
> - The Kubernetes operator (paperclipinc/paperclip-operator#45) sets
`runAsNonRoot: true`, `runAsUser: 1000`, `allowPrivilegeEscalation:
false`, and `drop: ALL` capabilities by default — the unconditional
`usermod` + `gosu` flow in the entrypoint requires root + `CAP_SETUID` /
`CAP_SETGID`, making the image undeployable on any cluster enforcing
baseline or restricted PodSecurity
> - Without root, neither the user remap nor `gosu` can ever succeed —
so the fix is a runtime branch: non-root starts `exec` the command
directly (warning if the runtime UID/GID differs from the requested
one), while root starts keep the existing `usermod`+`gosu` flow
> - This also covers platforms that assign arbitrary UIDs (OpenShift
restricted SCC), which previously crashed with a cryptic `usermod:
Permission denied`
> - The benefit is one image that works for both deployment shapes with
no operator-side workaround — pure superset, no breaking change

## Linked Issues or Issue Description

Refs paperclipinc/paperclip-operator#45 (cross-repo) — the operator's
default pod security context (`runAsNonRoot: true`, `runAsUser: 1000`,
`allowPrivilegeEscalation: false`, `drop: ALL`) is blocked by this
entrypoint behavior. The operator shipped a stopgap
(paperclipinc/paperclip-operator#46 lets the CRD override the security
context); this PR is the image-side fix that makes the secure defaults
work out of the box. Supersedes #2904 (v1 of this branch).

No in-repo issue covers this directly — problem described in-PR
following the bug-report template:

**What happened**

The entrypoint unconditionally runs `usermod`/`groupmod`/`chown` + `exec
gosu node`, which requires root plus `CAP_SETUID` / `CAP_SETGID` — any
non-root start crashes (`gosu` cannot drop privileges; a mismatched UID
dies earlier at `usermod: Permission denied`), making the reference
image undeployable on clusters enforcing baseline or restricted
PodSecurity.

**Expected behavior**

A non-root container `exec`s the command directly (with a clear warning
if its UID/GID differs from the requested `USER_UID`/`USER_GID`, since a
remap is impossible without root). The existing root + `usermod` +
`gosu` flow is preserved for Docker Compose, where the container starts
as root and switches to the requested UID/GID.

**Deployment mode**

Kubernetes with baseline/restricted PodSecurity and OpenShift-style
arbitrary-UID platforms (failing cases); Docker Compose root-start (must
keep working).

## What Changed

- **`scripts/docker-entrypoint.sh`** — branch on the runtime UID:
- **Non-root start** → `exec "$@"` directly. If the runtime UID/GID
differs from `USER_UID`/`USER_GID`, print a one-line warning to stderr
first (the remap is impossible without root; the warning keeps
volume-permission mismatches diagnosable instead of failing cryptically
inside `usermod`).
- **Root start** → unchanged: `usermod`/`groupmod` remap when requested,
`chown` of `/paperclip` when a remap happened, then `exec gosu node
"$@"`.

## Verification

**Automated:** `server/src/__tests__/docker-entrypoint.test.ts` runs the
real entrypoint with `id`/`usermod`/`groupmod`/`chown`/`gosu` stubbed
via PATH and asserts all five privilege branches (root+defaults,
root+remap, non-root match, arbitrary non-root UID, GID mismatch) — runs
in the regular server suite, no Docker needed.

**Manual (Docker):** ran the entrypoint on `node:lts-trixie-slim` (the
image's actual base) across the full matrix, with `gosu` stubbed to a
marker:

- [x] Root start, defaults → no remap, `gosu node` invoked (Docker
Compose flow unchanged)
- [x] Root start, `USER_UID=1001`/`USER_GID=1001` → `Updating node
UID/GID to 1001` + `gosu node` (remap flow unchanged)
- [x] Non-root `--user 1000:1000` (the operator's `runAsUser: 1000`
shape) → silent direct exec, command runs as 1000:1000
- [x] Non-root `--user 1234:1234` (arbitrary UID) → warning `running
unprivileged as 1234:1234; cannot remap to requested 1000:1000`, then
direct exec (previously: crash)
- [x] Non-root `--user 1000:1001` (GID mismatch) → warning, then direct
exec
- [x] Baseline check: master's entrypoint fails for any non-root start
(gosu/usermod require root)

End-to-end cluster verification under restricted PodSecurity exercises
the same branch as the `--user 1000:1000` case above; the operator
repo's deploy is the natural place for that smoke test once this ships
in an image tag.

## Risks

- **Backward-compatible.** Docker Compose / root-entrypoint path is
byte-for-byte the same flow — `usermod`/`gosu` runs whenever the
container starts as root.
- **Behavior change only for previously-broken starts.** Non-root
containers used to crash; they now run. The only observable difference
for a *working* deployment is none.
- **Mismatched non-root UID/GID warns instead of failing.** Deliberate:
the remap is impossible without root, and arbitrary-UID platforms
(OpenShift) rely on group-writable volumes; a hard fail would keep them
broken. The stderr warning preserves diagnosability.
- **No new env vars, no API surface.** Pure entrypoint behavior change
gated on the runtime UID.
- **Restricted PodSecurity ready.** The non-root branch needs no Linux
capabilities — works under `drop: ALL`.

## Model Used

Claude Opus 4.6; rebase, non-root generalization, and verification
matrix by Claude Fable 5 (1M context).

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
(agent-runtime sandbox images use `tini`, no gosu — unaffected)
- [x] Tests run locally and pass (new `docker-entrypoint.test.ts`
covering all five privilege branches, plus a manual Docker matrix on the
real base image; see Verification)
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Unblocks the default (non-overridden) security context of
paperclipinc/paperclip-operator#45 / #46.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-12 10:33:52 -07:00
Jannes Stubbemann 05bcd3ce84 feat(security): plugin tables get company_id FK for tenant isolation (#5865)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin subsystem persists state into four tables
(`plugin_entities`, `plugin_job_runs`, `plugin_logs`,
`plugin_webhook_deliveries`) and those rows currently have no notion of
an owning tenant — so company-deletion doesn't cascade plugin state, and
operators have no way to query "what does this plugin own for company
X?"
> - The fix is one thin slice of tenant-isolation hygiene that doesn't
change any external API: add a nullable `company_id` FK with `ON DELETE
CASCADE` to the four plugin tables, index it, and scope the
`plugin_entities` external-id uniqueness per-tenant
> - The benefit is plugin-row tenant attribution + cascade cleanup, with
zero impact on single-tenant local-first deploys (`NULL` continues to
mean instance-scope)

> **Rebase note (scope narrowed):** This PR originally also hardened the
schedulers (`heartbeat.tickTimers` / `resumeQueuedRuns` /
`enqueueWakeup` and `routines.tickScheduledTriggers`) to skip archived
companies. That half has since landed on `master` via #7478 (`93206f73`,
"Stop archived companies from waking agents") with a stricter
implementation (`status != 'active'` plus a skipped-request audit row).
On rebase those changes were dropped as redundant — `heartbeat.ts` and
`routines.ts` are now identical to `master`, and the scheduler-specific
tests were removed. **This PR is now DB-only.**

## Linked Issues or Issue Description

No existing issue covers this directly — problem described in-PR:

- Four plugin tables (`plugin_entities`, `plugin_job_runs`,
`plugin_logs`, `plugin_webhook_deliveries`) persist rows with no notion
of an owning tenant.
- Company deletion therefore does not cascade plugin state, and
operators have no way to query "what does this plugin own for company
X?"
- One thin slice of tenant-isolation hygiene fixes this without changing
any external API: a nullable `company_id` FK with `ON DELETE CASCADE`,
an index, and per-tenant scoping of the `plugin_entities` external-id
uniqueness.
- Part of the multi-tenant hardening initiative alongside #3967
(cross-tenant 404 oracle) and #5864 (per-company JWT keys).

## What Changed

**Schema (`packages/db/src/schema/plugin_*.ts`):**
- Nullable `companyId` FK with `onDelete: "cascade"` added to
`plugin_entities`, `plugin_job_runs`, `plugin_logs`,
`plugin_webhook_deliveries`.
- A btree index on each new `company_id` column (`<table>_company_idx`).
- `plugin_entities_external_idx` rescoped from `(plugin_id, entity_type,
external_id)` to `(company_id, plugin_id, entity_type, external_id)` and
switched to `UNIQUE … NULLS NOT DISTINCT` so instance-scope rows
(`company_id IS NULL`) keep their dedup guarantee while tenants get
their own namespace.

**Migration:**
- `0095_plugin_company_id_tenant_isolation.sql` — 14 statements: 4
column adds + 4 FK constraints (`ON DELETE CASCADE`) + 4 indexes +
drop/recreate of the external-id unique constraint.
- Journal entry + regenerated `0095_snapshot.json`.

**Tests:**
- `server/src/__tests__/plugin-tenant-isolation.test.ts` — `NULL`
preserves backward compat; `CASCADE` on company delete across all four
tables; per-tenant external-id namespacing; NULL-NULL collision rejected
(`NULLS NOT DISTINCT`).

## Verification

- `pnpm --filter @paperclipai/db run check:migrations` — pass.
- `pnpm --filter @paperclipai/db typecheck` (`tsc`) — pass.
- `vitest run plugin-tenant-isolation` — **4/4 pass** (embedded Postgres
applies `0095` and exercises cascade + NULLS NOT DISTINCT).

## Notes

- **Clean snapshot, no drift.** The earlier revision of this PR shipped
a ~17.6k-line meta snapshot that was almost entirely pre-existing drift.
On rebase the migration was renumbered (the old `0090_brainy_darkhawk`
collided with `master`'s `0090_resource_memberships … 0094`) and
regenerated from the current `master` baseline via `drizzle-kit
generate`. The result is a 14-statement migration containing **only**
the plugin-table changes — no unrelated drift.
- **Backward-compatible.** `NULL company_id` continues to mean
instance-scope (cron jobs, public webhooks). No new env vars, no API
surface change. Single-tenant local-first deploys unaffected.

## Risks

- **Migration is additive and nullable** — `0095` adds nullable
`company_id` columns, FK constraints, and indexes; existing rows stay
valid (`NULL` keeps meaning instance-scope) and no backfill is required.
- **`ON DELETE CASCADE` is a behavioral change**: deleting a company now
removes its plugin rows across all four tables. Intended (it is the
point of the PR), but operators relying on plugin rows surviving company
deletion would be affected. Covered by the cascade tests.
- **Uniqueness semantics change on `plugin_entities`**: the external-id
constraint is rescoped per-tenant and switched to `UNIQUE … NULLS NOT
DISTINCT`, so two instance-scope rows (`company_id IS NULL`) with the
same external id are now rejected instead of coexisting. Covered by the
NULL-NULL collision test.
- **No API surface change, no new env vars.** Single-tenant local-first
deploys unaffected.

(Section added retroactively to match the PR template; distilled from
the What Changed / Notes sections above.)

## Model Used

Same authoring setup as #5864 (same series, same day): Claude Opus 4.7
(1M context), extended thinking mode. (Section added retroactively.)

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Tests run locally and pass (plugin-tenant-isolation 4/4)
- [x] `check:migrations` + db typecheck pass
- [x] No UI changes
- [x] Migration carries only the intended changes (no snapshot drift)
- [x] Scheduler half dropped as superseded by #7478

Part of the multi-tenant hardening initiative — see also #3967
(cross-tenant 404 oracle) and #5864 (per-company JWT keys).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-12 10:17:19 -07:00
Aron Prins a5b3cc98b0 fix(server): cache Intl.DateTimeFormat per timezone in cron minute-stepper (#8033) (#8034)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The routine scheduler lets agents and users run work on cron
schedules; every 30s a tick computes each trigger's next occurrence
> - `computeNextRun`/`nextCronTickInTimeZone` find that occurrence by
stepping forward one minute at a time (capped at 2.6M iterations),
constructing a fresh `Intl.DateTimeFormat` on every step — ~1ms of ICU
work each
> - Sparse schedules (monthly ≈ 43k steps ≈ 40s) and never-matching
crons (the #7529 midnight bug → full 2.6M steps ≈ 45 min) block the Node
event loop for the whole scan, every tick
> - In production this pegs the server at 100% CPU, health checks time
out, and Paperclip Desktop repeatedly shows "the embedded server is no
longer responding" (diagnosed via a CPU sample: 74% of samples inside
`Builtin_DateTimeFormatConstructor`)
> - This pull request caches the formatter per timezone, since
`Intl.DateTimeFormat` instances are immutable and reusable
> - The benefit is each minute-step pays only `formatToParts` (~1µs): a
43k-step scan drops from ~40s of blocked event loop to under a second,
and the server stays responsive while routines are active

## Linked Issues or Issue Description

- Fixes: #8033
- Refs #7529 — a never-matching midnight cron forces the minute-stepper
through its full 2.6M-iteration cap, which is the worst-case trigger for
this perf bug; the two compound
- Refs #7922 — in-flight fix for #7529 touching the same function
(`getZonedMinuteParts`); the changes are compatible (this PR changes
formatter construction, that PR changes hour normalization)

## What Changed

- `server/src/services/routines.ts`: added a per-timezone
`Intl.DateTimeFormat` cache (`getZonedMinuteFormatter`) used by
`getZonedMinuteParts` and `assertTimeZone`, replacing per-call
construction
- Exported `nextCronTickInTimeZone` so the behavior is testable (same
export PR #7922 makes)
- Added `server/src/services/routines-formatter-cache.test.ts`: verifies
a sparse monthly cron resolves to the correct next occurrence across a
DST-bearing timezone, and asserts at most one formatter construction for
a ~43k-minute-step scan (and zero on a warm cache)

## Verification

- `npx vitest run server/src/services/routines-formatter-cache.test.ts`
— 2 tests pass
- `pnpm --filter @paperclipai/server exec tsc --noEmit` — clean
- Live validation: applied the same patch to the server bundled in
Paperclip Desktop 3.2.9, which was hitting 99.3% CPU with 5s
health-check timeouts within 60s of boot on a real workload; after the
patch, CPU idles at 0–3% across scheduler ticks and `/api/health`
answers in ~1ms (observed over multiple 30s ticks)

## Risks

- Low risk. `Intl.DateTimeFormat` instances are immutable and safe to
reuse; the cache key is the timezone string, and entries are small and
bounded by the number of distinct timezones in use
- Invalid timezones still throw in the constructor before anything is
cached, so `assertTimeZone` semantics are unchanged
- Does not change cron matching semantics; minute-stepping remains
(replacing it with cron-field arithmetic is noted in #8033 as a
follow-up)

## Model Used

- Claude (Anthropic), model ID `claude-fable-5` (Fable 5), via Claude
Code CLI with extended thinking and tool use (profiling, patching, and
live verification performed by the model under user supervision)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server-only change)
- [ ] I have updated relevant documentation to reflect my changes (N/A —
internal perf fix, no doc surface)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:15:53 -07:00
Doyeon Baek 4965dc834a fix(ui): don't window-scroll the desktop shell on comment submit (#7972) (#8041)
Fixes #7972.

## Thinking Path
- The reporter pinned the post-submit composer-viewport restore in
`ui/src/lib/issue-chat-scroll.ts`, which falls back to
`window.scrollBy(...)` when `#main-content` is not independently
scrollable — exactly the short-thread repro case.
- In the desktop shell the body is `overflow: hidden` (set in
`Layout.tsx`) inside a fixed-height `h-dvh` flex column, so a window
scroll never moves content: it translates the entire shell (sidebar
included) off-screen, and a plain reload does not restore it. On mobile
(`min-h-dvh`, `body { overflow: visible }`) and the auth-free perf
fixture the page genuinely scrolls, so the window IS the correct target
there.
- A prior attempt forced `resolveIssueChatScrollTarget` to always use
`#main-content`; that path is a no-op on a non-overflowing container and
is sensitive to a stale `ui/dist`/`.vite` cache, which likely masked the
result. Gating the window-scroll itself is the precise root-cause fix
and covers both restore call sites (`queueViewportRestore` and the
`[messages]` layout effect) since both route through one function.

## What Changed
- Added `isWindowScrollable(doc, win)` to `issue-chat-scroll.ts`: the
window is a valid scroll target only when the document body is not
clipped. It checks both the `overflow` shorthand and the `overflow-y`
longhand (some engines, incl. jsdom, do not derive the longhand from the
shorthand in computed style).
- Gated the `window.scrollBy` fallback in
`restoreComposerViewportSnapshot` behind `isWindowScrollable`; on the
desktop shell there is nothing to restore, so the scroll position is
left untouched.
- Added unit tests for the desktop-shell (no window scroll) case and for
`isWindowScrollable`.

## Verification
- `ui $ vitest run src/lib/issue-chat-scroll.test.ts` → 6 passed (2 new
+ existing window/element restore tests still green).
- `ui $ vitest run src/components/IssueChatThread.test.tsx` → 55 passed
(consumer regression).

## Risks
- Low. Behaviour only changes when the resolved target is `window` AND
the document body is clipped — i.e. the desktop shell, where the
previous behaviour was the bug. Mobile and the perf fixture keep window
scrolling unchanged (body not clipped → `isWindowScrollable` true).

## Model Used
claude-opus-4-8

---

- [x] I searched the GitHub PRs for similar or duplicate PRs and
confirmed this is not a duplicate.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:05:57 -07:00
Devin Foley 3fbab2e6db fix: resolve orphan-sweep null-assignee filter regression (#8018)
> Resubmits #5925 by @digitalflanker-ux (rebased onto current `master`;
original commit authorship preserved).

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The issues list API powers orphan-sweep and board inbox views that
filter by assignee
> - `assigneeAgentId=null` is a valid query-string sentinel for
"unassigned issues"
> - A regression caused that sentinel to throw 500 instead of filtering
correctly
> - This pull request restores null-sentinel parsing in the route and
service layers
> - The benefit is reliable orphan-sweep and unassigned-issue queries
without server errors

## Linked Issues or Issue Description

Refs #5891 (paired fix — land together)

**Bug:** `GET /api/companies/:id/issues?assigneeAgentId=null` returned
HTTP 500. Expected: HTTP 200 with only unassigned issues. Malformed
UUIDs should return 4xx, not 500.

## What Changed

- Parse `assigneeAgentId=null` in the issues list route and pass a JS
`null` filter to the service
- Handle malformed assignee IDs with HTTP 422 in the route layer
- Extend `issueService.list` to treat `assigneeAgentId: null` as `IS
NULL` SQL filter
- Add route-level and service-level regression tests

## Verification

- `pnpm exec vitest run
server/src/__tests__/issue-list-assignee-filter-routes.test.ts
server/src/__tests__/issues-service.test.ts`
- result: 2 files passed, 79 tests passed

## Risks

Low risk — scoped to query-parameter parsing and list filtering; no
schema or API contract changes beyond fixing the regression.

## Model Used

None — human-authored original fix by @digitalflanker-ux; rebased and
test-harness adjustments by Paperclip cluster cleanup.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- Pairs with #5891 — both fix the `assigneeAgentId=null` issues-list
regression and should land together.
- Supersedes #5925 (fork branch could not be force-pushed; this is the
operator-mergeable resubmission).

---------

Co-authored-by: openclaw <digitalflanker@gmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-12 09:20:00 -07:00
roy493 d9ea1bf9e1 fix: skip same-run self-comments (Path A heartbeat-reopen + Path B implicit-todo move) (#4973)
## Thinking Path

- Paperclip treats issue comments as both communication and wake
signals, so comment attribution affects whether completed work reopens.
- The bug lived in two independent paths: deferred comment wake
promotion in `heartbeat.ts`, and implicit reopen-on-comment logic in
`routes/issues.ts`.
- Both paths need the same core rule: a comment from the same run that
just closed the issue must not look like a fresh human follow-up.
- Deferred wake batches also need one extra safeguard: if a batch mixes
a same-run self-comment with a real human comment, the human follow-up
must still reopen the issue.

## What Changed

- `server/src/services/heartbeat.ts` now suppresses deferred reopen only
when every referenced comment in the batch was created by the closing
run.
- `server/src/routes/issues.ts` now passes `actorRunId`,
`checkoutRunId`, and `executionRunId` into
`shouldImplicitlyMoveCommentedIssueToTodo`, and skips the implicit move
when the comment came from the run that already owns the issue.
- `server/src/__tests__/heartbeat-comment-wake-batching.test.ts` adds
coverage for both Path A cases: same-run self-comment stays closed,
while a mixed self-comment plus human-comment batch still reopens.
- `server/src/__tests__/issue-comment-reopen-routes.test.ts` covers the
same-run guard on both POST and PATCH comment paths, plus the negative
case where a different run still reopens.

## Verification

```bash
pnpm --filter @paperclipai/server exec vitest run src/__tests__/issue-comment-reopen-routes.test.ts
pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-comment-wake-batching.test.ts -t "self-authored by the closing run|mixes self-authored and human comments"
pnpm --filter @paperclipai/server typecheck
```

## Risks

- Low risk. Both changes are additive guards and preserve existing
behavior for comments that do not originate from the owning run.
- The deferred-wake change now uses all-self semantics, which is the key
correctness detail for mixed batches.
- Full CI is still the authoritative validation for the broader
heartbeat integration surface.

## Model Used

- OpenAI Codex, GPT-5-based coding agent (`codex_local` adapter in
Paperclip).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, server-only
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

Refs #6601
Refs #3980

---------

Co-authored-by: Paperclip <paperclip@users.noreply.github.com>
2026-06-12 00:33:08 -07:00
Sherman Lye fecc41d4fd fix(recovery): skip stranded-issue recovery when pending wake interaction exists (#4854)
## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents and their work.
> - Recovery logic is part of that control plane because it decides when
agent work is truly stranded versus intentionally waiting.
> - Issues can pause on human-gated thread interactions such as
`request_confirmation`, `ask_user_questions`, and `suggest_tasks`.
> - `reconcileStrandedAssignedIssues()` was treating some of those
waiting issues as stranded because it did not check for pending
wake-style interactions.
> - That mismatch created false-positive recovery cascades on work that
was correctly paused for human input.
> - This pull request adds the missing guard and locks it in with
focused regression coverage.
> - The benefit is safer recovery behavior: real stranded work is still
recovered, while human-gated work stays stable and inspectable.

## Linked Issues or Issue Description

- Refs #7403
- Searched open GitHub PRs/issues for the same recovery-interaction bug
before merge prep; no duplicate open PRs found.

## What Changed

- Added `hasPendingWakeInteraction(companyId, issueId)` in
`server/src/services/recovery/service.ts` to detect pending thread
interactions with continuation policies `wake_assignee` and
`wake_assignee_on_accept`.
- Inserted that guard into `reconcileStrandedAssignedIssues()`
immediately after the active-execution-path check so human-gated issues
are skipped instead of escalated.
- Added a parameterized regression test in
`server/src/__tests__/heartbeat-process-recovery.test.ts` that covers
both continuation-policy values and verifies recovery does not fire.
- Appended the maintainer cross-reference section required by merge
prep.

## Verification

- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/heartbeat-process-recovery.test.ts -t "skips
stranded recovery when a pending" --pool=forks --isolate`
- Greptile Summary comment on the latest head reports `Confidence Score:
5/5`.
- Remote Paperclip CI is running on head
`4702684c213b5018e6918cb6176e7ef40f440ebf`.

## Risks

- Low risk. The production change is a read-only early exit in an
existing recovery sweep.
- The main behavioral shift is intentional: issues with pending
wake-style interactions will no longer enter stranded recovery until the
human gate clears.
- If there is a hidden interaction state we should also treat as
waiting, it would need an explicit follow-up rather than falling through
this guard.

> Checked `ROADMAP.md`; this is a focused bug fix, not overlapping
roadmap feature work.

## Model Used

- Original PR authoring: Claude Code using Claude Opus 4.6.
- Merge prep, rebase, verification, PR-body repair, and Greptile
follow-up: OpenAI Codex via the Paperclip ACPX local adapter (exact
model ID not exposed in this workspace), with tool use and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

Refs #7403

Co-authored-by: Sherman Lye <user@example.com>
2026-06-12 00:31:33 -07:00
Ismaël O. 3b7c42be86 fix(openclaw-gateway): complete and stabilize OpenClaw Gateway integration (#2322)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `openclaw_gateway` adapter is how operators wire Paperclip
agents to an OpenClaw gateway over WebSocket
> - The adapter UI previously only exposed a handful of config fields in
edit mode; many timeout / auth / session-routing knobs were unreachable
through the form
> - The serializer also forgot to inject the configured `authToken` into
the `x-openclaw-token` header, and the server-side execute path lacked
retries on transient gateway errors and an `OPENCLAW_TOKEN` env fallback
> - This pull request exposes the full set of config fields in both
create and edit modes, fixes the serializer, hardens the server-side
execute path, and pins the existing default request timeouts (120s /
120000ms) — see the dedicated commit and the new unit tests
> - The benefit is operators can configure and reconfigure an
`openclaw_gateway` agent end-to-end through the UI, with no silent
change to the defaults documented in the adapter README and
`doc/ONBOARDING_AND_TEST_PLAN.md`

## Linked Issues or Issue Description

Closes #414
Closes #1901
Closes #2309

## What Changed

- **UI**: Removed the `!isCreate` guard so all `openclaw_gateway` config
fields are visible in both create and edit modes (`authToken`,
`agentId`, `sessionKeyStrategy`, `sessionKey`, `timeoutSec`,
`waitTimeoutMs`, `disableDeviceAuth`, `autoPairOnFirstConnect`, `role`,
`scopes`, `paperclipApiUrl`, `headersJson`, `payloadTemplate`,
`runtimeServices`).
- **Serialization**
(`packages/adapters/openclaw-gateway/src/ui/build-config.ts`): inject
`authToken` into headers as `x-openclaw-token`; apply safe defaults on
create (`timeoutSec=120`, `waitTimeoutMs=120000`,
`sessionKeyStrategy="issue"`, `role="operator"`,
`scopes=["operator.admin"]`).
- **Backend**
(`packages/adapters/openclaw-gateway/src/server/execute.ts`): add
`OPENCLAW_TOKEN` env-var fallback for `authToken`, retry logic (max 2
retries with backoff for transient gateway errors), session-key prefix
`agent:{agentId}:{sessionId}` when `agentId` is configured.
- **Defaults restoration** (dedicated commit): an earlier revision of
this PR lowered the default request timeouts to `60` / `30000`. The
current branch restores the historical `timeoutSec=120` /
`waitTimeoutMs=120000` defaults that match the values documented in
`packages/adapters/openclaw-gateway/src/index.ts`,
`src/server/execute.ts` on master, and the worked example in
`doc/ONBOARDING_AND_TEST_PLAN.md`.
- **Tests** (new):
`packages/adapters/openclaw-gateway/src/ui/build-config.test.ts` pins
the documented timeout and identity defaults so the silent-halve
regression cannot recur.

## Verification

- `pnpm --filter @paperclipai/adapter-openclaw-gateway typecheck`
- `pnpm typecheck` (root)
- Manual: create a new `openclaw_gateway` agent — all fields visible,
defaults populate as documented.
- Manual: edit an existing `openclaw_gateway` agent — every field
round-trips correctly and saves.
- Manual: unset `authToken` in the form and set `OPENCLAW_TOKEN` env var
— adapter picks up the env-var fallback.
- Manual: simulate a transient gateway error — execute retries up to 2
times with backoff before failing.

## Risks

- Low risk. Surface area is one adapter, behind explicit operator
configuration. The defaults change in this PR is a restoration of values
that already exist on master and in the adapter docs, so no production
agent sees a behavioral shift relative to the prior release. Field
exposure in edit mode is purely additive — existing values are preserved
on save.

## Model Used

- Provider/model: Claude (Anthropic) — `claude-opus-4-7`
- Mode: standard tool use, no extended thinking
- Capability notes: code execution + repository file edits via Claude
Code

## Cross-references and status (maintainer)

Closes #414
Closes #1901
Closes #2309

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip Bot <bot@paperclip.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 23:31:39 -07:00
Vasu Yadav d7049e0cae fix(server): adopt stale checkout run ownership (#5413)
## Thinking Path

> - Paperclip is a control plane for autonomous AI-agent companies.
> - Issue checkout ownership is part of the execution-control layer that
prevents two runs from mutating the same task at the same time.
> - The current lock model should preserve `409` conflicts for live
competing owners, but it should not strand the rightful assignee behind
a stale terminal run.
> - A same-agent follow-up run can encounter an existing `checkoutRunId`
from a failed, timed-out, succeeded, or missing heartbeat run.
> - In that case, the new run should safely adopt ownership instead of
failing with an ownership conflict.
> - This pull request makes stale checkout adoption transactional and
keeps live checkout owners protected.
> - The benefit is safer run recovery without weakening single-owner
checkout semantics.

## Linked Issues or Issue Description

- Fixes #5350
- Closes #1508
- Closes #1970
- Closes #2083
- Closes #3158
- Closes #3190
- Related stale-lock PRs reviewed during dedup search: #7536, #6658,
#5660, #5442, #6223, #7048, #6824, #6799

## What Changed

- Updated issue checkout ownership recovery so the current assignee can
adopt a stale terminal or missing checkout run.
- Added row locking around stale checkout adoption to avoid races while
replacing `checkoutRunId` / `executionRunId`.
- Preserved `409` behavior when a different live checkout owner is still
active.
- Prevented terminal actor runs from reclaiming an unowned checkout lock
after the newer eager stale-checkout clear path.
- Fixed the stale checkout test fixture so same-assignee cases do not
insert duplicate agent rows.
- Added/kept focused coverage for stale checkout adoption and live-owner
conflict behavior.
- Fixes #5350.

## Verification

- Focused tests:

```sh
pnpm exec vitest run server/src/__tests__/issues-service.test.ts server/src/__tests__/issue-stale-execution-lock-routes.test.ts
```

Result:

```text
2 passed, 84 tests passed
```

- Server typecheck:

```sh
pnpm --filter @paperclipai/server typecheck
```

Result:

```text
passed
```

- Live curl smoke confirmed same-agent stale checkout adoption returns
`200` instead of `409`.

```text
old_run_status=succeeded
checkout_http=200
patch_http=200
```

The PATCH response showed `checkoutRunId` and `executionRunId` updated
to the new run id.

### Live curl smoke result

<img width="1498" height="570" alt="Live curl smoke showing stale
checkout adoption returned 200"
src="https://github.com/user-attachments/assets/4bf834de-e3cd-4495-ac5a-74767b439eeb"
/>

### Server request log

<img width="631" height="131" alt="Server logs showing heartbeat,
checkout, and patch requests succeeded"
src="https://github.com/user-attachments/assets/ceaaa403-110e-44e8-bac8-5d8506e79cc3"
/>

## Risks

- Low to medium risk: this touches issue execution lock ownership.
- The behavioral shift is intentionally narrow: only the current
assignee can adopt stale terminal or missing checkout ownership.
- Live checkout owners remain protected with `409`.
- No database migration or API contract change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5.5 Codex coding agent with repository tool use, shell
execution, code review, and local verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- Closes #1508
- Closes #1970
- Closes #2083
- Closes #3158
- Closes #3190
- Status: rebased onto current master; focused tests and server
typecheck pass locally; all required CI is green; Greptile is 5/5;
master drift verified.

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-11 22:03:07 -07:00
David Bezar 01e59c074a fix(watchdog): suppress repeat alerts when source issue is blocked or evaluation board-closed (#5942)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies; the
stale-active-run watchdog monitors agent heartbeat runs for extended
output silence and fires evaluation issues to alert the responsible
manager
> - The watchdog uses a unique index on open evaluation issues to
prevent duplicate open issues per run, but this only prevents two
*simultaneous* open issues — not sequential ones created after each
closure
> - When a board reviewer closes an alert as done directly (without
recording a watchdog decision), the dismissed_false_positive guard is
bypassed and `findOpenStaleRunEvaluation` returns null on the next scan
— causing a new alert to fire every 30 minutes until the run terminates
> - The previous fix also removed `blockedByIssueIds` mutation from
`ensureSourceIssueBlockedByStaleEvaluation` to break the
block→silence→new-alert loop, but that left no idempotency guard on the
source-issue escalation comment, so every critical scan re-appended the
comment to the source-issue thread
> - Additionally, runs whose source issue is already `blocked` (agent is
correctly idle, waiting on a human action) should never generate alerts
at all — silence is expected in that state
> - This PR fixes all three gaps in `createOrUpdateStaleRunEvaluation` /
`ensureSourceIssueCommentedForStaleEvaluation`: (1) skip when source
issue is `blocked`, (2) auto-record a dismissed_false_positive decision
when a closed evaluation exists with no prior watchdog decision, (3) add
an activity-log-backed idempotency guard so the source-issue escalation
comment fires exactly once per (sourceIssue, evaluationIssue) pair
across scan cycles and process restarts
> - The benefit is that agents correctly paused waiting on board-gated
blockers no longer generate repeated false-positive noise tickets,
board-closed evaluations are permanently suppressed without requiring a
second interaction, and source-issue threads no longer get spammed with
duplicate escalation comments

## What Changed

- `server/src/services/recovery/service.ts`:
- Added `blocked` source-issue guard: `if (sourceIssue?.status ===
"blocked") return { kind: "skipped" }` — idle output is expected when
the source issue is blocked
- Added `findClosedStaleRunEvaluation()` — queries for `done` evaluation
issues for a given run, ordered by most recent update (scoped to `done`
only so system-cancelled evaluations don't permanently suppress alerts)
- Added `hasDismissedFalsePositiveDecision()` — queries for an existing
dismissed_false_positive watchdog decision record
- Added closed-evaluation auto-dismiss: when a prior evaluation was
closed `done` on the board without any watchdog decision, auto-inserts a
dismissed_false_positive record so future scans skip via the existing
guard. The check-then-insert runs inside a transaction guarded by a
per-(company, run) `pg_advisory_xact_lock` so two overlapping scans
cannot both observe `hasAnyDecision = false` and both insert duplicate
rows
- Removed `blockedByIssueIds` mutation from the escalation path and
renamed `ensureSourceIssueBlockedByStaleEvaluation` →
`ensureSourceIssueCommentedForStaleEvaluation` to reflect that the
function now only adds a comment + activity log (no state mutation) —
evaluation issues are observability-only and adding them as hard
blockers created a self-amplifying loop (blocked→silent→new
alert→blocked again)
- Added activity-log-backed idempotency guard at the top of
`ensureSourceIssueCommentedForStaleEvaluation`: query the activity log
for a `heartbeat.output_stale_escalated` row with the same (sourceIssue,
evaluationIssue) pair and return false when one is present. The single
activity-log row written on the first successful escalation is the
suppression record for all later scans, surviving process restarts
- `server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`:
- Added: "emits the source-issue escalation comment only once across
repeated critical scans" (covers the comment-spam regression path)
  - Added: "skips ticket creation when the source issue is blocked"
- Added: "suppresses repeat alerts when evaluation is closed on the
board without a watchdog decision"
- Added: "still allows re-arm after continue decision even when
evaluation is board-closed" (exception path: if any watchdog decision
exists, human opted in to lifecycle — honour it)

## Verification

```sh
pnpm exec vitest run server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
```

- All 18 watchdog tests pass locally
- Regression: source-issue escalation comment emits exactly once across
repeated critical scans
- Blocked source → no evaluation created (result.created === 0,
result.skipped === 1)
- Board-closed evaluation + no decisions → auto-records
dismissed_false_positive; second scan creates nothing
- Board-closed evaluation + continue decision → second scan still
creates (re-arm preserved)

## Risks

- **Low risk.** The blocked-status guard is a pure early-return that
adds no state mutation. The auto-dismiss path only inserts a record when
no decisions exist — it cannot fire for runs where a human has opted in
to the watchdog lifecycle via snooze/continue. Removing
`blockedByIssueIds` from the critical-escalation path is safe because
evaluation issues are already parented under the source issue.
- The `dismissed_false_positive` auto-insert is now race-safe under
concurrent scans via `pg_advisory_xact_lock` keyed on `(companyId,
runId)` so the check-then-insert pair is serialized without requiring a
schema change.
- `findClosedStaleRunEvaluation` is scoped to `done` only (not
`cancelled`) so system code paths that cancel evaluation issues cannot
permanently suppress future watchdog alerts for the same run.

## Model Used

- Provider: Anthropic
- Model: Claude Sonnet 4.6 (`claude-sonnet-4-6`) for original change;
Claude Opus 4.7 (`claude-opus-4-7`) for follow-up review fixes
- Context: full repo read with tool use, running as SADE agent in
Paperclip Claude Code
- Mode: agentic code analysis + targeted edit

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- Closes #4937
- Closes #5207
- Closes #5767
- Closes #5949

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 22:00:13 -07:00
Vladimir Balko deef1f479d fix(heartbeat): release execution lock on cross-agent reassignment (#5110)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each issue can hold an execution lock via `issues.execution_run_id`,
so concurrent wakes for the same task either coalesce into the active
run or wait deferred
> - When the issue is reassigned to a *different* agent (e.g. board
operator changes `assigneeAgentId` from Coder → Reviewer + flips
`status` to `in_review`), the new assignee's wake is correctly sent down
the assignment-wakeup path
> - But the lookup `activeExecutionRun` still finds the previous holder
run as long as it is in `{queued, running, scheduled_retry}` — and
`enqueueAssignmentWakeup` falls through to the deferred-wake branch when
the holder agent does not match the new assignee
> - The trouble is the **queued** holder for the old assignee will never
start (the issue's status / target now belongs to someone else, the
relevant assignment trigger was the original one), so the lock is never
released, the deferred wake is never promoted, and the new assignee
silently never wakes
> - This pull request detects that situation right next to the existing
`cancelStaleScheduledRetry` cleanup: if `activeExecutionRun.status !==
'running'` AND the holder agent differs from `issue.assigneeAgentId`,
cancel the holder run, release the lock, and proceed with a normal
queued wake instead of deferring
> - The benefit is hand-offs across agents become reliable — no more
silent stalls that operators have to unstick by manually cancelling a
queued run

## Linked Issues or Issue Description

- Closes #4058

## What Changed

- One new check in `reapOrphanedRuns()`'s peer function — the
`enqueueAssignmentWakeup` defer-detection block in
`server/src/services/heartbeat.ts` (around the lock-resolution code
immediately following `cancelStaleScheduledRetry`):
- If `activeExecutionRun` exists, its `status !== 'running'`, and
`activeExecutionRun.agentId !== issue.assigneeAgentId`, mark the holder
run as `cancelled` with errorCode `lock_released_on_reassignment`,
cancel its corresponding wakeup request if any, and null
`activeExecutionRun` so the lock-clear branch right below proceeds to
release `executionRunId` / `executionAgentNameKey` / `executionLockedAt`
and the wake gets enqueued normally.
- `running` runs still defer (legitimate concurrency).
- Same-agent queued/scheduled holders still defer (legitimate coalesce).
- Total +37 lines, no API change, no schema change.

## Verification

```sh
# Existing reaper tests still pass — exercises the lock-resolution path
pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts --no-coverage
# expected: Tests  39 passed (39)

# New regression test for the cross-agent lock-release race
pnpm exec vitest run server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts --no-coverage
```

Manual reproduction (matches an incident we hit running a small Coder +
Reviewer company):

1. Coder pickup heartbeat schedule fires; paperclip queues a Coder run
and pre-allocates the lock by recording `issues.execution_run_id =
<queued-coder-run-id>` for the pickup issue.
2. The Coder run sits in `queued` because the agent's slot is busy
elsewhere (`maxConcurrentRuns: 1`).
3. Operator (or CEO) PATCHes the issue: `assigneeAgentId: <coder>` →
`<reviewer>` together with `status: in_progress` → `in_review`.
4. Paperclip creates the Reviewer assignment wakeup, but stores it as
`deferred_issue_execution` because `activeExecutionRun` is the queued
Coder run.
5. **Before this PR**: Reviewer never wakes; the deferred wakeup waits
for the queued Coder lock holder which never starts (the issue is no
longer the Coder's). Operator has to `POST
/api/heartbeat-runs/<queued-coder>/cancel` manually to unstick the
chain.
6. **After this PR**: paperclip recognizes the holder is non-running and
belongs to a now-foreign agent, cancels it inline, releases the lock,
and queues the Reviewer wake normally — Reviewer wakes on the next
heartbeat tick.

## Risks

- **Low**. The new branch only fires when both conditions are true:
- The holder run is **not** `running` — `running` runs still defer (we
never interrupt active work).
- `activeExecutionRun.agentId` is different from the issue's *current*
`assigneeAgentId` — i.e. the assignee was just changed, the old holder
is bound to the prior owner.
- The cancel uses errorCode `lock_released_on_reassignment` so operators
can grep for it; the corresponding wakeup is also cancelled in the same
transaction so we do not leave an orphan wakeup request.
- No DB schema change, no public API change, no UI change.
- Sits next to the existing `cancelStaleScheduledRetry` cleanup pattern,
so the behavior is locally consistent with how stale schedule retries
are already cleared.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), 1M-context build, extended
thinking + tool use enabled. Used to trace the lock-acquire / defer /
promote paths in `heartbeat.ts` from the live incident, design the
minimal-blast-radius fix next to `cancelStaleScheduledRetry`, and
produce this PR description.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass (39 in the directly
affected suite, plus the new regression test)
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A (server-side wakeup routing)
- [x] I have updated relevant documentation to reflect my changes —
in-line code comment explains the new branch
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- `Closes #4058`

### Maintainer-added changes on top of the original commit

A second commit was added on top of @vbalko-claimate's original to pin
the cancel `UPDATE` for the queued/scheduled holder to the exact
non-running status read just above it. Without that predicate, a worker
that flipped the holder from `queued` → `running` between the `SELECT`
and the `UPDATE` could have its freshly-claimed `running` row silently
clobbered to `cancelled`. The new commit also gates the wakeup-request
cancellation and the `activeExecutionRun = null` assignment on a
non-empty `RETURNING`, so neither fires when the predicate misses. A
dedicated regression test
(`heartbeat-lock-release-on-reassignment.test.ts`) covers both paths:
the legitimate-running-holder defer case and the queued→running race.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@devinfoley.com>
2026-06-11 21:59:04 -07:00
alcylu 9e81067678 fix: clear stale executionRunId on release, reassignment, and checkout (#2482)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Issues are the unit of agent assignment; each assignment queues a
heartbeat run, and the agent claims ownership via a `checkout()` that
sets `checkoutRunId` and `executionRunId` on the issue row.
> - When a queued run never starts (crash, deploy, lost heartbeat) or a
different run picks up the work, the issue is left with a stale
`executionRunId` pointing at a terminal/missing run.
> - The next checkout attempt fails with "Issue checkout conflict"
because the fast-path `UPDATE` requires `executionRunId` to be null or
equal to the requester's run id, so the row is permanently locked until
an admin clears the column by hand.
> - This pull request closes that lifecycle gap in three places —
`release()` and `update()` clear the execution lock fields alongside the
existing `checkoutRunId` clear, and `checkout()` gains a guarded
stale-`executionRunId` adoption path that mirrors the existing
`adoptStaleCheckoutRun` pattern.
> - The benefit is that assignment-triggered issues self-heal after a
lost run instead of paging an admin to unlock them, while the adoption
path keeps the caller's `expectedStatuses` guard, preserves any pending
`assigneeUserId`, and preserves the original `startedAt` for issues
already `in_progress`.

## Linked Issues or Issue Description

- Closes #759
- Closes #1015
- Closes #1276
- Closes #1298
- Closes #2265
- Closes #2661
- Closes #2964
- Closes #3559
- Closes #4033
- Closes #4131

## What Changed

- `server/src/services/issues.ts` — `release()` now clears
`executionRunId`, `executionAgentNameKey`, and `executionLockedAt`
alongside `checkoutRunId`.
- `server/src/services/issues.ts` — `update()` clears the same
execution-lock fields on status change (away from `in_progress`) and on
assignee change.
- `server/src/services/issues.ts` — `checkout()` gains a stale
`executionRunId` adoption block that runs only when the row's
`executionRunId` points at a terminal/missing heartbeat run, the
caller's `expectedStatuses` still hold, and the requester is either the
existing assignee or the assignee is null. The `SET` clause preserves
`assigneeUserId` and only resets `startedAt` when the issue was not
already `in_progress` (matches `adoptStaleCheckoutRun` semantics).
- `server/src/__tests__/issues-service.test.ts` — two regression tests
covering the new adoption guards: (1) checkout refuses to promote a
`done` issue when `done` is not in `expectedStatuses`, even with a
lingering `executionRunId` pointer; (2) checkout adoption of a stale
`checkoutRunId` preserves the issue's `assigneeUserId`.

## Verification

- `vitest run src/__tests__/issues-service.test.ts` — 75/75 tests pass,
including the two new regression tests.
- `tsc --noEmit` clean.
- Manual repro of the original stuck-lock case: queue a run, mark the
heartbeat run terminal without releasing the issue, attempt a new
checkout — the adoption path now succeeds with the caller's
`expectedStatuses` guard intact instead of returning a checkout
conflict.

## Risks

- Low risk. The `release()` and `update()` changes are additive field
clears alongside the existing `checkoutRunId` clear and follow the same
conditions. The `checkout()` adoption block is gated by the same status
/ assignee / expected-statuses constraints as the fast-path `UPDATE` and
only fires when the prior run is verifiably terminal via
`isTerminalOrMissingHeartbeatRun()`. No migration. No public API change.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), extended-thinking mode, tool-use
enabled (file reads, edits, shell, gh CLI). Used to address review
feedback on the original commit by Allen Lu (`alcylu`); follow-up fix
commit preserves the `expectedStatuses` guard, `assigneeUserId`, and
`startedAt` and adds regression tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server-only)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- Closes #759
- Closes #1015
- Closes #1276
- Closes #1298
- Closes #2265
- Closes #2661
- Closes #2964
- Closes #3559
- Closes #4033
- Closes #4131

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-11 21:58:04 -07:00
NiViGaHo 7945c70396 fix(issues): reopen-guard for assignee self-comment on terminal issue (AKS-1563) (#4346)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issues are the unit of agent work, and a "done" issue should stay
done unless something explicit reopens it
> - The implicit-reopen path (human comment on a terminal issue) already
keeps agents from reopening their own issues via
`shouldImplicitlyMoveCommentedIssueToTodo`, but the explicit `reopen:
true` path was not similarly guarded
> - That gap lets the assignee agent reopen its own `done`/`cancelled`
issue just by posting a log-style comment with `reopen: true` — the same
"log lines are not reopen signals" semantics that the implicit path
already encodes
> - This pull request adds a focused
`isAssigneeSelfCommentOnTerminalIssue` guard applied at both `PATCH
/issues/:id` and `POST /issues/:id/comments`, forcing
`effectiveMoveToTodoRequested = false` when the actor is an agent
commenting on its own terminal issue without `resume: true`
> - The benefit is a single, narrow invariant: only an explicit `resume:
true` (or a different-agent / human commenter) reopens a terminal issue
— assignee self-comments stay communicative

## Linked Issues or Issue Description

Refs #3980
Refs #3935
Refs #6601

## What Changed

- Adds `isAssigneeSelfCommentOnTerminalIssue` helper in
`server/src/routes/issues.ts` next to the existing
`shouldImplicitlyMoveCommentedIssueToTodo`
- Applies the guard at both comment entry paths (`PATCH /issues/:id`
with a `comment` body and `POST /issues/:id/comments`) so
`effectiveMoveToTodoRequested` is forced to `false` when actor is an
agent and matches the **current** assignee of a `done`/`cancelled` issue
— even if `reopen: true` was sent explicitly
- PATCH path compares against `existing.assigneeAgentId` (not
`requestedAssigneeAgentId`), so a different agent that PATCHes a
terminal issue with `{ comment, reopen: true, assigneeAgentId: <self> }`
still reopens as today
- The `resume: true` explicit-resume path is preserved verbatim — the
guard short-circuits on `resumeRequested`
- Existing external-caller paths (different agent / human user
commenting on terminal) are unchanged and still reopen
- New unit tests in
`server/src/__tests__/issue-comment-reopen-routes.test.ts`:
- `does not reopen via POST comment+reopen when the assignee agent is
the actor on a done issue`
- `does not reopen via POST comment+reopen when the assignee agent is
the actor on a cancelled issue`
- `does not reopen via PATCH comment+reopen when the assignee agent is
the actor on a done issue`
- `still reopens a done issue via PATCH when a different agent reassigns
to self with reopen=true`

## Verification

- [x] `vitest run src/__tests__/issue-comment-reopen-routes.test.ts` —
65/65 pass locally (4 new + 61 existing)
- [x] `tsc --noEmit` — no new errors in changed files
- [x] Manual trace: explicit-resume path (`resume: true`) still reopens
because the guard short-circuits on `resumeRequested`

## Risks

Low. The guard is a single short-circuit before the existing reopen
decision and only fires when actor is an agent commenting on its own
`done`/`cancelled` issue without `resume: true`. The `resume: true` path
is unchanged, and the PATCH comparison uses the current assignee so
cross-agent takeover with `reopen: true` continues to reopen.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Mode: extended thinking + tool use (Claude Code agent harness)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A — server-only)
- [x] I have updated relevant documentation to reflect my changes (no
docs touch the reopen guard)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---

## Cross-references and status (maintainer)

Rebased on current `master`. The implicit-reopen case is already handled
upstream by the user-actor branch of
`shouldImplicitlyMoveCommentedIssueToTodo`; this PR adds the matching
guard for the explicit `reopen: true` path. The PATCH-path guard
compares against `existing.assigneeAgentId` so cross-agent reassignment
+ reopen still reopens.

Refs #3980
Refs #3935
Refs #6601

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 21:55:37 -07:00
Dale Carman d782c4cd53 fix(heartbeat): prevent zombie run coalescing and ensure startup reap completes before timer ticks (#1731)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run in heartbeats — short execution windows triggered by the
heartbeat service
> - The heartbeat service coalesces overlapping wakeups: if a run for an
agent is already active, a new wakeup merges into it rather than
creating a duplicate
> - But when the server restarts, in-progress runs are left in
`"running"` status in the database — their child processes are gone, but
the DB rows persist as orphans
> - The startup `reapOrphanedRuns()` was fired as a `void` promise — the
timer interval started immediately in parallel, so the first timer tick
could coalesce a new wakeup into an orphaned "running" row before the
reap had a chance to remove it
> - Once coalesced, the orphan's `updatedAt` refreshed, making the
reaper skip it as "not old enough" — a zombie run that prevents the
agent from ever waking again
> - This PR fixes both the coalescing guard (do not coalesce into a
zombie) and the startup ordering (await reap before starting the timer),
eliminating the death spiral

## What Changed

- **`server/src/index.ts`** — `startServer` now `await`s
`reapOrphanedRuns()` (with one retry) before calling `setInterval`.
Timer ticks cannot start until orphaned runs are cleaned up.
- **`server/src/services/heartbeat.ts`** — Added two exported pure
functions:
- `isZombieRun(run, tracked)` — returns `true` if a run is `"running"`
in the DB but has no live entry in the in-memory `runningProcesses` Map
- `filterZombieCoalesceTarget(target, tracked)` — returns `null` if the
coalesce candidate is a zombie, letting the wakeup fall through to
create a new queued run instead
- Both coalescing call sites now use `filterZombieCoalesceTarget` before
deciding to coalesce
- **`server/src/__tests__/heartbeat-zombie-guard.test.ts`** — 8 new
behavioral tests covering `isZombieRun` and
`filterZombieCoalesceTarget`, including the critical zombie scenario,
legitimate live runs, queued runs (must never be filtered), and null
pass-through

## Verification

```bash
# Run the new tests
pnpm test:run
```

Manual reproduction (before fix):
1. Start an agent on a timer heartbeat
2. Kill the server mid-run (child process dies, DB row stays
`"running"`)
3. Restart the server
4. Observe: agent never wakes again — subsequent wakeups coalesce into
the dead run, refreshing `updatedAt`, keeping it alive forever

After fix: startup reap clears the orphan before the timer starts;
subsequent wakeups create fresh queued runs.

The one pre-existing test failure (`worktree helpers > copies shared git
hooks`) is unrelated — it fails on `upstream/master` as well due to a
`pnpm install` failure in the test environment.

## Risks

- **Startup latency**: `await reapOrphanedRuns()` adds a small delay
before the timer starts. In practice this is a fast DB query. The retry
adds at most one extra attempt on transient failure.
- **Behavior change**: Wakeups that previously coalesced into zombie
runs will now create new queued runs instead. This is the correct
behavior — the zombie was preventing any forward progress.
- **Queued runs unaffected**: `isZombieRun` only flags `"running"`
status. Queued runs pass through `filterZombieCoalesceTarget` unchanged
(covered by tests).

## Model Used

- OpenAI GPT-5 via a Codex-style terminal coding agent with tool use and
git/gh access. Exact hosted alias is not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

Refs #3168
Refs #4174
Refs #4697
Refs #6399

Related PRs checked: #4075, #4705, #5232, #6952
- [x] I have searched GitHub for duplicate or related PRs and linked
them above

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-11 21:54:32 -07:00
sunghere 130219c0be fix(recovery): exempt stranded escalation when assignee shows recent visible progress (#5213)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The recovery service watches `in_progress` agent-assigned issues
every 30s and creates "Recover stalled issue …" child issues when
execution looks stranded
> - The `isRepeatedProductiveContinuationRecovery` branch escalates
after just **two consecutive productive continuation runs** — fine for
genuinely stuck agents that loop without doing anything, but a false
positive for batch workflows that legitimately advance every heartbeat
(e.g. multi-frame image generation that produces 1–2 frames + an
attachment per heartbeat)
> - In production this fired ~95 times for a 19-character batch run,
burning a recovery owner heartbeat each time
> - This pull request adds a "recent visible progress" exemption: if the
assignee posted a comment or any attachment within the exemption window
(default 30 min, env-tunable, 60s floor), skip the escalation and let
the normal continuation-retry path enqueue the next wake
> - The benefit is one platform tweak unblocks all current and future
batch workflows without weakening the genuinely-stuck case — agents that
go silent still escalate after the window elapses

## What Changed

- `server/src/services/recovery/service.ts`
- new `STRANDED_RECENT_PROGRESS_EXEMPTION_MS` constant (default 30 min,
override via env, floored at 60s)
- new `hasRecentVisibleProgress(companyId, issueId, assigneeAgentId,
windowMs)` helper — single parallel query against `issue_comments`
(filtered by `authorAgentId`) + `issue_attachments`, both using existing
indexes
- in `reconcileStrandedAssignedIssues`, the
`isRepeatedProductiveContinuationRecovery` branch now consults the
helper before escalating; on exemption it falls through to the existing
continuation-retry enqueue path
- new `recentProgressExempted` counter on the reconcile result, surfaced
in the periodic recovery log via the existing `...reconciled` spread
- `server/src/__tests__/heartbeat-process-recovery.test.ts`
- new test: recent agent comment → no escalation, continuation
re-queued, `recentProgressExempted: 1`
- new test: stale (24h-old) agent comment → escalation still fires as
before

## Verification

- `pnpm typecheck` — green across the workspace
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts` — 39/39 pass
(37 pre-existing + 2 new)
- Smoke after deploy: confirm Image Spec multi-frame generation no
longer creates `Recover stalled issue …` child issues per heartbeat

## Risks

- **Behavioral shift, low blast radius.** A genuinely-stuck agent that
posts cosmetic comments every <30 min would now escalate later instead
of immediately. Mitigated by:
  - Window is configurable via `STRANDED_RECENT_PROGRESS_EXEMPTION_MS`
- Other escalation paths are untouched (failed/cancelled/timed_out runs
still escalate immediately, paused-tree handling unchanged,
recovery-issue-on-recovery guard unchanged)
- Periodic recovery log now reports `recentProgressExempted` so a
runaway exemption is visible in operations
- No DB migration required — both `issue_comments` and
`issue_attachments` queries use existing indexes
- Backward compatible: pre-existing test "blocks stranded in-progress
work after a productive continuation retry was already used" still
passes unchanged because no comment is seeded → no exemption → escalates

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), extended thinking, tool use
enabled. Investigation, change, tests, and PR body all human-supervised
through a Paperclip agent heartbeat.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, server-only
- [ ] I have updated relevant documentation to reflect my changes — no
docs touched the previous behavior; the env knob is self-documenting via
the comment in `service.ts`
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Cross-references and status (maintainer)

Rebased onto current `master`. No duplicate PRs absorbed.

Refs #6072 — related open report in the same
`reconcileStrandedAssignedIssues` /
`isRepeatedProductiveContinuationRecovery` family (stale
productive-continuation evidence). This PR does not fix #6072, but the
recent-visible-progress exemption added here shrinks the false-positive
surface in that branch and the new `recentProgressExempted` counter
gives operators visibility into the broader escalation path.

Co-authored-by: sunghere <sunghere@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 21:53:51 -07:00
Lempkey e1e2cef928 fix(issues): accept array-form ?status= filter and stop crashing on repeated keys (#4628) (#4890)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Boards, agents, and the public REST API all read issue lists via
`GET /api/companies/:cid/issues`, with `?status=` as the most-common
filter
> - Express's default `qs` parser binds repeated keys to a `string[]` —
the conventional URL form `?status=todo&status=in_progress` is therefore
valid input
> - The service layer treated `filters.status` as a string and called
`.split(",")` unconditionally, returning HTTP 500 with `TypeError:
filters.status.split is not a function`. The same buggy pattern lived at
a second call site in the same file
> - This PR adds a small `parseStatusFilter` helper that normalizes all
four shapes the route can receive, routes both service-layer call sites
through it, and widens the `IssueFilters.status` type so the contract
stops lying about runtime reality
> - The benefit is a passive 500 disappears for any client (curl, board,
agent code) that builds `?status=` with array-style binding, and the
type system now forces every future caller to handle both shapes
correctly

## Linked Issues or Issue Description

- Refs #4628
- Closes #4084
- Related earlier attempt: #1964

## What Changed

- **`server/src/services/issues.ts`** — Added exported helper
`parseStatusFilter(input: string | readonly string[] | undefined):
string[]` that normalizes single strings, CSV
(`?status=todo,in_progress`), array (`?status=todo&status=in_progress`),
and mixed array+CSV; trims and filters empties. Widened
`IssueFilters.status` from `string` to `string | readonly string[]`.
Replaced inline `.split` call sites in `list()`, blocked-count
filtering, `count()`, and `countUnreadTouchedByUser()` with
helper-driven branching.
- **`server/src/routes/issues.ts`** — Replaced dishonest
`req.query.status` casts with `string | string[] | undefined` at both
issue-list and blocked-count entry points so the route contract matches
Express `qs` runtime behavior.
- **`server/src/__tests__/parse-status-filter.test.ts`** (new) — 10 unit
cases: undefined, empty string, single, CSV, array, mixed array+CSV,
whitespace trim, trailing/extra commas, no-mutation guarantee, hostile
non-string entry guard.
- **`server/src/__tests__/issues-list-query-parsing.test.ts`** (new) — 5
supertest cases against a minimal Express app whose handler mirrors the
route cast/forwarding pattern: single, CSV, repeated-key array, mixed
array+CSV, and no `?status` param.
- **`server/src/__tests__/issues-service.test.ts`** — Added
embedded-Postgres service coverage for array-form status filters through
`list`, `count`, and `countUnreadTouchedByUser` on current master.

**Why service-layer, not route-layer:** the bug is the service contract.
Fixing only at the route would leave other service-layer call sites
latent, keep `IssueFilters.status` inaccurate, and let future internal
callers reintroduce the same crash. Widening the type is the forcing
function that prevents recurrence.

**Why `parseStatusFilter` is exported, not file-local:** the helper has
direct unit coverage and keeps the normalization logic colocated with
its only current call sites.

## Coordination with prior work

- Supersedes **#4084** (thanks to @adlai88 for the original
report-and-fix). This PR additionally fixes the extra current-master
service call sites, widens `IssueFilters.status` so the type contract is
honest, replaces the incorrect route casts, and ships direct regression
coverage.
- **#1964** bundles unrelated route/service changes; this PR keeps scope
tight per CONTRIBUTING.md's one-PR-one-change guidance.

## Out-of-scope finding

While verifying all query-string status parsing sites, I found a sibling
bug in `server/src/services/execution-workspaces.ts:409` reachable from
`routes/execution-workspaces.ts:48`, where repeated `?status=` keys can
still hit the same `.split(",")` assumption. I left that out of this PR
to keep the review surface small.

## Verification

```bash
pnpm --filter @paperclipai/server exec vitest run \
  src/__tests__/parse-status-filter.test.ts \
  src/__tests__/issues-list-query-parsing.test.ts \
  src/__tests__/issues-service.test.ts \
  --testNamePattern='parseStatusFilter|issue list status query parsing|accepts array-form status filters in list and count|excludes plugin operation issues from unread inbox counts'
```

Result on the rebased head: `3` files passed, `17` tests passed, `72`
skipped.

GitHub CI on PR `#4890` is green on the rebased head
`805731d3270783d0b80b33ee1dccdc6771febef6`, including `verify`,
`Typecheck + Release Registry`, `Build`, `e2e`, general tests,
serialized suites, Socket checks, Snyk, and `Greptile Review`.

Local workspace typecheck commands still encounter unrelated
current-master baseline errors under `packages/plugins/sdk` and
`server/src/services/company-skills.ts`; no failures were produced from
the `issues` files changed in this PR.

## Risks

- **Type widening blast radius:** `IssueFilters.status` widens from
`string` to `string | readonly string[]`. Any direct caller that still
assumes `.split()` on the input now gets a useful typecheck failure
instead of a latent runtime crash.
- **Behavior change:** `?status=todo,in_progress&status=done` previously
returned HTTP 500; it now returns HTTP 200 with the union of matching
statuses. Single-string and CSV behavior remain unchanged.
- **No migration. No breaking API changes. No new deps. No UI changes.**

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. — Confirmed: this is a bug
fix, not a feature. ROADMAP.md grep showed no overlap.

## Model Used

- **Claude Opus 4.7** (Anthropic), `claude-opus-4-7`, 1M context,
extended thinking. Used for problem scoping, implementation, and test
authoring; the final rebasing, PR prep, and verification updates were
handled in the maintainer workflow.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

## Cross-references and status (maintainer)

- Closes #4084

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-11 21:52:43 -07:00
Jannes Stubbemann 70357b961f feat(security): per-company JWT signing keys for multi-tenant isolation (#5864)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents authenticate to the server with a JWT signed by the
deployment's master secret
> - In a multi-tenant deployment, all agents from every tenant are
signed with the *same* key, so a leak (CI/staging dump, hostile
contractor with infra access, supply-chain) lets the attacker mint
tokens for *any* tenant
> - The same master secret also issued tokens with a 48-hour TTL, giving
any leaked token a two-day window of validity even after rotation
> - This pull request derives a per-company signing key via
`HMAC-SHA256(master, "jwt:<companyId>")` and reduces the default TTL to
1h; the verifier tries the per-company key first and falls back to the
master secret only for tokens issued before this change so no agent gets
locked out on deploy
> - The benefit is multi-tenant key isolation (a leak of one company's
derived key cannot forge tokens for another) and a tighter blast-radius
on any leaked token, with zero local-first impact (single-tenant deploys
derive their one company's key the same way and continue to work
unchanged)

## Linked Issues or Issue Description

Refs #5288 — a separate key-hygiene finding in the same module
(`agent-auth-jwt.ts` falls back to `BETTER_AUTH_SECRET` as the JWT
signing secret). Related agent-JWT trust-model concern, but not fixed by
this PR — the master-secret fallback selection is unchanged here.

No existing issue covers this PR's problem directly — described in-PR:

- In a multi-tenant deployment, agents from every tenant get JWTs signed
with the *same* master key, so a single leak (CI/staging dump, hostile
contractor, supply chain) lets the attacker mint tokens for *any*
tenant.
- The same master secret issued tokens with a 48-hour TTL, giving any
leaked token a two-day validity window even after rotation.
- Fix: derive a per-company signing key via `HMAC-SHA256(master,
"jwt:<companyId>")` and reduce the default TTL to 1h, with a
master-secret verification fallback so pre-existing tokens are not
locked out on deploy.

## What Changed

- **`server/src/agent-auth-jwt.ts`**
- New `deriveCompanySigningKey(masterSecret, companyId)` — `HMAC-SHA256`
with domain-separated input (`jwt:<companyId>`) so the master secret can
be safely reused for other HMAC purposes in the future without
cross-protocol risk.
  - `signAgentJwt` always signs with the derived per-company key.
- `verifyAgentJwt` reads `company_id` from the token's (untrusted) claim
payload, looks up the candidate derived key, and verifies. If that fails
AND a master secret is set, it falls back to verifying with the raw
master secret — pre-existing tokens validate until they expire.
Verification still fails if the signature doesn't bind.
- Default TTL: `60 * 60 * 48` → `60 * 60`. Existing
`PAPERCLIP_AGENT_JWT_TTL_SECONDS` override still wins.
- **`PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK`** (optional, default
off) — operators set this ~one TTL after deploying to sunset the
master-secret verification fallback entirely, closing the window in
which a leaked master secret could forge arbitrary-`exp` tokens for any
tenant.
- **`server/src/__tests__/agent-auth-jwt.test.ts`** (6 new cases)
- Per-company isolation via tamper: token for company A fails when
verified for company B.
- Legacy-token verification path: tokens signed with the raw master
secret still verify.
  - Default TTL is 1h.
- Legacy fallback toggle: master-secret tokens accepted when unset,
rejected when enabled, and per-company tokens unaffected either way.

## Verification

- `pnpm --filter @paperclipai/server run typecheck` — clean.
- `npx vitest run agent-auth-jwt` — 11/11 pass (6 new + 5 existing).
- Manual: token signed for company A under per-company key fails when
verified against company B's derived key.

## Risks

- **Backward-compatible verification**, so no agent gets locked out on
deploy — but operators relying on hot-swapping the master secret should
note that pre-existing tokens *will* keep validating against the master
key until their TTL elapses, unless
`PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` is set to end the
fallback window explicitly.
- **TTL reduction is a default, not a hard cap.** Operators who relied
on the 48h window can override via env. If 1h is too aggressive for
upstream taste, happy to gate the change behind an env var.
- **No new required env vars.** Single-tenant local-first deploys derive
one company's key the same way and behave identically to today.
- **Domain-separated HMAC input** (`jwt:<companyId>`) means the master
secret can be safely reused for other future HMAC purposes without
cross-protocol risk.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode; rebase +
legacy-fallback sunset documentation by Claude Fable 5 (1M context).

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — part of the multi-tenant hardening initiative
- [x] Tests run locally and pass (`agent-auth-jwt` 11/11)
- [x] Added per-company-isolation, legacy-fallback, and TTL-default
tests
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Part of the multi-tenant hardening initiative — see also #3967
(cross-tenant 404 oracle) and #5865 (plugin tables `company_id`).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 18:00:22 -07:00
Jannes Stubbemann 606e74d11f cloud_tenant: company-scoped tenants, never instance-admin (#7525)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, and a
single server instance can host many companies.
> - The auth middleware (`server/src/middleware/auth.ts`) supports a
`cloud_tenant` mode where a trusted hosting proxy injects per-request
identity headers, designed originally for one-deployment-per-tenant
setups.
> - In that original setup, granting every cloud tenant the
`instance_admin` role was harmless; on a **shared, multi-tenant pool**
it means any paying tenant is admin of the whole instance and can reach
every other tenant's data.
> - A tenant only needs to own its own company — which it already gets
via the company membership the same code path upserts — so
instance-level admin is never appropriate for `cloud_tenant` actors.
> - This PR removes the `instance_admin` grant from the cloud-tenant
path and pins `isInstanceAdmin: false` on the resolved actor.
> - Greptile review then surfaced a follow-up gap: deployments that ran
the pre-hardening build still have stale `instance_admin` rows in
`instance_user_roles`, which other lookups (BetterAuth session path,
board API keys, and the authorization service's own DB re-check) would
still honor.
> - The follow-up commit closes that gap by purging stale rows at the
cloud-tenant auth boundary and by teaching the authorization service
that `cloud_tenant` actors are never instance admins.
> - The benefit is that shared-pool hosting becomes structurally safe:
tenants are company-scoped owners, never instance admins — including on
deployments upgrading from the older behavior.

## Linked Issues

- Refs #966 — managed SaaS multi-tenant hosting is the deployment shape
this hardening protects.
- Refs #5015 — same problem space: instance-admin-scoped credentials are
too broad for multi-company instances; tenants need company-scoped
access.

Neither issue is fully closed by this PR; it removes the instance-admin
grant from the `cloud_tenant` trusted-header path specifically.

## What Changed

- `server/src/middleware/auth.ts`
- Removed the `instanceUserRoles` insert that granted every cloud tenant
`instance_admin`; `resolveCloudTenantActor` now returns
`isInstanceAdmin: false` (was `true`).
- `resolveCloudTenantActor` now **deletes** any stale `instance_admin`
row for the authenticated tenant user on every trusted-header request,
so grants left behind by pre-hardening deployments are purged at the
source (closes the Greptile P2: stale rows could otherwise re-elevate
the user via the BetterAuth session path, board API keys, or the
authorization service).
  - The function is `export`ed so it can be unit-tested directly.
- `server/src/services/authorization.ts`
- `authorizationService` previously re-checked `instanceUserRoles` from
the DB regardless of the actor flag, which would have elevated even
hardened `cloud_tenant` actors while a stale row lingered. Actors with
`source === "cloud_tenant"` are now never elevated to instance admin;
other board actors keep the existing lookup.
- `server/src/services/authorization.ts` +
`server/src/middleware/auth.ts` (follow-up commit `dc57a71c7`)
- CI on the merge ref surfaced that elevation removal alone strands real
cloud tenant users: board actors only ever reached `issue:read` /
`issue:mutate` through instance-admin elevation (`permissionForAction`
maps both to no grant key). `decide()` now grants `cloud_tenant` actors
with an **active membership in the resource company** the same read
surface as a same-company agent (`agent:read`, `company_scope:read`,
`issue:read`, `project:read`) plus `issue:mutate` for non-viewer members
— cross-company access stays denied (new `allow_company_member` reason).
- `resolveCloudTenantActor` seeds the standard role-default permission
grants (`ensureHumanRoleDefaultGrants`) so granted actions (e.g.
`tasks:assign`, `agents:create` for owners) work without elevation.
- Master-side route tests that stubbed cloud tenant actors with
`isInstanceAdmin: true` now seed a real membership and assert under the
hardened contract (`issue-identifier-routes`,
`multilingual-issues-routes`, `issue-comment-redaction`).
- Tests
- `server/src/middleware/cloud-tenant-actor.test.ts` (new): cloud tenant
is never instance-admin, is scoped to exactly the one company from its
stack, still upserts user/company/membership, purges stale
`instance_admin` rows, returns null without the server token, and maps
non-owner stack roles without elevating.
- `server/src/__tests__/auth-session-route.test.ts`: end-to-end
middleware regression — a user with a stale `instance_admin` row stops
being elevated via the session path once they authenticate through the
cloud-tenant path (with a control assertion showing the pre-purge
elevation).
- `server/src/__tests__/authorization-service.test.ts` (embedded
Postgres): a `cloud_tenant` actor with a stale `instance_admin` row in
the real DB cannot cross company boundaries, while a `session` actor
with the same row still resolves `allow_instance_admin`.

## Verification

Run from the repo root after `pnpm install --frozen-lockfile`:

```bash
cd server
npx vitest run src/middleware/cloud-tenant-actor.test.ts src/__tests__/auth-session-route.test.ts
# 9 tests passed
npx vitest run src/__tests__/authorization-service.test.ts
# 16 tests passed (embedded Postgres)
pnpm typecheck
# clean
```

Also ran the broader auth-related suites locally (`auth-routes`,
`authz-company-access`, `better-auth`, `adapter-routes-authz`,
`express5-auth-wildcard`): 8 files, 58 tests, all passing.

## Risks

- **This touches authentication and authorization paths directly.**
Mistakes here are security bugs in both directions; review accordingly.
- **Behavioral change for existing `cloud_tenant` deployments:** tenants
that previously (incorrectly) had instance-admin lose it — including the
ability to see/manage other companies on the instance. This is the
intended hardening, but any single-tenant deployment that relied on the
cloud-tenant identity for instance administration must provision a
separate admin identity.
- **The purge is destructive by design:** if an operator's
instance-admin identity is *also* provisioned through the cloud-tenant
headers (same user id), its `instance_admin` row will be deleted on the
next trusted-header request. Operators should hold admin through a
non-cloud-tenant identity.
- **Residual gap (documented, not fixed here):** a deployment that ran
the old cloud_tenant build and then *disabled* cloud-tenant mode keeps
stale rows until the affected user re-authenticates through the cloud
path. A data migration was considered and deliberately avoided: there is
no reliable SQL predicate for "cloud-tenant-provisioned user" (no source
column), so a migration risks deleting legitimate admins.
- No schema or migration changes; no UI changes.

## Model Used

- Claude Fable 5 (claude-fable-5, 1M context), extended thinking + tool
use, via Claude Code — this revision; original PR authored in an earlier
Claude Code session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none duplicate this; related issues Refs #966 / #5015 are
linked in the issue section)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have updated relevant documentation to reflect my changes (no
existing docs reference `cloud_tenant` mode)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:59:32 -07:00
Harshit Khemani d7f2f88323 fix(server): allow board members the null-mapped visibility actions agents already get (#7890) (#7935)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The authorization service (`server/src/services/authorization.ts`)
decides every actor's actions; `permissionForAction()` intentionally
maps read/visibility actions (`agent:read`, `issue:read`,
`project:read`, `company_scope:read`, `runtime:manage`, `secrets:read`)
to `null`, meaning "no explicit database grant required"
> - The board-actor path's `if (!permissionKey) return
deny(deny_unsupported_action)` guard caught those null-mapped actions
*before* any membership-based evaluation, contradicting the intentional
null mapping
> - Result (#7890): board users with active company membership see "You
have no agents" on the Dashboard — `filterAgentsForActor()` drops every
agent because `access.decide({action: "agent:read"})` denies
> - This pull request allows exactly those six actions for board users
with an active company membership, mirroring the agent actor path's
standard-trust policy so board and agent actors behave consistently
> - The benefit is board members can actually see their company's
agents, issues, and projects, while everything else (including
`agent:wake` and `issue:mutate`, which have no board analog today) keeps
its existing deny

## Linked Issues or Issue Description

Fixes #7890

## What Changed

- `server/src/services/authorization.ts`: inside the board path's
null-`permissionKey` branch, the six null-mapped visibility actions
(`agent:read`, `company_scope:read`, `issue:read`, `project:read`,
`runtime:manage`, `secrets:read`) now resolve via `getActiveMembership`
— active membership → `allow` with the pre-existing
`allow_simple_company_member` reason; no membership →
`deny_missing_membership`. All other null-mapped actions (`agent:wake`,
`issue:mutate`) keep `deny_unsupported_action`.
- `server/src/__tests__/authorization-service.test.ts`: three regression
tests in the existing embedded-postgres suite — member allowed the
visibility actions, non-member denied with `deny_missing_membership`,
and `agent:wake`/`issue:mutate` still denied.

## Verification

- `npx vitest run server/src/__tests__/authorization-service.test.ts` →
20 passed (17 pre-existing + 3 new) against embedded postgres.
- `pnpm --filter @paperclipai/server typecheck` → clean.
- Policy rationale: the agent actor path's standard-trust branch already
allows these same six actions company-wide (`allow_company_agent`); this
PR gives board members the identical set, per the issue's note that the
null mapping means "no explicit grant needed". `agent:wake` is self-only
for agents and `issue:mutate` is assignee-gated — neither has a board
semantic today (no route invokes them for board actors), so both
intentionally keep the unsupported-action deny.

## Risks

- This is authorization code, so reviewed conservatively: the change
only affects the board (session user) path, only for actions that
returned `null` from `permissionForAction()`, and only flips deny→allow
when an **active** company membership exists. Instance admins and
`local_implicit` boards were already allowed via earlier short-circuits.
- Viewer members keep the four read-only visibility actions but are
denied `runtime:manage` and `secrets:read` (`deny_missing_grant`),
matching the `tasks:assign` viewer carve-out in the same board block
(added in review follow-up 55f3b40).

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code, agentic
mode with tool use (subagent implementation + independent adversarial
review subagent), extended thinking enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found for #7890)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A — server-only; the UI symptom is "no agents" with no
styling change)
- [x] I have updated relevant documentation to reflect my changes (N/A —
no docs describe the board permission mapping)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:58:46 -07:00
ymmot 7058d7b6c3 fix: auto-complete approved review comments (#5839)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue lifecycle and review handoff rely on a comment-driven
"approve" gesture from the active reviewer to transition `in_review` →
`done`
> - The original auto-completion path matched approval markers loosely
and split the comment insert from the status transition, which let `NOT
APPROVED` close issues and let a 422-on-status-change leave an orphan
comment behind
> - That broke the safety expectation that a rejection comment can never
auto-complete an issue, and that observable state (comment+status)
cannot diverge from intended state
> - This pull request tightens the approval regex against negated
phrasings and wraps the comment insert + status transition + execution
decision in one transaction so a failed transition rolls the comment
back
> - The benefit is that reviewers can post negated phrasings safely, and
any failure in the auto-approval transition leaves the thread unchanged
instead of in a half-applied state

## Linked Issues or Issue Description

### What happened?

The comment-driven auto-approval path in `routes/issues.ts` had two
latent safety bugs surfaced during review:

1. The approval-detection regex matched negated phrasings such as `NOT
APPROVED`, `NOT APPROVED.`, `Do not approve`, `Not approving this`, so a
reviewer comment intended as a rejection could auto-complete the issue.
2. The auto-approval insert + status transition + execution decision
were not atomic. If the post-comment status update returned 422
(`unprocessable`), the persisted approval comment was left behind
without the corresponding state change, leaving the thread half-applied.

### Expected behavior

- Negated approval phrasings (`NOT APPROVED`, `not approved.`, `I do not
approve`, `not approving this`, etc.) must never trigger
auto-completion. Positive controls (`Approved`, `LGTM, approved`) must
continue to trigger it.
- A failed status transition must roll back the corresponding approval
comment so observable state and intended state never diverge.

### Steps to reproduce

1. Open an `in_review` issue assigned to a reviewer.
2. As the reviewer, post `NOT APPROVED` as a comment.
3. Prior to this fix: the issue auto-transitions to `done`. After this
fix: the issue stays `in_review`, the comment lands, and no transition
fires.
4. Separately, induce a 422 on the post-approval status update (e.g.
concurrent delete). Prior to this fix: the approval comment is persisted
but the issue stays `in_review`. After this fix: the comment is rolled
back along with the failed transition.

### Paperclip version or commit

Branch tip `ca60f00276` at the time of this submission. Targets
`master`.

### Deployment mode

Affects both hosted and self-hosted deployments. Behavior is server-side
only.

## What Changed

- Tightened the review-marker approval regex in
`server/src/routes/issues.ts` so it rejects negated phrasings while
preserving positive controls.
- Required structured `kind: review` / `decision: approved` metadata
adjacent to the markdown approval marker (rejects blank-separated
structured approval and mismatched actor kinds).
- Wrapped the auto-approval comment insert, status transition, and
execution decision in a single drizzle transaction in
`server/src/routes/issues.ts`, threading the transaction handle through
`addComment` in `server/src/services/issues.ts` so a concurrent delete
or 422 transition rolls back the comment.
- Added a dedicated activity log entry for the post-approval status
transition and skipped stale `issue_commented` wakes that arrive after
the auto-approval transition.
- Added 61 regression tests in
`server/src/__tests__/issue-comment-reopen-routes.test.ts` covering
negated phrasings, positive controls, structured-metadata adjacency,
actor-kind matching, atomic rollback on transition failure, and
stale-wake suppression.

## Verification

- `cd server && npx vitest run
src/__tests__/issue-comment-reopen-routes.test.ts` → 61/61 passing
locally.
- Typecheck on changed files passes locally.
- All required CI checks expected to be green on this branch tip.

## Risks

- Low risk. Changes are localized to the comment-driven auto-approval
path; existing `addComment` callers are unaffected (the new transaction
handle parameter is optional and defaults to the top-level `db`).
- The transaction wrapper changes observable timing very slightly
(single tx vs. two-step), but the only externally visible effect is
atomicity — failures now leave no orphan state.
- Regex tightening is opt-out safe (positive controls still match) but
if a reviewer in the wild used a creative phrasing not covered by the
regression set, they may need to repost as `Approved`.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities: extended thinking, tool use, code execution

## Checklist

- [x] I searched for similar open/closed PRs and confirmed this is not a
duplicate
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have considered and documented any risks above

---------

Co-authored-by: Tommy <tommy@Mac-mini-Anton.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-11 17:54:07 -07:00
Jannes Stubbemann 482f64e343 fix(plugin-kubernetes): resolve sandbox pod by exact name (controller labels pods with sandbox-name-hash, not sandbox-name) (#7982)
## Thinking Path

Production e2e on the merged #5790 plugin failed on every fresh lease
with "Failed to install the adapter runtime command" for a harness that
was present in the runtime image. Tracing the lease showed the first
exec resolved no pod: the exact-label fallback added during the #5790
review queries `agents.x-k8s.io/sandbox-name=<name>`, but the
kubernetes-sigs agent-sandbox controller labels pods only with
`agents.x-k8s.io/sandbox-name-hash` (see `sandboxLabel` in its
`controllers/sandbox_controller.go`) and NAMES the backing pod exactly
after the Sandbox CR. The selector matches nothing, `findPodForSandbox`
returns null, execute returns "podName could not be resolved", and
adapter-utils misreports it as a missing runtime command.

## What Changed

Between the `status.podName` read and the label fallback, try an
exact-name pod GET (`readNamespacedPod({namespace, name})`). This is
collision-free, so the original review concern (name-prefix matching
execing into a concurrent sandbox's pod) stays honored. A 404 falls
through to the existing full-name label selector for controller versions
that do set such a label. Non-404 errors propagate unchanged.

## Verification

- New unit test pins the controller reality: pod named exactly like the
sandbox, only a `sandbox-name-hash` label, no full-name label; fails
before the fix, passes after.
- Review-feedback round: the primary-path test now asserts the
exact-name GET is never called, and a new test covers non-404 error
propagation (403 rejects, no fallback). 153/153 plugin tests green, tsc
clean.
- Production-verified on our deployment: agent runs were broken on every
fresh lease before this patch and complete end-to-end after it (gVisor
sandbox pool, agent-sandbox controller v0.4.6; verified run with cost
event and agent reply on a fresh tenant).

## Risks

Low: one additional pod GET per first-exec on a fresh lease, only when
`status.podName` is unset. Non-404 errors from the GET propagate
unchanged (now test-pinned).

## Issue

No existing issue; the defect is described in full under Thinking Path
(introduced by the review-round fallback change in #5790, first hit in
production e2e on 2026-06-11).

## Model Used

Claude Fable 5 (claude-fable-5, Claude Code CLI, extended reasoning,
tool use)

## Duplicate search

Searched open and closed PRs for `findPodForSandbox`,
`sandbox-name-hash`, and pod-resolution fixes; no duplicate found.
Related parent: #5790 (introduced the fallback this PR repairs).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (no UI change)
- [x] I have updated relevant documentation to reflect my changes (code
comments; no doc surface affected)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 17:47:17 -07:00
Devin Foley bb7978327e fix(logger): redact passwords and tokens from HTTP error log lines (#8013)
Resubmits #5820 by @echokos. The original PR's head fork could not
accept maintainer edits (organization-owned fork without cross-org
maintainer-edit access), so we've resubmitted the commits here with
original authorship preserved. Thanks to @echokos for the contribution.

---

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies; the
server is an Express app with structured HTTP logging via pino-http.
> - The middleware in `server/src/middleware/logger.ts` defines a
`customProps` hook that attaches request context (`req.body` /
`req.params` / `req.query`) to every 4xx/5xx log entry so operators can
diagnose failed requests.
> - That hook copies the body verbatim. Better Auth's `POST
/api/auth/sign-in/email` carries an `{ email, password }` body — on a
wrong-password attempt the request lands in the 4xx branch and the
plaintext password is written to `~/.paperclip/logs/server.log`.
> - Two existing issues raise this (#3072 plaintext-password leak, #4759
similar concerns) and neither has a fix.
> - Same exposure surface applies to sign-up, reset-password, API key
creation, and any endpoint that accepts a credential in the body and can
return 4xx.
> - This pull request introduces a small `redactSensitive` walker that
returns a shallow copy of the input with values for known
credential-shaped keys replaced with `[REDACTED]`, and applies it at
every body/params/query log site in `customProps`.
> - The benefit is that operators can keep diagnostic logging on without
their disk silently accumulating user passwords and bearer tokens.

## What Changed

- `server/src/middleware/redact-sensitive.ts` (new): depth-capped,
case-insensitive walker. Sensitive keys covered: `password`,
`currentPassword`, `newPassword`, `passwordConfirmation`,
`passwordConfirm`, `confirmPassword` (+ snake_case variants), `secret`,
`client_secret`, `access_token`, `refresh_token`, `id_token`,
`auth_token`, `session_token`, `api_key`, `authorization`,
`private_key`. Bare `token` deliberately not in the list — pagination
cursors and CSRF tokens are not credentials (per Greptile review).
- `server/src/middleware/logger.ts`: wraps the six log sites in
`customProps` (3 ctx-path + 3 fallback-path) with `redactSensitive`.
- `server/src/__tests__/redact-sensitive.test.ts` (new): covers
plaintext password, case-insensitive matching, multiple credential keys,
nested objects/arrays, bare `token` left untouched, primitives
untouched, cycle safety.
- Depth-cap returns `undefined` (field absent from log line) rather than
a sentinel string, per Greptile review.

## Verification

- `pnpm --filter @paperclipai/server test redact-sensitive` should run
the new test file green.
- Manual: tail `~/.paperclip/logs/server.log`, hit `POST
/api/auth/sign-in/email` with a deliberately wrong password, confirm the
logged `reqBody.password` reads `[REDACTED]` (not the plaintext) and the
surrounding fields still appear for diagnosis.

## Risks

Low. The walker only rewrites values at the log-emit boundary — the
actual `req.body` object handed to downstream handlers is unchanged
because `redactSensitive` returns a new object. Standard log fields
(email, route path, status code) remain visible. The sensitive-key list
is conservative enough that the only risk is over-redacting a
non-credential field that happens to share a name with a known
credential; the bare `token` carve-out in this revision addresses the
most obvious such case.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), Anthropic, extended thinking
mode, working through Claude Code CLI.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [ ] I have run tests locally and they pass (no `node_modules` in my
disposable PR-prep checkout; CI vitest will exercise)
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server-only)
- [ ] I have updated relevant documentation to reflect my changes (no
doc surface affected)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Closes #3072. Refs #4759.

---

## Original Context

`customProps` in the HTTP logger copies `req.body` / `req.params` /
`req.query` verbatim into 4xx/5xx log entries. Better Auth's
wrong-password flow therefore writes:

```
{"reqBody":{"email":"…","password":"founding6gomez6croaking"},"msg":"POST /api/auth/sign-in/email 401"}
```

…to disk. This PR rewrites credential-shaped values to `[REDACTED]` at
that boundary.

---

## Cross-references and status (maintainer)

Closes #5820
Closes #3095
Closes #4760
Closes #4886

---------

Co-authored-by: Aurora <aurora@majorimpact.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 17:09:32 -07:00
AyeletMorris-ShieldFC d2ef767712 fix(heartbeat): clear orphan execution locks on every issue when a run finalizes (#4318)
> **Note (rebase, 2026-06-11):** this PR was rebased onto current
`master` again after #6008 (`Clear stale checkoutRunId on run
finalization and add backstop sweeper`) landed. See "What Changed" below
for how the previous narrow per-issue checkoutRunId clear from #6008 is
now subsumed by a single bulk-update pass over every sibling that still
references the finalizing run, with the two columns cleared in separate,
scoped UPDATEs so retry pointers are not clobbered.

## Thinking Path

> - Paperclip orchestrates AI-agent companies; issue execution ownership
is gated by `executionRunId` / `executionAgentNameKey` /
`executionLockedAt`, and any checkout whose run doesn't match the stored
`executionRunId` is rejected with 409 "Issue run ownership conflict"
> - In production, a running company silently got stuck: multiple
in-progress issues ended up with `executionRunId` pointing at heartbeat
runs that had already finalized hours earlier, so every new agent
checkout returned 409 and the issues stayed marked blocked forever
> - Root cause: `releaseIssueExecutionAndPromote` only resolved and
cleared the execution lock on one issue per finalizing run (the run's
`contextSnapshot.issueId`, or `rows[0]` when no context issue existed),
but `enqueueWakeup`'s "legacy run" fallback can stamp the same `run.id`
onto sibling issues' `execution_run_id`, so the siblings were left as
orphans
> - #4258 shipped a *reactive* fix for this bug class in `issueService`:
`clearExecutionRunIfTerminal` now self-heals a stale execution lock on
the next ownership-gated access (`checkout`, `assertCheckoutOwner`,
`release`) to each affected issue, and `release` now unconditionally
clears the three execution-lock fields
> - #6008 shipped the *symmetric* fix for the `checkoutRunId` column
(per-issue self-heal in `releaseIssueExecutionAndPromote`,
`clearCheckoutRunIfTerminal` helper, and a backstop sweeper)
> - This PR adds the *proactive* half at the point of run finalization,
and generalizes #6008's per-issue checkoutRunId clear to every sibling
that still references the finalizing run. After this PR + #4258 + #6008,
orphan locks are cleared at the moment the run ends (across both
execution and checkout columns, on every affected sibling), not only on
the next access attempt to one of them

## Linked Issues or Issue Description

- Closes #4194
- Closes #201
- Closes #3904

## What Changed

- **`server/src/services/heartbeat.ts` —
`releaseIssueExecutionAndPromote`:** lock the context issue (when set)
**and** every issue still referencing the finalizing run via either
`execution_run_id` or `checkout_run_id`, under a single `SELECT ... FOR
UPDATE ORDER BY id` (deterministic lock acquisition across concurrent
finalizations). Then issue two scoped bulk `UPDATE`s in the same
transaction:
- one clears `executionRunId` / `executionAgentNameKey` /
`executionLockedAt` on every issue whose `executionRunId` still matches
this run,
- the other clears `checkoutRunId` on every issue whose `checkoutRunId`
still matches this run.

The split avoids clobbering a retry's `executionRunId` pointer: in the
codex-transient-upstream and process-loss retry paths, `executionRunId`
is moved from this run to the retry run before
`releaseIssueExecutionAndPromote` runs, while `checkoutRunId` is left
pinned at the failed run. A single combined `UPDATE` with an `OR`
predicate would null the retry's `executionRunId` in that case — these
two scoped UPDATEs do not.

The deferred-wake promotion contract is preserved: pick the run's
context issue when present, else the first candidate (matching the
legacy `rows[0]` selection under the new ordering). Recovery-agent
fields added by a concurrent master change (`taskKey`, `recoveryAgent`,
`recoverySessionBefore`, `recoveryAgentNameKey`, and the extra
`assigneeAgentId`/`assigneeUserId` columns used downstream for
`issueNeedsImmediateRecovery`) are fully preserved through the merge.
The workspace-validation-failed recovery-comment path added by master is
also preserved on the primary issue.
- **`server/src/__tests__/execution-lock-orphan-cleanup.test.ts` (new, 6
tests):** multi-issue cleanup on finalize (2 issues); higher fan-out (4
issues) exercising the bulk `UPDATE` path; finalization of a run without
a `contextSnapshot.issueId`; cross-company isolation under a
pathologically cross-tenant `executionRunId`; unrelated-run locks are
never touched by a sibling run's finalization; and a dedicated test for
the `checkoutRunId` bulk-clear path that proves the split-UPDATE
invariant by seeding a sibling whose `executionRunId` already points at
a retry run while `checkoutRunId` is still pinned at the finalizing run
— the test asserts the retry pointer is preserved and the checkout
column is cleared.

**Not in this PR:** `server/src/services/issues.ts` is intentionally
unchanged. The release-side changes from the previous revision of this
PR are fully subsumed by #4258 (`clearExecutionRunIfTerminal` plus
unconditional clear in `release`) and #6008
(`clearCheckoutRunIfTerminal`). The per-issue checkoutRunId clear added
in `releaseIssueExecutionAndPromote` by #6008 is replaced by the bulk
path here, which strictly widens coverage from "primary issue only" to
"every sibling that still references this run".

## Verification

- `pnpm install --frozen-lockfile` — clean
- `pnpm typecheck` (server workspace) — passes on the rebased branch
- Focused suite (8 files, 147 tests — `execution-lock-orphan-cleanup`
(6), `heartbeat-run-log`, `heartbeat-run-summary`,
`issues-checkout-wakeup`, `issue-execution-policy-routes`,
`issue-agent-mutation-ownership-routes`, `issues-service`, and
`issue-stale-execution-lock-routes`): **147/147 pass**.
- `heartbeat-process-recovery.test.ts` (52 tests): 51 pass; the one
failure (`queues exactly one retry when the recorded local pid is dead`)
reproduces verbatim on raw `master` with the PR's changes reverted, so
it is a pre-existing flake (also noted by the earlier CI-retrigger
commit on this branch).
- Regression evidence: reverting `server/src/services/heartbeat.ts` to
`master` while keeping the 6 new tests causes 5 of them to fail (the
finalization-cleanup tests, including the new
checkoutRunId-pointer-preservation test; the "unrelated-run locks never
touched" test passes either way — that's its purpose as a negative
control); restoring the fix returns to 6/6 green.
- `pnpm-lock.yaml` untouched; no migration required; no public API shape
change.

Repro-ability of the original production symptom:

```
# Seed an issue with executionRunId pointing at a finalized run
# (matches what enqueueWakeup's legacy-run fallback can produce)
UPDATE issues SET execution_run_id = '<finalized-run-id>',
                  execution_agent_name_key = 'ceo',
                  execution_locked_at = NOW()
 WHERE id = '<issue-id>';
# Any subsequent svc.checkout against this issue 409s until the
# lock is cleared. Before #4258, the lock stayed forever. After
# #4258, it self-heals on next ownership-gated access. After this
# PR, it's cleared at the moment the run finalizes so an untouched
# sibling issue doesn't rely on a later access to recover.
```

## Risks

- **Same bug class exists in two adjacent, untouched code paths in this
file** — `enqueueProcessLossRetry` repoints `executionRunId` only for
the context issue (not siblings stamped with the failed run id), and
`enqueueMissingIssueCommentRetry` locks all matching issues under `FOR
UPDATE` but only updates the first row returned. Filed separately as
#4319 with exact line refs so this PR can stay narrow.
- **No `activity_log` entry is emitted for secondary orphan issues**
whose locks are cleared by the new bulk UPDATEs — only the single
primary issue retains its existing promotion event stream. Callers that
audit lock transitions purely via `activity_log` may see orphan issues
flip to `execution_run_id = null` (or `checkout_run_id = null`) without
a matching event. Easy to add a batched log line in a follow-up if audit
completeness matters; the #6008 backstop sweeper already emits
`issue.stale_lock_cleared` for the catch-up path so this is mainly an
observability nicety for the proactive path.
- **UI polling shift** — `ui/src/pages/IssueDetail.tsx` and
`ui/src/lib/issueActiveRun.ts` key off `execution_run_id` for active-run
polling; clearing orphan locks at finalize (rather than waiting for next
access as in #4258's flow) means the "executing" UI state falls back
slightly faster when the underlying run has genuinely finalized.
Observable, but an improvement over showing stuck state.
- **Lockfile and manifests untouched**; no migration required; no public
API shape change.

## Model Used

- **Provider/model:** Anthropic Claude Opus 4.7 (the model this session
is running on, per Cursor IDE system context)
- **Harness:** Cursor IDE
- **Capabilities used:** extended/reasoning thinking mode; filesystem
and shell tool use; parallel subagent orchestration for a three-lens
readonly self-review (correctness+concurrency, regression blast radius,
test adequacy+style) before the initial push; targeted rebase conflict
resolution after #4258 landed and again after #6008 landed, combining
all three branches' changes to `releaseIssueExecutionAndPromote`
- **Context:** full repository plus live access to the running Paperclip
instance that exhibited the bug (the instance was unblocked via a
targeted DB intervention before this code fix was authored; the live
observation drove the root-cause analysis)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work (the roadmap contains no references to heartbeat
execution-lock management or `releaseIssueExecutionAndPromote`)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (#4258 and #6008 are the closest prior art and are explicitly
cross-linked in the thinking path and "What Changed" sections; no other
open or merged PR touches `releaseIssueExecutionAndPromote`)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template — see "Linked Issues or Issue Description" above
- [x] I have run tests locally and they pass (typecheck workspace-wide;
147/147 in the focused suite including #4258's and #6008's new tests)
- [x] I have added or updated tests where applicable (6 regression
tests; 5 of them provably fail on `master` without the code change)
- [ ] If this change affects the UI, I have included before/after
screenshots — *n/a, this is a server-only change; any UI polling effect
is documented under Risks*
- [ ] I have updated relevant documentation to reflect my changes —
*n/a, no user-facing or API docs reference
`releaseIssueExecutionAndPromote`*
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (the prior `verify` flake is
unrelated to this PR's change path and reproduces on unrelated branches;
documented above and in follow-up #4328)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(the only test-coverage gap Greptile flagged on the latest review — the
`checkoutRunId` bulk-clear branch — is now covered by the new 6th test
in this revision)
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 16:39:47 -07:00
scotttong 6f9801a46b feat(ui): NUX rework behind enableConferenceRoomChat experimental flag — capsule onboarding, conference-room chat, unified composer (#8000)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The first-run experience (onboarding wizard) and the chat surfaces
(conference-room/board chat, task threads, composers) are the product's
front door — they decide whether a new operator understands "hire
agents, give them work, review results" in the first five minutes
> - Today those surfaces feel ticket-y and form-like: the wizard is a
static multi-step form that ends in an anticlimactic "Launch" screen,
the task composer and board chat behave differently from each other, and
agent-feed issue quicklooks misbehave (multiple flyouts open at once,
cards jump on hover)
> - We wanted to iterate toward a conversational, team-centric NUX — but
without risking the workflows of everyone already running Paperclip
> - This PR reworks the NUX behind a new default-OFF
`enableConferenceRoomChat` experimental flag: a capsule-motif onboarding
wizard that builds your team as you answer, a conference-room chat
surface, one shared ChatComposer across surfaces, brand-accurate status
chips, and feed-quicklook fixes — with the pre-existing UI
fork-and-frozen as `*Classic` components that flag-OFF users keep
> - The benefit is a complete, testable modern NUX that anyone can opt
into from Settings → Experimental, with zero default behavior change and
a clean path to either graduate or drop the experiment

## Linked Issues or Issue Description

No pre-existing GitHub issue — feature description per
`feature_request.yml`:

- **Problem / motivation:** Paperclip's onboarding wizard and chat
surfaces grew up as separate ticket-centric forms. New users get a
form-filling experience rather than the feeling of standing up a team;
the board chat and task threads use different composers with different
affordances; the agent feed's issue quicklook can stack multiple
popovers and shifts cards on hover.
- **Proposed solution:** A coherent NUX experiment behind one
experimental flag (`enableConferenceRoomChat`, Settings → Experimental,
default OFF): capsule onboarding wizard with an evolving team capsule,
conference-room chat, unified `ChatComposer`, team-centric copy, brand
status chips, quicklook single-flight fix. Flag-OFF users get the exact
pre-experiment UI via frozen `*Classic` forks, verified by an on/off
parity test matrix.
- **Alternatives considered:** (a) incremental unflagged restyling —
rejected: the changes interlock across surfaces and would drip risk into
every release; (b) a separate app shell / route for the new NUX —
rejected: too much divergence, the flag + classic-fork pattern keeps the
diff reviewable and reversible.
- **Roadmap alignment:** `ROADMAP.md` lists **CEO Chat** ("a
lighter-weight way to talk to leadership agents... should still resolve
to real work objects"). This experiment is groundwork in that direction
(conference-room chat resolves to issues/tasks via the same composer
used in task threads) and does not change the core task-and-comments
model.

Related PRs found in the dedup search (same area, none duplicate this
work — they target the classic wizard, which this PR intentionally
leaves intact and mergeable):

- #5385 — Coach-driven onboarding: conversational entry +
agent-companies package import
- #5378 — Onboarding wizard: reusable adapter picker + probe card
- #6636 — ui(onboarding): friendly error surface + retry for the wizard
- #7005 — fix(onboarding): explicitly await first-task wake
- #2616 — fix: restore workspace directory config in onboarding wizard

## What Changed

- **Experimental flag plumbing** — `enableConferenceRoomChat` in shared
types/validators, server instance-settings service + API, Settings →
Experimental card with explicit enable/disable copy
- **Onboarding wizard** — classic wizard forked and frozen
(`OnboardingWizardClassic`); flag-ON variant is a 5-step capsule wizard
with a persistent evolving `AgentCapsule` (gradient/glow motif),
team-centric reframed copy, and a typing-dots intro (hardened with
fake-timer tests)
- **Conference-room chat** — flag-ON board-chat surface with agent
bubble name/icon headers and copy/vote/timestamp action rows
(`AgentBubbleActionRow`)
- **Unified composer** — shared `ChatComposer` adopted across surfaces;
translucent surface + scroll-mask removal; "Agent mode"/"Plan mode"
relabels; no-assignee confirmation `AlertDialog` (new
`ui/alert-dialog.tsx` primitive); `@task` reference picker +
linkification in mentions
- **Agent feed** — single-flight issue-quicklook store (one popover at a
time), flyouts open to the left, removed hover translate-y jitter
- **Status chips** — brand-accurate task status chips behind the flag
(light/dark, 1px borders per paperclip.ing/brand)
- **Tests** — flag on/off parity matrix across IssueDetail,
NewIssueDialog, Sidebar, wizard, gate components; component tests for
all new pieces
- **Merge with `master`** — one conflict in
`ui/src/components/IssueChatThread.tsx`, resolved by keeping master's
new `AssigneeChip`/`HandoffWakeRow`/`RunStatusBadge` components inside
the flag-gated metadata-row chrome (details in commit `21a5642a`);
post-merge fixes: vitest 4 mock typing in `MarkdownEditor.test.tsx`,
flag hook made safe for provider-less mounts (master's new isolated
component tests)
- **Branch hygiene** — internal design wireframes/mockups stripped
before the PR (they live in the Paperclip issue threads)
- No user-facing documentation changes required: the flag is
intentionally experimental and self-described in the Settings card; no
existing docs reference the affected surfaces

## Verification

- `pnpm run typecheck` — green across the workspace (ui, server, shared,
plugins)
- Full UI suite (`vitest run` in `ui/`, clean worktree at this HEAD):
**1593/1595 passing, 223/224 files** — the 2 remaining failures are in
`src/components/artifacts/ArtifactCard.test.tsx` and **fail identically
on pristine `origin/master`** (pre-existing upstream, unrelated to this
branch)
- Full server suite (`vitest run` in `server/`, same clean worktree):
results in PR checks; flag plumbing covered by instance-settings tests
- Targeted post-merge resolution check: `IssueChatThread`,
`IssueChatThreadSystemNotice`, `IssueDetail`, `Sidebar`,
`ConferenceRoomChatGate`, `OnboardingWizardVariant`, `NewIssueDialog`,
`InstanceExperimentalSettings`, `MarkdownEditor` — 172/172 passing
- Manual walkthrough: flag OFF (default) → onboarding wizard, task
thread, board chat, composer all render the classic UI; flag ON via
Settings → Experimental → capsule wizard, conference-room chat, unified
composer, status chips active
- Screenshots: see below

**Flag on/off screenshots** (committed on this branch under
`screenshots/PR-8000-*`):

| Surface | Flag OFF (classic, default) | Flag ON (experimental) |
| --- | --- | --- |
| Settings → Experimental | ![settings
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-off.png)
| ![settings
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-on.png)
|
| Task thread | ![thread
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-off.png)
| ![thread
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-on.png)
|
| Home / nav | ![home
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-off.png)
| ![home
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-on.png)
|
| Conference Room (flag-ON only surface) | — | ![conference
room](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-conference-room-flag-on.png)
|

Capsule onboarding wizard walkthrough screenshots (flag ON) are attached
to the Paperclip design/implementation threads; the wizard requires a
fresh instance so it is captured via the e2e harness
(`tests/e2e/nux-phase4-screenshots.spec.ts`).


## Risks

- **Large surface, but gated:** all new behavior sits behind
`enableConferenceRoomChat`, default OFF; flag-OFF rendering is locked by
frozen `*Classic` forks plus an on/off parity test suite
- **Classic forks are frozen at the fork point (`e3aada1d`):** master
features added to the live thread component after that point (assignee
handoff chips, run status badge, composer mention coach) render in the
flag-ON path; the flag-OFF task thread keeps the fork-point behavior
until the experiment graduates (forks deleted) or is dropped (forks
restored as canonical). Called out for reviewer attention.
- **Merge-conflict resolution in `IssueChatThread.tsx`** (commit
`21a5642a`) deserves reviewer eyes: master's new handoff/run-status
components were kept; the base toast-style no-assignee flow remains
replaced by the AlertDialog flow introduced on this branch
- Schema/server changes are additive (one optional boolean instance
setting); no migrations of existing data

## Model Used

- Claude (Anthropic) via Claude Code running in the Paperclip agent
harness (agent: ClaudeCoder)
- Branch implemented across multiple agent sessions on Claude Opus-class
models with extended thinking + tool use (file edits, shell, Playwright
screenshots); merge/PR session model ID as reported by the harness:
`claude-fable-5` (Claude Code CLI)
- All code was agent-authored and board-reviewed through Paperclip issue
threads (plans, wireframes, confirmations) before merging

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes (none
required — experimental flag, self-documenting Settings card; noted
above)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (run 3 on `8af3041a`: all 16
gates SUCCESS, incl. e2e and all 4 serialized-suite shards)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(re-review verdict: Confidence 5/5, “Safe to merge”; all 4 round-1
findings fixed + confirmed resolved; both summary notes addressed in
`8af3041a`)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:32:55 -05:00
Dotta 1413729a06 Build the Skills Store (#7990)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents increasingly depend on reusable skills, so the control plane
needs a first-class way to browse, inspect, install, version, and attach
those skills.
> - The old skills surface was mostly operational plumbing; it did not
give operators a store-like discovery flow, canonical detail URLs, rich
source/version context, or creation paths.
> - The backend also needed stronger contracts around company skill
metadata, versions, install counts, runtime materialization, and adapter
skill preferences.
> - This pull request builds the Skills Store foundation across DB,
shared contracts, server routes/services, UI, and Storybook.
> - The benefit is a more inspectable, operator-friendly skill workflow
that still preserves company-scoped control-plane boundaries and agent
runtime behavior.

## Linked Issues or Issue Description

No GitHub issue exists for this Paperclip work item. Paperclip task
refs: PAP-10846 and PAP-10921.

Feature request:
Paperclip operators need a single Skills Store experience where company
skills can be discovered, inspected, created, versioned, installed, and
attached to agents without relying on scattered operational screens or
implicit runtime state.

Related PR search:
- Searched GitHub for `Skills Store`, `company skills`, and `skill
detail`.
- Found several open skills-related PRs such as #7809 and #4409, but no
duplicate PR for this end-to-end Skills Store branch.

## What Changed

- Added the Skills Store backend foundation: company skill schema
fields, migrations, shared types/validators, and expanded server skill
routes/services.
- Added skill discovery, category navigation, canonical skill detail
routes, tabs, source attribution, version snapshots/diffs, install count
backfill, and creation flows.
- Updated agent skill preference handling so version selections survive
runtime mention injection and runtime skill materialization honors
pinned versions.
- Preserved unversioned skill assignments as live/current selections
instead of silently pinning them to the current version at assignment
time.
- Added focused regression coverage for company skill routes/services,
route helpers, UI behavior, skill version diffs, and runtime skill
version pins.
- Added Storybook coverage for Skills Store discovery/detail states and
updated the main layout navigation.
- Addressed Greptile findings around version creation races,
soft-deleted comments, fork metadata scoping, GitHub skill directory
fallback, runtime snapshot materialization, shared runtime
skill-selection helpers, and version-assignment semantics.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-project-env.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
packages/shared/src/validators/company-skill.test.ts`
- `pnpm exec vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run
server/src/__tests__/agent-skills-routes.test.ts`
- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx -t
"edits existing custom assignee model options from the properties pane"`
- `pnpm --filter @paperclipai/server typecheck`
- GitHub checks are green on `0823957a2`: Build, Canary Dry Run, General
tests, Typecheck + Release Registry, serialized server suites, e2e,
policy/review, Socket, Snyk, and aggregate `verify`.
- Greptile Review succeeded on `0823957a2` with `40 files reviewed, 0
comments added`; GitHub unresolved review threads: 0.

Not run in this heartbeat:
- Browser screenshot capture for the UI changes. This PR intentionally
omits screenshots per the Paperclip task direction not to add design
screenshots/images.

## Risks

- Broad feature branch touching DB, shared contracts, server, and UI;
reviewers should still scan merge conflicts carefully if `master` moves
again before landing.
- Skill version/runtime behavior is sensitive: pinned skill versions
must stay pinned while default selections should continue following the
current version.
- UI polish should get normal reviewer/browser attention before merge
because this PR includes a large Skills Store surface and screenshots
were intentionally omitted.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent with tool use and local command
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (intentionally omitted per PAP-10921 direction)
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:02:09 -05:00
Jannes Stubbemann 69a368ed51 fix(gemini-local): pre-select gemini-api-key auth in managed-HOME settings.json for headless runs (#7918)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The gemini-local adapter runs gemini-cli headlessly, including on
remote/sandboxed execution targets where the adapter manages a dedicated
HOME under the runtime root
> - gemini-cli hard-refuses headless runs with "Invalid auth method
selected." unless `$HOME/.gemini/settings.json` persists an auth
selection; setting `GEMINI_DEFAULT_AUTH_TYPE` alone does NOT satisfy it
(proven in an isolated pod)
> - With a managed HOME the runtime root replaces the image home, so any
settings.json baked into the agent image (or the user's real home) is
invisible to the CLI, and every sandboxed gemini run dies before doing
any work
> - This affects any sandbox provider that runs gemini with API-key auth
through the managed-HOME path (SSH, E2B, Daytona, Kubernetes, or any
other remote execution target); it is a headless-execution bug fix, not
gateway- or deployment-specific behavior
> - This pull request makes the adapter pre-select the `gemini-api-key`
auth type in the managed `$HOME/.gemini/settings.json` whenever a
Gemini/Google API key is present, writing both settings schema
generations and never touching an existing settings.json
> - The benefit is that gemini agents actually run headlessly on remote
and sandboxed execution targets without any manual settings provisioning

## Linked Issues or Issue Description

No existing issue; describing the bug in-PR (bug template fields):

- **What happened:** Headless gemini-local runs on remote/sandboxed
execution targets fail immediately with `Invalid auth method selected.`
even though `GEMINI_API_KEY` is provided.
- **Expected:** Providing the API key should be enough for a headless
run to authenticate and proceed.
- **Root cause:** gemini-cli requires an auth selection persisted in
`$HOME/.gemini/settings.json` for non-interactive runs; the
`GEMINI_DEFAULT_AUTH_TYPE` env var does not substitute for it (verified
in an isolated pod with only the env var set). The adapter's
managed-HOME execution path points HOME at the runtime root, so any
pre-existing settings.json (image-baked or user home) is hidden and the
CLI finds no auth selection.
- **Reproduction:** Run the gemini-local adapter against a
remote/sandboxed execution target with `GEMINI_API_KEY` set and no
settings.json under the managed HOME; the run aborts with the error
above.
- Duplicate/related search: no existing PR or issue addresses this;
closest related is #7693 (bundles gemini-cli in the Docker image), which
makes the CLI available but does not fix headless auth selection.

## What Changed

- `packages/adapters/gemini-local/src/server/execute.ts`: after
provisioning the managed HOME, when a Gemini/Google API key is present,
write `$HOME/.gemini/settings.json` pre-selecting `gemini-api-key` auth.
Both settings schema generations are written (legacy top-level
`selectedAuthType` and current `security.auth.selectedType`) so old and
new gemini-cli versions are covered.
- The write is strictly scoped to the managed HOME (the per-run runtime
root on sandbox transports). On non-managed remote targets (SSH), where
the remote home is the user's real home and existing settings remain
visible to the CLI, the adapter creates nothing (review feedback, P1).
- The write is guarded by `[ -f ... ] ||` so a user-shipped
settings.json (e.g. via workspace) is never overwritten.
- The key-presence gate checks the run env AND the host process env
(`GEMINI_API_KEY` / `GOOGLE_API_KEY`): in sandboxed paths the key never
enters the adapter's run env; it reaches the agent pod via the sandbox
provider's per-run secret (env passthrough from the host env), so the
host env is the correct signal there.
- `packages/adapters/gemini-local/src/server/execute.remote.test.ts`: a
new sandbox-transport test asserts the settings.json write lands under
the per-run runtime root (path + `gemini-api-key` content), and the SSH
test asserts no settings.json is created on a non-managed home.

## Verification

- `npx vitest run packages/adapters/gemini-local`: 3 files, 17 tests,
all pass.
- `pnpm --filter @paperclipai/adapter-gemini-local typecheck` and
`build`: clean (test file is covered by the package tsconfig `include`).
- Negative control: in an isolated pod, gemini-cli with `GEMINI_API_KEY`
+ `GEMINI_DEFAULT_AUTH_TYPE` set but no settings.json still fails with
`Invalid auth method selected.`; with the settings.json written by this
change, the run proceeds.
- Verified end-to-end: a gemini agent in a hardened Kubernetes (gVisor)
sandbox completed a real task (with `GOOGLE_GEMINI_BASE_URL` pointing at
a GenAI-compatible endpoint), producing a billed usage row. That
deployment supplies the verification evidence; the fix applies to any
sandbox provider running gemini with API-key auth.

## Risks

- Low risk. The new write only fires on the managed-HOME path (per-run
runtime root) when an API key is present, and only when no settings.json
exists yet, so existing setups, real user homes on SSH targets, and
user-provided settings are unaffected.
- If a future gemini-cli changes the settings schema again, the file may
need a third generation key; both current generations are written today.

## Model Used

- Claude (Anthropic), Claude Opus 4.8, 1M context, extended thinking,
with tool use (code execution / shell) via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (no UI change)
- [x] I have updated relevant documentation to reflect my changes (code
comments document the behavior; no doc pages cover managed-home auth)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(review requested)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:14:05 -07:00
Jannes Stubbemann 9e750d3e92 feat(codex-local): env-driven gateway routing via PAPERCLIP_CODEX_PROVIDERS config.toml (#7919)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `codex-local` adapter runs the OpenAI Codex CLI; Paperclip
already maintains a managed `CODEX_HOME` per company and ships it to
remote/sandboxed execution targets
> - Deployments increasingly put an OpenAI-compatible LLM gateway
between the harness and the model for cost, governance, or
data-residency reasons: LiteLLM, OpenRouter, Portkey, Kong, a corporate
proxy, self-hosted models (vLLM/Ollama), or region-pinned/sovereign
endpoints. But Codex has no CLI flag or env var for a custom endpoint:
its only mechanism is `[model_providers.<id>]` tables (with `base_url`,
`env_key`, `wire_api`) in `$CODEX_HOME/config.toml`, selected by a
root-level `model_provider` key
> - Today there is no supported way to get such provider config into the
managed `CODEX_HOME`, so gateway routing requires hand-editing files the
adapter owns and regenerates
> - This pull request adds the codex analogue of #7837's opencode
mechanism: a `PAPERCLIP_CODEX_PROVIDERS` JSON env var whose shape maps
1:1 onto codex's TOML schema, merged into the managed `config.toml` so
the existing asset-shipping + `env.CODEX_HOME` mechanics deliver it to
local and sandboxed runs alike; nothing here is specific to one hosting
setup
> - The benefit is Codex works behind any OpenAI-compatible gateway with
config only; with no env set, behavior is unchanged

## Linked Issues or Issue Description

No existing issue; describing in-PR (feature / adapter enhancement).

- **Gap:** there is no supported way to register a custom/gateway
`[model_providers.*]` endpoint for `codex-local`. Codex's only
custom-endpoint mechanism is `config.toml` (`base_url` + `env_key` +
`wire_api`, selected via the root `model_provider` key), and the adapter
owns/regenerates the managed `CODEX_HOME`, so operators cannot durably
hand-edit it.
- Related: #7837 (the opencode-local analogue of this change, same
env-driven gateway-routing pattern). Searched for duplicate/related PRs:
no existing codex-local gateway/provider-routing PR found.

> Note on ROADMAP: this is adapter-level, opt-in config (defaults
unchanged) that *enables* gateway routing for one harness; it is not the
core "Cloud / Sandbox agents" platform work itself.

## What Changed

- New `prepareCodexRuntimeConfig()`
(`packages/adapters/codex-local/src/server/runtime-config.ts`): reads
`PAPERCLIP_CODEX_PROVIDERS` (run env first, then `process.env`), shaped
as `{"providers": {"<id>": {base_url, env_key, wire_api, ...}},
"model_provider": "<id>"}`, and merges it into the managed
`CODEX_HOME`'s `config.toml`. No-op when unset or empty.
- A malformed value (invalid JSON, not a JSON object, no `providers`
object, no usable provider entries, or individual entries with empty
names or non-object values, which are skipped by name) is never silently
dropped: each case surfaces a distinct, user-visible note (via the
prepare notes, which flow into command notes + `onLog`) and unusable
input leaves `config.toml` untouched.
- Merge is marker-delimited and TOML-correct: existing `config.toml`
content is preserved between two managed blocks. Root keys (e.g.
`model_provider`) are prepended **before the first table header** (TOML
root-region rule), `[model_providers.*]` tables are appended.
Pre-existing same-name provider sections and root `model_provider` keys
are excised so the managed definitions win without duplicate-table parse
errors.
- `{env:VAR}` placeholders are expanded server-side for
literal-credential fields; `env_key` indirection remains the preferred
path.
- Crash-safe restore: prepare writes a pre-run backup
(`config.toml.paperclip-backup`) before the merged file; `cleanup()`
restores the original in the execute `finally` and removes the backup.
If a run never reaches `cleanup()` (a throw during the setup between
prepare and execution, or SIGKILL), the next prepare restores the
original from the backup with full fidelity, including user
`[model_providers.*]` sections the merge excised (review feedback, P2);
plain block-stripping remains the fallback for pre-backup state.
- An explicit adapter-config `env.CODEX_HOME` override is treated as
user-managed: no merge, surfaced as a command note.
- Dependency-free hand-emitted TOML (strings/numbers/booleans, arrays of
scalars, plain objects as inline tables); basic strings escape
U+0000-U+001F and U+007F per TOML 1.0 (review feedback, P2). Merged
output was additionally validated locally with python tomllib during
development; the committed tests assert the structural invariants.
- `execute.ts` wiring: `prepareCodexRuntimeConfig` runs after
`prepareManagedCodexHome` (before the home ships to the remote target),
notes surface via `onLog` + command notes, and the `finally` calls
`cleanup()`.

**Note for reviewers:** current codex removed `wire_api = "chat"`
(openai/codex#10157, Feb 2026), so gateway provider configs must use
`wire_api = "responses"`, i.e. the gateway must speak `/v1/responses`.
The adapter passes the value through verbatim; this is a codex-side
constraint worth knowing when configuring it.

## Verification

- `pnpm --filter @paperclipai/adapter-codex-local build` and
`typecheck`: tsc clean against current `master`
- `pnpm exec vitest run packages/adapters/codex-local`: 45 passing
(incl. 17 `runtime-config` tests: fresh-merge + cleanup restore,
root-region placement, same-name provider override, inline
tables/arrays, DEL escaping, `{env:}` expansion from run env +
`process.env`, per-case malformed-input notes with `config.toml`
untouched, skipped-entry notes alongside a successful merge, silent
no-op when unset/empty, explicit-`CODEX_HOME` skip note, backup restore
of excised user sections after an interrupted run, backup removal on
cleanup, stale-block self-heal, re-run replacement)
- Verified end-to-end: a codex agent in a hardened Kubernetes (gVisor)
sandbox completed a real task routed through an OpenAI-compatible
gateway's `/v1/responses`, with a billed usage row recorded on the
gateway. That deployment supplies the verification evidence; the
mechanism is gateway-agnostic.

## Risks

Low. Entirely env-driven and opt-in; with `PAPERCLIP_CODEX_PROVIDERS`
unset the adapter never touches `config.toml` and behavior is
byte-identical to before. The merge preserves user content, restores the
original file on cleanup, and survives interrupted runs via the pre-run
backup; malformed input surfaces a visible note and is ignored without
touching `config.toml`. No migration/UI impact.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking +
tool use, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md (adapter-level opt-in config enabling
gateway routing; not the core sandbox-platform work, noted above)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (#7837 is the opencode analogue; no codex-local duplicate
found)
- [x] I have either (a) linked existing issues OR (b) described the
issue in-PR following the relevant issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (n/a, no UI)
- [ ] I have updated relevant documentation to reflect my changes (env
var documented inline; no central doc references the adapter env yet)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (green on the previous head;
re-running on the final note-copy polish commit)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(both review P2s are fixed at head: the interrupted-run restore via the
pre-run backup and the U+007F escaping; a re-review is requested for the
note-copy polish)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:13:31 -07:00
Jannes Stubbemann 6e4aca9c67 feat(pi-local): env-driven gateway routing via PAPERCLIP_PI_PROVIDERS models.json (#7920)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `pi-local` adapter runs the Pi coding agent, including inside
remote/sandboxed execution targets; Pi resolves `--provider P --model M`
by an exact (provider, id) match against its model registry, and it has
no base-url CLI flag or env var: a `models.json` in its agent config dir
(`$PI_CODING_AGENT_DIR`, falling back to `$HOME/.pi/agent`) is its only
mechanism for custom or OpenAI/Anthropic-compatible endpoints
> - Deployments increasingly put an LLM gateway between the harness and
the model for cost, governance, or data-residency reasons: LiteLLM,
OpenRouter, Portkey, Kong, a corporate proxy, self-hosted models
(vLLM/Ollama), or region-pinned/sovereign endpoints. Today there is no
supported way to get such provider config into Pi's registry for
orchestrated runs
> - The opencode adapter gained the equivalent capability in #7837 and
codex in #7919; this pull request is the Pi analogue, so the harness
layer stays gateway-agnostic regardless of which CLI an agent uses;
nothing here is specific to one hosting setup
> - This pull request reads `PAPERCLIP_PI_PROVIDERS` (Pi's `models.json`
`providers` shape), materialises a managed `models.json` in a temp
agent-config dir, points `PI_CODING_AGENT_DIR` at it, and ships it to
remote execution targets with the run
> - The benefit is Pi works behind any compatible gateway with config
only; with no env set, behavior is unchanged

## Linked Issues or Issue Description

No existing issue; describing in-PR (feature / adapter enhancement).

- **Gap:** there is no supported way to register custom/gateway
providers + models for `pi-local`. Pi's only custom-endpoint mechanism
is a `models.json` in its agent config dir, and orchestrated (especially
sandboxed) runs have no way to provision one declaratively.
- Related: #7837 (the opencode-local analogue, same env-driven
gateway-routing pattern) and #7919 (the codex-local analogue). Searched
for duplicate or related PRs: no existing pi-local
gateway/provider-routing PR found.

> Note on ROADMAP: this is adapter-level, opt-in config (defaults
unchanged) that *enables* gateway routing for one harness; it is not the
core "Cloud / Sandbox agents" platform work itself.

## What Changed

- New `packages/adapters/pi-local/src/server/runtime-config.ts`:
`preparePiRuntimeConfig()` reads `PAPERCLIP_PI_PROVIDERS` (a JSON object
in pi's `models.json` `providers` shape) from the run env, then
`process.env`. When set, it expands `{env:VAR}` placeholders (run env
first, then process env; unresolvable placeholders left intact), writes
`{"providers": ...}` to a managed temp dir as `models.json`, and returns
env with `PI_CODING_AGENT_DIR` pointing at it plus a cleanup handle.
- `execute.ts`: the prepared dir ships to remote execution targets as
the managed-runtime asset `agentConfig` (same mechanism as opencode's
`xdgConfig`), and `PI_CODING_AGENT_DIR` is repointed to the in-target
path; cleanup runs in `finally`.
- Misconfiguration is visible, not silent: a set-but-unusable
`PAPERCLIP_PI_PROVIDERS` (invalid JSON, not an object, no provider
objects) surfaces an explanatory note instead of proceeding unconfigured
into an opaque model-not-found failure later, and provider entries with
non-object values are skipped with a note naming them. Unset/empty stays
a silent no-op (feature off).
- Defaults unchanged: with `PAPERCLIP_PI_PROVIDERS` unset, the adapter
behaves byte-for-byte as before, for local runs and for every existing
sandbox provider.

## Verification

- All pi-local tests green against this base (new: providers written
verbatim, `{env:VAR}` expansion from run env/process env/unresolvable,
no-op when unset, `PI_CODING_AGENT_DIR` set and shipped, the
misconfiguration notes incl. skipped non-object entries, remote asset
sync + env repoint). Typecheck and build clean.
- Production end-to-end evidence (our deployment, used as verification,
not as the scope of the change): a pi agent in a Kubernetes gVisor
sandbox resolved a custom provider from the shipped `models.json`,
completed an assigned issue through an Anthropic-compatible gateway, and
landed a billed usage row.

## Risks

Low. The entire feature is opt-in behind one env var; the only behavior
change when it is set is the intended one. The managed dir replaces the
host agent dir for the run by design (credentials travel inside the
provider config or via env-key indirection), which is the correct
posture for orchestrated runs. No migration/UI impact.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking +
tool use, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md (adapter-level opt-in config enabling
gateway routing; not the core sandbox-platform work, noted above)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (#7837 and #7919 are the opencode/codex analogues; no
pi-local duplicate found)
- [x] I have either (a) linked existing issues OR (b) described the
issue in-PR following the relevant issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (n/a, no UI)
- [ ] I have updated relevant documentation to reflect my changes (env
var documented inline; no central doc references the adapter env yet)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (green on the previous head;
re-running on the final note-copy polish commit)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(both prior review findings are fixed at head: the indirect notes-based
guard is now an explicit `agentConfigDir` handle, and a failed
`models.json` write no longer leaks the temp dir; a re-review is
requested for the note-copy polish)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:12:23 -07:00
Jannes Stubbemann 1ac1ba5442 feat(opencode-local): env-driven gateway routing (custom providers, small/cheap model, remote allow-all) (#7837)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `opencode-local` adapter runs the OpenCode harness; its
model/provider routing assumes built-in providers (anthropic/openai/...)
and their default models
> - Deployments increasingly put an OpenAI/Anthropic-compatible LLM
gateway between the harness and the model for cost, governance, or
data-residency reasons: LiteLLM, OpenRouter, Portkey, Kong, a corporate
proxy, self-hosted models (vLLM/Ollama), or region-pinned/sovereign
endpoints. But OpenCode only resolves `--model provider/model` when the
model is registered in a provider's `models` map, and
`OPENCODE_ALLOW_ALL_MODELS` does NOT bypass its internal `getModel()`
> - Several lanes also fall back to built-in default models the gateway
may not serve: the auxiliary/title model (e.g. `claude-haiku-*`) and the
budget/recovery "cheap" lane (`openai/gpt-5.1-codex-mini`); these abort
runs with "no keys found that support model"
> - This pull request makes the adapter's provider/model wiring
declarative via env, so any such deployment can register gateway models
+ pin the auxiliary/budget lanes without code changes; nothing here is
specific to one hosting setup
> - The benefit is OpenCode works behind any compatible gateway with
config only; with no env set, behavior is unchanged

## Linked Issues or Issue Description

No existing issue; describing in-PR (feature / adapter enhancement).

- **Gap:** there is no supported way to register custom/gateway
providers + models for `opencode-local`, nor to pin the auxiliary
(title-gen) and budget (recovery) model lanes, so routing OpenCode
through a gateway fails at `getModel()` or on the default helper models.
- Related: #5737 (exe.dev sandbox installs for gemini/opencode local),
#5823 (unblock claude_local on remote sandbox providers).

> Note on ROADMAP: this is adapter-level, opt-in config (defaults
unchanged) that *enables* gateway routing for one harness; it is not the
core "Cloud / Sandbox agents" platform work itself. Happy to
redirect/discuss in #dev if preferred.

## What Changed

- `PAPERCLIP_OPENCODE_PROVIDERS`: merge custom/extended providers
(OpenCode `provider` shape) into the runtime `opencode.json`, so gateway
models are registered and `--model provider/model` resolves. `{env:VAR}`
placeholders are expanded server-side (so a key need not depend on the
sandbox run env).
- A malformed `PAPERCLIP_OPENCODE_PROVIDERS` is no longer silently
ignored: invalid JSON, a non-object value, and individual provider
entries with non-object values (which are skipped by name) each append a
visible note to the run notes so the misconfiguration is diagnosable
(addresses both review P1s).
- `PAPERCLIP_OPENCODE_SMALL_MODEL` / `PAPERCLIP_OPENCODE_CHEAP_MODEL`:
pin the auxiliary (title-generation) and budget (recovery-retry) lanes
to gateway-served models; defaults unchanged.
- Honour `OPENCODE_ALLOW_ALL_MODELS` on the **remote** execution path
too (was local-only, a parity gap).
- `PAPERCLIP_OPENCODE_PRINT_LOGS`: optional toggle adding `--print-logs`
so OpenCode logs surface on stderr for diagnosing remote/sandbox runs.
- `buildOpenCodeModelProfiles()` guards its `process.env` default with
`typeof process` so the shared client/server module stays browser-safe
(a bare `process.env` at module load threw ReferenceError in the browser
under Vite dev middleware and broke UI rendering in the e2e lane).

## Verification

- `pnpm --filter @paperclipai/adapter-opencode-local build` and
`typecheck` (tsc clean)
- `pnpm exec vitest run packages/adapters/opencode-local/src` shows 33
passing (incl. new tests for the provider merge, `{env:}` expansion, the
malformed/non-object/skipped-entry provider notes, small/cheap-model
resolution, and the remote allow-all bypass)
- Manually verified end-to-end against a real
OpenAI-/Anthropic-compatible gateway: with the providers + small/cheap
model set, both the title-gen and main task route to the configured
gateway model and the agent completes (a real completion is returned and
billed). That deployment supplies the verification evidence; the
mechanism is gateway-agnostic.

## Risks

Low. Everything is env-driven and opt-in; with no env set the generated
config output is unchanged, and the cheap model profile keeps its model
(the only difference is its updated human-readable description).
Defaults preserved: built-in providers, Codex-mini cheap lane with
`variant: low`, no `--print-logs`. No migration/UI impact.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking +
tool use, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md (adapter-level opt-in config enabling
gateway routing; not the core sandbox-platform work, noted above)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (#5737, #5823)
- [x] I have either (a) linked existing issues OR (b) described the
issue in-PR following the relevant issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (n/a, no UI)
- [ ] I have updated relevant documentation to reflect my changes (env
vars documented inline via comments; no central doc references the
adapter env yet)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(the P1 about silently dropped malformed providers JSON is addressed in
6eeb803, the follow-up P1 about silently skipped non-object entries in
2f4045a; the latest review has no further findings, and a re-review is
requested for the final note-copy/test-fixture polish at head)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:11:49 -07:00
Jannes Stubbemann 398d746093 build(agent-runtime): harness runtime images for sandboxed execution (stage 3/3) (#7934)
> [!NOTE]
> This is **stage 3 of 3** of the staged Kubernetes contribution: stage
1 is the kubernetes sandbox-provider plugin (#5790), stage 2 is the
provider backend/hardening refresh filed separately, and this stage
ships the runtime images those sandboxes run.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandboxed agent execution (Refs #248) runs each agent turn in an
isolated environment; the kubernetes sandbox provider (stage 1, #5790)
schedules those runs as hardened pods
> - A sandbox pod needs a runtime image with the harness CLI
preinstalled: installing CLIs at run start is slow, flaky, and needs
network egress the sandbox should not have
> - There is no first-party image family for this, so every deployer
would have to hand-roll Ubuntu + Node + CLI images per harness and solve
signal handling, non-root, and image chaining themselves
> - This PR ships the agent-runtime image family: a hardened base
(non-root uid 1000, tini, git, the agent shim) plus one derived image
per harness, a buildx bake file that chains them, and a publish workflow
with cosign keyless signing
> - The benefit is that any sandbox infrastructure, the kubernetes
provider or otherwise, gets ready-made, signed, security-hardened
per-harness runtime images that are verified in production across five
harnesses

## Linked Issues or Issue Description

Refs #248 (sandboxed agent execution proposal) and #5790 (the kubernetes
sandbox provider, stage 1 of this contribution, which consumes these
images as per-run runtime images via its adapter defaults).

No issue covers the image gap itself, described in-PR: sandbox providers
reference `ghcr.io/paperclipai/agent-runtime-*` images, but the
repository contains neither the Dockerfiles nor the workflow that builds
and publishes them. Without this, self-deployers cannot reproduce or
audit the images their agent runs execute in.

## What Changed

- `docker/agent-runtime/Dockerfile.base`: foundation image. Ubuntu 22.04
+ Node 22 + git + tini (PID 1, signal propagation) + non-root
`paperclip` user (uid/gid 1000) + the agent shim compiled in a Go build
stage. `WORKDIR /workspace`, entrypoint `tini -- paperclip-agent-shim`.
- One derived Dockerfile per harness: `opencode` (opencode-ai), `pi`
(@mariozechner/pi-coding-agent), `codex` (@openai/codex), `gemini`
(@google/gemini-cli, plus headless auth-mode settings), `claude`
(@anthropic-ai/claude-code, symlinked as `claude-code`). Each installs
the CLI as root, returns to uid 1000, and asserts the binary is on PATH
at build time.
- `acpx` and `hermes` Dockerfiles are included in the bake group but are
not in the default publish scope (hermes is a stub until a CLI package
exists).
- `docker/agent-runtime/buildx-bake.hcl`: builds the whole family in one
pass. Derived targets chain off the `base` target through bake
`contexts` (the literal registry in each `FROM` is overridden to
`target:base` at build time, so no intermediate push is needed).
`REGISTRY` (default `ghcr.io/paperclipai`) and `VERSION` are overridable
variables.
- `tools/agent-shim/`: a small Go shim that runs as the container
command. It reads `/run/paperclip/runtime-command.json` (`{ "command",
"args" }`), resolves the harness CLI on PATH, and `syscall.Exec`s it so
SIGTERM from the kubelet reaches the harness directly. Harness-agnostic,
with unit tests.
- `.github/workflows/agent-runtime-images.yml`: builds and pushes the
default scope (base, opencode, pi, codex, gemini, claude) for
linux/amd64 on `workflow_dispatch` (explicit version tag) or pushes to
`master` touching these paths, then signs every digest with cosign
keyless OIDC. Uses only `GITHUB_TOKEN`; no extra secrets.
- `docker/agent-runtime/README.md`: image lineup, base contents, local
build instructions, the runtime-command contract, and the security
model.

Additive only: nothing in the product loads these images. Deployments
opt in via their sandbox provider configuration (for example the
kubernetes plugin's image settings).

## Verification

- `cd tools/agent-shim && go build ./... && go test ./... && go vet
./...`: all passing.
- `docker buildx bake -f docker/agent-runtime/buildx-bake.hcl --print
base opencode pi codex gemini claude`: resolves cleanly; every tag and
build context lands on `ghcr.io/paperclipai/agent-runtime-*` and derived
targets map the base ref to `target:base`.
- Workflow YAML validated (parses, single job, no org-specific secrets).
- This exact image family (built from these Dockerfiles, bake file, and
workflow) is what runs agent execution in production on paperclip.inc,
verified end-to-end across five harnesses (opencode, pi, codex, gemini,
claude): each as a full loop from assigned issue to per-run runtime
image in a sandboxed pod to completed run.

## Risks

- Low risk: purely additive, nothing in paperclip-server or the UI
references these files. The workflow only triggers on its own paths.
- Derived images install harness CLIs `@latest` at build time; a broken
upstream CLI release would surface at image build, not at run time, and
the PATH assertion fails the build rather than shipping a broken image.
- The hermes image is an explicit stub (documented in its Dockerfile)
until a hermes CLI package exists; it is outside the default publish
scope.
- cosign signing is keyless OIDC with the workflow identity; no
long-lived signing keys are introduced.

## Model Used

Claude Opus 4.8 (claude-opus-4-8, 1M context, extended thinking, tool
use via Claude Code).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:10:23 -07:00
Jannes Stubbemann 4ad94d0bde feat(server): kubernetes execution integration for sandbox-provider plugins (stage 2/3) (#7938)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The execution subsystem runs those agents in environments (local,
ssh, sandbox), and sandbox-provider plugins let an environment
materialize per-run sandboxes
> - Stage 1 (#5790) contributed a first-party Kubernetes
sandbox-provider plugin, but the server core has no way to adopt it
operationally: no per-run adapter selection, no way to force an instance
onto sandboxed execution, no declarative adapter/model configuration,
and the plugin must be installed by hand
> - Without this, a multi-tenant or security-conscious deployment cannot
guarantee that agent runs never execute on the host, and a single
environment cannot serve agents with different harnesses
> - This pull request adds the server + SDK integration: per-run
adapterType on the lease protocol, an env-gated forced-Kubernetes
execution policy with provisioning and a per-run allowlist guard, a
declarative adapter registry and model list, in-cluster env passthrough
for sandbox plugin workers, fail-safe auto-install of the bundled
plugin, and the matching UI affordance
> - The benefit is that sandbox-provider plugins become fully usable for
Kubernetes execution: operators configure everything via environment
variables and GitOps, while self-hosters who set none of the variables
see exactly the behavior they have today

## Linked Issues or Issue Description

Refs #5790 (stage 1 of 3: the Kubernetes sandbox-provider plugin
package).

No existing issue. Feature description: the server core lacks the
integration seams to operate a sandbox-provider plugin as the mandatory
execution path of an instance. This PR is stage 2 of 3 of the staged
Kubernetes contribution; stage 3 will contribute the agent runtime
images and their build pipeline.

## What Changed

One line per piece:

- `packages/plugins/sdk/protocol.ts`: optional `adapterType` on
`PluginEnvironmentAcquireLeaseParams` so a provider can select the
runtime image per run; existing providers simply ignore it
- `server/services/environment-runtime.ts` +
`environment-run-orchestrator.ts`: thread the agent's adapter type into
both lease-acquiring drivers, including the heartbeat path (the two call
sites have historically drifted, hence the pinned test)
- `server/services/environments.ts`: `ensureKubernetesEnvironment` /
`findKubernetesEnvironment`, an idempotent managed Kubernetes
environment per company, identified by a metadata marker and refreshed
(not recreated) on config change; `timeoutMs` rides on the config for
slow cold-start leases
- `server/services/execution-allowlist.ts`: pure (driver, provider,
policy) -> allow/deny guard; `executionMode=kubernetes` only allows the
kubernetes sandbox provider
- `server/services/execution-policy-bootstrap.ts` + startup hook in
`server/index.ts`: parse `PAPERCLIP_EXECUTION_MODE` / `PAPERCLIP_K8S_*`,
persist `executionMode` into instance general settings, and provision
the managed environment for every company; fails loud on
misconfiguration
- `server/services/heartbeat.ts`: when the policy forces Kubernetes, pin
run selection to the managed environment (also overriding any persisted
workspace environment id), refuse to fall back to local, and re-check
the actually acquired environment against the allowlist as defense in
depth
- `server/services/adapter-registry-bootstrap.ts` + shared
`AdapterRegistryEntry` type/validator: declarative `PAPERCLIP_ADAPTERS`
registry (inline JSON or file) that reconciles adapter availability at
startup and rides on the Kubernetes environment config
- `server/services/adapter-models-env.ts` + `adapters/registry.ts`:
`PAPERCLIP_ADAPTER_MODELS` lets an operator declare picker model lists
the server cannot CLI-discover
- `server/services/plugin-loader.ts`: pass
`KUBERNETES_SERVICE_HOST/PORT(_HTTPS)` through to plugin workers that
register environment drivers, so in-cluster API clients can be
constructed; all other host env stays stripped
- `server/app.ts`: fail-safe auto-install of the bundled kubernetes
plugin at boot; no-ops when the bundle is absent and never blocks
startup on error
- `packages/shared` types/validators: `InstanceExecutionMode` on general
settings (optional, strict schema)
- `ui/lib/forced-kubernetes-environment.ts` + `AgentConfigForm`: when
the policy is active, show a read-only Kubernetes environment instead of
the environment picker and default new agents onto the managed
environment
- Tests for every new module plus the adapterType pin in
`heartbeat-plugin-environment` and the managed-environment lifecycle in
`environment-service`

Everything is gated: with `PAPERCLIP_EXECUTION_MODE`,
`PAPERCLIP_ADAPTERS`, and `PAPERCLIP_ADAPTER_MODELS` unset (and no
bundled plugin present), every code path reduces to current behavior.
The per-run `adapterType` is an optional SDK parameter that existing
providers ignore.

## Verification

- `cd server && npx tsc --noEmit`: clean (0 errors); `ui` typecheck also
clean
- Targeted suites all green (11 files, 90 tests): `npx vitest run
server/src/__tests__/heartbeat-plugin-environment.test.ts
server/src/__tests__/environment-service.test.ts
server/src/__tests__/environment-runtime.test.ts
server/src/__tests__/environment-run-orchestrator.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/services/execution-policy-bootstrap.test.ts
server/src/services/execution-allowlist.test.ts
server/src/services/adapter-registry-bootstrap.test.ts
server/src/services/adapter-registry-bootstrap.reconcile.test.ts
server/src/services/adapter-models-env.test.ts
packages/shared/src/validators/adapter-registry.test.ts`
- `npx vitest run ui/src/components/AgentConfigForm.test.ts`: green (6
tests)
- Full `npx vitest run server/src/__tests__`: 2323 passed, 1 skipped;
the only failures (heartbeat-process-recovery pid-retry,
workspace-runtime symbolic-ref/git tests) reproduce identically on
pristine `master` in the same environment, so they are
machine-environment issues unrelated to this change;
`server-startup-feedback-export` needed its `services/index.js` mock
extended with the new export and is green
- This integration has been running in production on a hosted
multi-tenant deployment, where it executes agent runs across five
different harnesses through the stage 1 plugin

## Risks

- Low for existing deployments: every behavior is env-gated and the
defaults preserve current semantics; the auto-install block is wrapped
fail-safe and skips silently when the plugin bundle is absent
- `executionMode` is a new optional field on a strict zod schema; absent
input normalizes exactly as before
- The forced policy intentionally fails runs loudly (rather than falling
back to local) when no managed Kubernetes environment exists; this only
affects instances that explicitly set
`PAPERCLIP_EXECUTION_MODE=kubernetes`

## Model Used

Claude Opus 4.8 (claude-opus-4-8, 1M context), extended thinking,
agentic tool use via Claude Code.

## UI screenshots

The UI change is a new read-only "Execution" section in
`AgentConfigForm`, shown only when the instance execution policy forces
Kubernetes (`executionMode=kubernetes`); there is no "before" state for
it (the section did not exist, and instances without the forced policy
render the existing picker unchanged). Captured from the new Storybook
stories added in this PR (`Product/Agent Management`):

Managed Kubernetes environment present (read-only display, no local/SSH
picker):

![AgentConfigForm with forced Kubernetes
execution](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes.png)

No managed environment available yet (warning notice, no silent local
fallback):

![AgentConfigForm forced Kubernetes, missing environment
warning](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes-missing-env.png)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:09:02 -07:00
Jannes Stubbemann 05ab45225a feat(plugin-kubernetes): self-hostable Kubernetes sandbox provider (stage 1/3: plugin package) (#5790)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox providers are the seam that lets agent runs execute in
isolated environments; today the only first-party remote provider is
Daytona, a hosted third-party service
> - Self-hosters running Paperclip on their own infrastructure (often
Kubernetes already) have no first-party way to run agent sandboxes on a
cluster they control
> - That gap matters for teams with data-residency, sovereignty, or cost
constraints who cannot or will not send workloads to a hosted sandbox
service
> - This pull request adds a Kubernetes sandbox-provider plugin as a
standalone, workspace-excluded package: it implements every
SandboxProvider hook the Daytona provider does, on infrastructure the
operator owns
> - The benefit is that any Paperclip deployment with a Kubernetes
cluster gets multi-tenant, network-isolated, quota-bounded agent
sandboxes with zero new external dependencies

## Linked Issues or Issue Description

No existing issue. Following the feature template:

- **Problem:** Paperclip's remote sandbox execution requires a hosted
third-party provider. Self-hosters cannot run agent sandboxes on their
own Kubernetes clusters with a first-party provider.
- **Proposed solution:** A `@paperclipai/plugin-kubernetes`
sandbox-provider plugin with two backends: long-lived sandboxes via the
[kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)
CRD (multi-command exec, adapter-install pattern) and one-shot
`batch/v1` Jobs (stable APIs only, no extra controllers).
- **Alternatives considered:** Driving kubectl from a generic shell
provider (no lifecycle/lease semantics), or requiring a hosted provider
(exactly the constraint this removes).

## What Changed

This is **stage 1 of 3** of a staged contribution (direction agreed with
maintainers): the plugin package alone. Stage 2 (server integration:
lease params, provider registration) and stage 3 (agent runtime images +
CI) are companion PRs that will be cross-linked from a comment here.

- New package `packages/plugins/sandbox-providers/kubernetes`
(workspace-excluded, like the path already carved out in
`pnpm-workspace.yaml`): src, unit + kind integration tests, operator
prerequisite manifests, README, smoke-test guide
- Implements the full SandboxProvider hook surface the Daytona provider
implements: `validateConfig`, `probe`, `acquireLease`, `resumeLease`,
`releaseLease`, `destroyLease`, `realizeWorkspace`, `execute`
- Two backends: `sandbox-cr` (default; long-lived pod via the
agent-sandbox `Sandbox` CR, supports multi-command exec) and `job`
(one-shot `batch/v1` Job; nothing beyond k8s 1.27+ required)
- Per-run adapter resolution: one environment serves mixed harnesses;
the per-run `adapterType` hint is read through a local optional type
extension, so the plugin typechecks and builds against the current
plugin SDK and simply falls back to the environment's configured default
adapter until stage 2 lands
- Exec-env wrapping: the Kubernetes exec API carries no environment, so
commands are wrapped to receive the run's env
- Fast-upload interception for workspace realization, scoped per lease
- Per-tenant isolation: derived namespace per company, RBAC,
ResourceQuota, restricted-PSS pod security (runAsNonRoot, drop ALL,
seccomp RuntimeDefault, no SA token automount)
- Network egress policy in two flavors: native `NetworkPolicy` and
`CiliumNetworkPolicy` (FQDN allowlists)
- Image allowlist with glob matching, registry override, and per-run
image override validation
- Per-run Kubernetes Secrets carrying agent credentials, ownerRef'd to
the Job or Sandbox CR for cascade GC

## Verification

- Standalone build, exactly as the README documents:
  ```bash
  cd packages/plugins/sandbox-providers/kubernetes
  pnpm install --ignore-workspace
  pnpm test        # 147 unit tests, 17 files, all green
  pnpm typecheck   # clean against the in-repo plugin SDK on master
pnpm build # dist/ emitted, manifest + worker entrypoints present
  ```
- A kind-cluster end-to-end integration test is included
(`RUN_K8S_INTEGRATION_TESTS=1 pnpm test
test/integration/end-to-end-run.test.ts`)
- Beyond CI: this provider has been verified in a production
multi-tenant deployment against five harnesses (opencode, pi, codex,
gemini, claude code) with real billed runs

## Risks

- **Zero behavior change for any existing deployment.** The package is
workspace-excluded; nothing in the server imports or loads it until
stage 2's integration lands. No existing code paths are touched.
- The default `sandbox-cr` backend depends on an alpha CRD
(`agents.x-k8s.io/v1alpha1`); the README flags this and the `job`
backend uses only stable APIs as a fallback.
- Risk surface is confined to deployments that explicitly install and
configure the plugin.
- The default runtime images (`ghcr.io/paperclipai/agent-runtime-*`) are
published by the stage 3 companion PR (#7934); until that lands,
deployments must point `runtimeImage` at their own images.

## Model Used

Claude Opus 4.8 (1M context), extended thinking, with tool use (Claude
Code).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (no UI changes)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending this push)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:07:00 -07:00
Devin Foley c139d6c025 fix(codex-local): omit default model so codex CLI picks per auth mode (#7971)
## Thinking Path

> - Paperclip orchestrates AI agents through pluggable local adapters;
codex_local wraps OpenAI's `codex` CLI.
> - The codex_local adapter declares a hard-coded
`DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"` and multiple Paperclip
consumers (UI build-config, server route, OnboardingWizard, NewAgent
form, AgentConfigForm) fall back to it when the operator doesn't pick a
model.
> - That model — and every `*-codex` model plus the older
`gpt-5/5.1/5.2` lines — is API-key-only. Codex CLI rejects them on
ChatGPT subscription auth with "The 'gpt-5.3-codex' model is not
supported when using Codex with a ChatGPT account."
> - Every codex_local agent created through the default onboarding path
inherits this pin and breaks on its first heartbeat for any user authed
via `codex login` (ChatGPT).
> - claude_local already takes the right shape: its build-config only
sets `adapterConfig.model` when the operator actually picked one, and
falls through to whatever default `claude` CLI uses.
> - Codex CLI's own default is auth-mode-aware. ChatGPT-subscription
accounts get `gpt-5.5`; API-key accounts get the codex-tuned default. A
Paperclip-side pin masks this and downgrades whichever group it wasn't
built for.
> - This PR makes codex_local match claude_local's shape: omit
`adapterConfig.model` when the user picks "default," and let the CLI
choose. Subscription users stop breaking; API-key users stop getting
downgraded.
> - The benefit is auth-mode-correct defaults with no Paperclip-side
hard pin, plus future-proofing: when OpenAI bumps the CLI default we
inherit it for free.

## What Changed

- `packages/adapters/codex-local/src/ui/build-config.ts` — only set
`adapterConfig.model` when the operator picked one (parity with
`packages/adapters/claude-local/src/ui/build-config.ts`).
- `server/src/routes/agents.ts` — drop the codex_local-specific
`next.model = DEFAULT_CODEX_LOCAL_MODEL` fallback in
`applyCreateDefaultsByAdapterType`. Bypass-sandbox default is left in
place (security posture, not a model choice).
- `ui/src/pages/NewAgent.tsx`, `ui/src/components/AgentConfigForm.tsx`,
`ui/src/components/OnboardingWizard.tsx` — stop pre-populating the model
field with `DEFAULT_CODEX_LOCAL_MODEL` when the user selects the Codex
adapter. Other adapters' defaults (gemini_local, cursor, opencode_local)
are unchanged.
- `DEFAULT_CODEX_LOCAL_MODEL` is preserved as an exported constant for
downstream consumers / plugin authors who want to opt in to a pin; we
just stop forcing it on operators who didn't ask for one.
- Test: assert `buildCodexLocalConfig` omits `model` when input is
blank.

## Verification

- `pnpm exec vitest run
packages/adapters/codex-local/src/ui/build-config.test.ts
packages/adapters/codex-local/src/server/codex-args.test.ts
server/src/__tests__/adapter-registry.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/agent-permissions-routes.test.ts` → 74/74 passing
- `pnpm exec vitest run ui/src/lib/duplicate-agent-payload.test.ts
ui/src/lib/acpx-model-filter.test.ts` → passing
- `pnpm tsc --noEmit -p .` → clean
- Live: I separately verified live during initial investigation that on
ChatGPT-subscription auth, `gpt-5.3-codex` is rejected and `gpt-5.5` is
what Codex CLI picks by default. Omitting model lets the CLI handle
that.

## Risks

- Telemetry: any sink that reads `adapterConfig.model` for cost
attribution will now see the empty/omitted case more often. The CLI
emits the actually-used model in its event stream; downstream telemetry
should already read from there for accuracy, but worth a check.
- Operator UX: "default" now means "whatever the CLI picks" instead of a
Paperclip-known model. The selectable catalog still includes `gpt-5.5`,
`gpt-5.4`, `gpt-5.3-codex`, etc. for operators who want to pin
explicitly.
- Existing agents are unaffected — their `adapterConfig.model` is
already set; this only changes the *new-agent* default flow.

## Related work

- Depends on: an open catalog-add PR adding `gpt-5.5` to the selectable
model list and to `CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS`. Operators
who want to switch to `gpt-5.5` explicitly need that PR merged first;
this PR is the structural change that makes "default" mean "let the CLI
choose."
- Closes #5371 — codex_local default model selection persists
`gpt-5.3-codex` instead of adapter default (this PR is the exact fix
#5371 proposes).
- Related: #5132 (opencode-local: hire-time default model fails on
ChatGPT-OAuth accounts) — same problem shape on a sibling adapter; not
fixed here but worth tracking for a parallel.
- Related: #5939 (codex_local adapter hardcodes `gpt-5.3-codex-spark`
validation, fails on ChatGPT OAuth accounts regardless of configured
model) — separate validation-path bug; not fixed here.

## Model Used

Claude (Sonnet-class), running inside Paperclip as a claude_local
executor.


## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-10 20:53:33 -07:00
LIXin Ye 9a48d92104 Add GPT-5.5 to Codex local model options (#5575)
## Related work

This PR is the cleanest "add `gpt-5.5` to the codex-local catalog"
change open against master. Several other PRs propose the same
catalog/fast-mode update; they should close as duplicates once this
lands:

- #4646 — Add Codex gpt-5.5 model option
- #6044 — feat(codex-local): add gpt-5.5 to model catalog, default
reasoning to medium, cheap profile xhigh
- #6045 — feat(codex-local): add gpt-5.5 to model catalog, default
medium reasoning, xhigh cheap profile
- #6595 — feat(adapters): add new Codex models (gpt-5.5, gpt-5.4-mini,
gpt-5.3-codex, gpt-5.2)

Related issues this enables (catalog-level surface area):

- #5371 — codex_local default model selection persists `gpt-5.3-codex`
instead of adapter default. This PR makes `gpt-5.5` selectable in the
dropdown; a separate follow-up changes the *default* behavior so users
who don't pick a model are subscription-compatible.
- #5132 — opencode-local: hire-time default model fails on ChatGPT-OAuth
accounts. Sibling adapter, same problem shape; not fixed here but worth
tracking as a parallel for the opencode side.

---

## Thinking Path

> - Paperclip orchestrates AI agents through adapter-backed local and
remote runtimes.
> - The `codex_local` adapter declares built-in model options that feed
the server model list and, in turn, the agent configuration UI dropdown.
> - GPT-5.5 is available in newer Codex environments but was missing
from Paperclip's fallback `codex_local` model list.
> - Operators could still type a manual model ID, but the default
dropdown made the supported path look unavailable.
> - Codex fast mode support is declared separately, so adding GPT-5.5 to
the visible list should also include it in the supported fast-mode set.
> - This pull request adds GPT-5.5 to the built-in Codex local model
options and updates focused tests around argument generation and adapter
model listing.
> - The benefit is a clearer default setup path for agents using GPT-5.5
without changing existing defaults or migrations.

## What Changed

- Added `gpt-5.5` to the `codex_local` fallback model list.
- Added `gpt-5.5` to `CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS`.
- Updated Codex argument tests to cover GPT-5.5 fast mode and preserve
manual-model fast mode behavior.
- Updated adapter model listing tests to assert the Codex fallback list
includes GPT-5.5.

## Verification

- `pnpm exec vitest run
packages/adapters/codex-local/src/server/codex-args.test.ts
server/src/__tests__/adapter-models.test.ts`
- `git diff --check`
- UI note: this is a dropdown data-source change rather than a
layout/component change; the adapter model listing test covers the list
consumed by the UI.

## Risks

- Low risk. This only extends a static fallback model list and fast-mode
allowlist.
- Existing defaults remain unchanged (`gpt-5.3-codex`).
- If a local Codex CLI does not support `gpt-5.5`, selecting it will
still fail at execution time the same way any unavailable manual model
would.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex desktop coding agent, GPT-5-family model. The exact
backing model ID was not exposed by the local runtime; the session used
shell, Git, test execution, and GitHub CLI tool access.


## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-10 20:02:39 -07:00
Jannes Stubbemann 93cdc5c1ce fix(adapter-utils): tar sandbox workspace by entry, not '.', to avoid EPERM on unowned target dir (#7836)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can run in remote/sandboxed environments via the shared
sandbox managed-runtime in `@paperclipai/adapter-utils` (used by
SSH/E2B/Daytona and other sandbox providers), which syncs the workspace
into the sandbox by tarring it up and extracting it inside the pod/host
> - When the sandbox runs the harness as a non-root user whose
home/workspace dir it does not own (for example a hardened, non-root,
gVisor pod with an `emptyDir`-mounted workspace), the workspace upload
aborts before the agent can start
> - Root cause: `createTarballFromDirectory` archives `.`, embedding a
`./` self-entry whose mode/mtime tar then tries to restore onto the
**extraction target directory**; `chmod`/`utime` of `.` fails with
`Operation not permitted` for a non-owner
> - This is not specific to any one deployment: the `.` self-entry EPERM
can bite every sandbox provider built on the shared managed runtime as
soon as the extracting user does not own the target directory, which is
the norm for hardened non-root sandboxes
> - This pull request archives the directory's top-level entries by name
instead of `.`, so there is no `./` self-entry and extraction never
touches the target dir's metadata
> - The benefit is that workspace sync works in any sandbox where the
target dir is non-root or not owned by the extracting user, without
GNU-only tar flags

## Linked Issues or Issue Description

No existing issue; describing in-PR (bug).

- **What happens:** managed sandbox runs that sync the workspace fail at
upload with `tar: .: Cannot utime: Operation not permitted` / `tar: .:
Cannot change mode to ... : Operation not permitted`, aborting the run
before the harness starts.
- **Where:** `packages/adapter-utils/src/sandbox-managed-runtime.ts`, in
`createTarballFromDirectory` (archives `.`).
- **When:** the extraction target directory is not owned by the
(non-root) user extracting the tar inside the sandbox.
- Closely related (different root cause): #6560 (E2B workspace upload +
lease idle failures).

## What Changed

- `createTarballFromDirectory` enumerates the directory's top-level
entries with `fs.readdir` and passes them by name after `--` (guards
flag-like filenames) instead of archiving `.`, eliminating the `./`
self-entry that triggers the EPERM.
- Empty workspaces (legitimate for blank-workspace runs) write a valid
1024-byte all-zero EOF tar instead of invoking `tar` with no paths.
- `--exclude` patterns continue to apply (to nested matches and any
named entry).

## Verification

- `pnpm --filter @paperclipai/adapter-utils build` (tsc clean)
- `pnpm exec vitest run
packages/adapter-utils/src/sandbox-managed-runtime.test.ts` runs green
- New tests: uploaded workspace/asset tarballs contain no `.`/`./`
member yet still extract correctly; empty workspace produces a valid
(no-op) tarball. Existing managed-runtime sync test unchanged.
- Manually verified in a hardened (non-root, gVisor) sandbox pod: with
the fix, the workspace upload that previously aborted with the EPERM now
succeeds. That deployment is the reproduction and verification
environment; the fix itself is provider-agnostic.

## Risks

Low. Behavior is unchanged for owned/root targets; the archive contents
are the same minus the `./` self-entry (which tar recreates implicitly
on extract). Portable across GNU/BSD/busybox tar (no GNU-only
`--no-overwrite-dir`). No API/migration/UI impact.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking +
tool use, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work (bug fix in shared sandbox utils, not core feature
work)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (#6560)
- [x] I have either (a) linked existing issues OR (b) described the
issue in-PR following the relevant issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (n/a, no UI)
- [ ] I have updated relevant documentation to reflect my changes (n/a,
internal behavior, no docs reference this)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(the only finding was the description-template P2, resolved by this
description; the latest review covers the current head with no code
findings and all CI gates are green)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:36:19 -07:00
Dotta 11a64819f9 Keep agent-created follow-ups in run workspace
Reviewed and merged for PAP-10871/PAP-10873.\n\nVerification:\n- pnpm vitest run server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts\n- git diff --check origin/master...HEAD\n- GitHub PR checks green before merge
2026-06-10 21:20:51 -05:00
Sherif Kozman b8fb81dee9 fix(gemini-local): treat token-overflow as a fresh-session signal (#4932)
## Thinking Path

The same 2026-04-30 audit that produced PR #4118 (`Invalid session`
regex extension) and the ENOTFOUND classifier (#4931) identified a third
stuck-session pattern: **13 failures in 7 days, all on a single agent
(Ernest)**, with stderr matching:

```
_ApiError: {"error":{"code":400,"message":"The input token count exceeds
the maximum number of tokens allowed 1048576","status":"INVALID_ARGUMENT"}}
  at ChatCompressionService.compress
```

The root cause is that gemini-cli's `ChatCompressionService` blew the 1M
token context limit **during its compression step itself**. Resuming the
same session ID will hit the same wall on the next attempt — the session
is effectively dead the same way it is when "Invalid session identifier"
fires (PR #4118).

## What Changed

Extends the `isGeminiUnknownSessionError` regex in `parse.ts` with two
phrases:
- `exceeds\s+the\s+maximum\s+number\s+of\s+tokens`
- `input\s+token\s+count\s+exceeds`

Both trigger the **existing** fresh-session retry path in
`execute.ts:596` — no new code path. Same extension pattern as PR #4118.

## Verification

- `npx vitest run --project @paperclipai/adapter-gemini-local` → 14/14
pass (11 in `parse.test.ts` + 3 existing in `execute.remote.test.ts`)
- 2 new tests cover the token-overflow patterns
- `pnpm --filter @paperclipai/adapter-gemini-local typecheck` → clean
- Audit query against `heartbeat_runs.stderr_excerpt` confirms regex
matches all 13 occurrences

## Stacking

This PR is stacked on top of #4931 (the ENOTFOUND classifier) which adds
the `parse.test.ts` file. If #4931 merges first, this PR's diff is just
the regex + 2 tests. If this PR is reviewed first, please merge #4931
first to avoid touching the same test scaffolding twice.

## Risks

- **Low.** Single-line regex extension. No new code paths.
- The session-reset path is well-trodden (PR #4118 in flight).
- If a non-Gemini caller produces a stderr containing "exceeds the
maximum number of tokens" by coincidence, they would trigger one
unnecessary fresh-session retry. Not plausible in the gemini-cli output
context where this stderr is sourced.

## Model Used

Claude Opus 4.7 (1M context), Anthropic SDK via Claude Code CLI.

## Checklist

- [x] Thinking path traces from audit data to single-line regex change
- [x] Model specified
- [x] No duplicate of planned core work
- [x] Tests pass locally
- [x] Tests added (2 new)
- [x] N/A — server-side regex
- [x] Internal pattern; no docs change
- [x] Risks documented
- [x] Will address Greptile + reviewer comments before merge
- [x] I searched the GitHub PR list for similar PRs and confirmed this
is not a duplicate (related: #4118 covers the "Invalid session
identifier" regex; this PR extends the same regex with token-overflow
phrases)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-10 19:10:29 -07:00