merge: integrate current master into Grok native runner

Preserve current provider versions, Grok subscription fixtures, and immutable resolved-lock verification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-23 06:45:36 -07:00
commit 45cf50aa90
168 files changed
+3365 -487

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
# Adapter model audit: September 22, 2026
This audit covers model selection in the existing coding-agent adapters. Catalog
entries identify models; provider accounts and installed CLIs determine access.
No agent defaults or saved model selections are migrated.
| Adapter | Changes from the audit |
| --- | --- |
| Claude Code | Add Opus 5.5 and require CLI 2.1.280. Fable 5.1, Fable 5, Sonnet 5, and Mythos 5 were already listed. Expose the documented model-specific effort levels in creation and editing. |
| Claude on Bedrock | Add Opus 5.5, Opus 5, Sonnet 5, Opus 4.7, and Sonnet 4.6. Correct the obsolete `-v1` suffix on Opus 4.8 and Fable 5. Apply the Opus 5.5 version check to Bedrock IDs too. |
| Codex and the Codex runner catalog | Add GPT-6 Sol and Luna, including Fast mode. Astra was already listed. Expose efforts through Ultra for Astra, Sol, and GPT-5.6 Sol/Terra; cap both Luna generations at Max. |
| Grok Build | Add Grok 4.7, 4.6, and 4.5. Offer Extra High for 4.7/4.6. Save edited effort as `reasoningEffort`, which the runtime consumes. Keep `grok-build` as the sentinel that lets the CLI choose its default. |
| Gemini CLI | Add Flash 3.8, 3.7, 3.6, 3.5, Flash Lite 3.5/3.1, and 3 Flash Preview. Remove the retired Gemini 2.0 choices. Keep Auto and the existing 3.1 Pro and 2.5 choices. |
| Cursor | Add the current documented fallback IDs for Composer 2.5, Opus 5.5, Fable 5.1, Sonnet 5, GPT-5.6 Sol/Terra/Luna, Gemini 3.8 Flash, Muse Spark 1.3, and Grok 4.7/4.6/4.5. Runtime model discovery remains available. |
| OpenCode | Refresh the static fallback used by remote environments with GPT-6 and GPT-5.6 families, current Claude models, Gemini 3.8 Flash, and Grok 4.7. |
| Kimi Code | Add K3 256K. Relabel `kimi-for-coding` as K2.8 Preview, which replaced K2.7 under the same ID. Forward CLI effort for K2.8 Preview and both K3 variants. |
## Sources and verification
- [Claude Code model configuration](https://code.claude.com/docs/en/model-config)
documents the Opus 5.5 CLI requirement and supported efforts.
[Opus 5.5 specifications](https://platform.claude.com/docs/en/models/opus-5-5/overview)
and [model ID conventions](https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions)
supply direct and Bedrock IDs. Bedrock entries use the catalog's existing US
inference-profile convention; regional availability remains account-dependent.
- [OpenAI's Codex model guide](https://learn.chatgpt.com/docs/models) lists GPT-6
Sol and Luna. The installed Codex model metadata independently lists Astra,
Sol, Luna, and all three GPT-5.6 variants with the effort sets used here.
[GPT-6 Luna specifications](https://developers.openai.com/api/docs/models/gpt-6-luna)
also document Fast pricing. Codex's Ultra setting is a CLI capability; it is
not inferred from the API's effort enum.
- [Grok 4.7](https://docs.x.ai/developers/grok-4-7) and
[Grok 4.6](https://docs.x.ai/developers/models/grok-4.6) document their IDs and
four effort levels. The installed `grok models` command had no authenticated
account and returned only its default sentinel. No inference was run.
- [Google's model catalog](https://ai.google.dev/gemini-api/docs/models) supplies
the new Gemini IDs. [The retirement schedule](https://ai.google.dev/gemini-api/docs/deprecations)
records Gemini 2.0 shutdown on June 1, 2026. Gemini 2.5 remains available to
existing users. [Gemini CLI model selection](https://geminicli.com/docs/cli/model/)
accepts an explicit model; login method and entitlement determine availability.
- [Cursor's catalog](https://cursor.com/docs/models-and-pricing) links each
model's exact ID. `cursor-agent --list-models` returned no models for the local
account, so the additions use documented IDs rather than guessed variant
suffixes. Account-specific Fast/thinking variants remain discoverable.
- [Kimi's model configuration](https://www.kimi.com/code/docs/en/kimi-code/models.html)
documents all four current IDs, K2.8's in-place alias upgrade, and effort
support. Kimi effort remains limited to the existing explicit CLI engine;
the default ACP engine's effort mapping is outside this catalog update.
## Other adapters and restricted models
OpenCode discovers local models, but remote environment routes use its static
fallback catalog. All twelve added provider-qualified IDs were also present in
the installed OpenCode registry. [OpenCode model configuration](https://opencode.ai/docs/models/)
documents its `provider/model` format and provider registry.
Pi already discovers models from its runtime or provider registry.
Hermes and OpenClaw accept provider configuration without a curated model list.
Cursor Cloud obtains its account's model list from Cursor. These paths do not
need a static entry per upstream release. Local OpenCode discovery returned
current models, including Muse Spark 1.3 and Nemotron 3.5 Lightning.
Anthropic describes Mythos 5.1 as invitation-only in the
[Fable 5.1 documentation](https://platform.claude.com/docs/en/models/fable-5-1/overview).
The public current-model catalog does not publish a selectable ID for it. Keep
account discovery and custom IDs available instead of inventing an ID. Grok
4.7 Fast is available in Grok Build and Cursor, but its exact account-specific
CLI variant ID was not exposed locally. It is not a public xAI API model.
Image, video, audio, and embedding models are outside these coding-agent pickers.
## Prior work checked
Fable 5.1 was already merged in [#12730](https://github.com/paperclipai/paperclip/pull/12730).
Astra was already merged in [#12851](https://github.com/paperclipai/paperclip/pull/12851).
The Grok 4.6/4.5 proposal [#11324](https://github.com/paperclipai/paperclip/pull/11324)
was closed and parked by its author. This update preserves the newer CLI-default
sentinel behavior from [#12062](https://github.com/paperclipai/paperclip/pull/12062).
Open model-discovery work such as [#13127](https://github.com/paperclipai/paperclip/pull/13127)
and [#13565](https://github.com/paperclipai/paperclip/pull/13565) is separate from
these catalog and effort corrections. No open PR covering the newly added IDs
was found before implementation.
+6
View File
@@ -458,6 +458,12 @@ connection work or enforce a real tenant boundary. Follow these rules:
- Show browser sign-in/pending/return only for a real OAuth handoff supported by
the chosen authentication. Zapier's pasted MCP URL or bearer token requires no
Paperclip sign-in window. Advanced token/header setups need no invented OAuth step.
- On an OAuth failure or cancellation, explain the outcome on the return screen
and offer a retry of the same saved connection. Do not silently return to a blank
setup form or display untrusted provider error text from the callback URL.
- Resuming a saved connection retains its credential identity. Show that identity
as fixed in Access, and start OAuth with the credential policy returned by the
server. Do not offer personal/shared choices that the server will ignore.
- Put optional narrowing in fields marked `advanced: true`.
- Give hidden fields a `defaultValue`; never create a hidden required field the
server cannot fill.
+25 -1
View File
@@ -168,7 +168,9 @@ task.run
│ │ └── runner.turn.submit
│ └── agent.turn
│ ├── provider.turn.queue
│ └── provider.time_to_first_agent_event
│ ├── provider.time_to_first_agent_event
│ ├── tool.request.input_stream
│ └── tool.execute
└── task.settle
├── native.result.finalize
└── session.checkpoint.persist
@@ -189,6 +191,28 @@ session records `runner.session.resume`. `agent.turn` begins at
at that terminal event and remains open through finalization and checkpoint
persistence, so settlement work does not appear to outlive its parent.
`provider.time_to_first_agent_event` ends at the first meaningful provider
activity: a nonempty assistant/reasoning delta, a tool announcement with an
execution identity, or an existing assistant/reasoning/tool item start or
completion. Usage updates and empty deltas do not count. This is a first
activity metric, not time to a finished answer.
`tool.request.input_stream` measures the observed request-input window: from
an ACP tool announcement to its last input update before the provider reports
the tool complete. It can include provider buffering and transport. It is not
a claim about the exact end of model generation or the start of API execution.
The span is omitted when no subsequent input update is observed.
For native control-plane tools, `tool.execute` measures authorization and
execution inside the server authority, including time spent in HTTP requests.
Provider tool IDs and MCP request IDs are separate namespaces; the trace does
not guess a pairing by tool name or arrival order. Both spans belong to the
current run's `agent.turn`, including when a warm provider session is reused.
Their `operation` attribute is a known semantic tool name or `other`. Only a
boolean input-update marker crosses the provider-event boundary; no arguments,
argument hashes, or raw call IDs are exported by these spans. Concurrent tools
have separate durations; overlapping spans must not be summed as wall time.
The active native scope is also published through the existing asynchronous
runtime-parent seam. Provider execution, plugin, websocket/duplex, daemon, and
sandbox spans therefore inherit the correct branch even when their callbacks
+19
View File
@@ -369,6 +369,7 @@ Mount surfaces currently wired in the host include:
- `projectSidebarItem`
- `globalToolbarButton`
- `appShellOverlay` (persistent, signed-in application shell)
- `organizationSwitcher` (one React contribution replacing the organization menu)
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
@@ -617,3 +618,21 @@ pnpm build
For image-supplied plugins and the persistent shell lifecycle, see
[Distribution plugins](DISTRIBUTION-PLUGINS.md).
### Organization switcher
Declare one `organizationSwitcher` slot with `ui.sidebar.register`. The host
passes `PluginOrganizationSwitcherProps`: current company display data, collapsed
and open state, navigation/logout callbacks, and an icon renderer. Use the host
logout callback; authenticate remote account requests at their owning service.
`currentCompany` describes the host-local company. A distribution plugin must
resolve its external account/organization label itself; the host does not fetch
that portfolio on the plugin's behalf.
The slot props and `useHostContext()` are display context, not proof of identity.
The host reserves the trigger with a neutral placeholder while account, company,
and plugin discovery load. Plugins should reserve the same space while their
external label loads and retain resolved labels during same-account refreshes.
The host resets plugin state on account/company changes and keeps its built-in
menu when no unique contribution exists, discovery fails, the module is missing,
or rendering throws. The slot is a React-only contract; do not use a custom
element export. This replaces only the menu, not company policy or authorization.
+10 -7
View File
@@ -28,7 +28,7 @@ COPY cli/package.json ./cli/package.json
# The complete resolved lock (including transitive integrity hashes) is reviewed.
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
# Refresh this digest together with source/provider dependency changes.
ARG PAPERCLIP_RUNNER_LOCK_SHA256=f05a4a9cad90c72fe5b0dc2530a8f265a2b71caec91761664d955a53321ddd2f
ARG PAPERCLIP_RUNNER_LOCK_SHA256=a297efff4b2dc0db5d8680793c1194efd2d8d3643f6935ab40d659b3fa34542e
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
&& sha256sum -c /tmp/provider-lock.sha256 \
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'
@@ -54,8 +54,8 @@ ENV PATH=${PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT}/node_modules/.bin:/usr/local/sha
RUN npm uninstall -g @anthropic-ai/claude-code @openai/codex opencode-ai \
&& npm install -g \
@xai-official/grok@1.0.41 \
@google/gemini-cli@0.58.0 \
@moonshot-ai/kimi-code@0.41.0 \
@google/gemini-cli@0.60.0 \
@moonshot-ai/kimi-code@2.0.2 \
&& npm cache clean --force
# Hermes requires Python >=3.11,<3.14; the image's default Python is newer.
@@ -66,8 +66,8 @@ RUN /usr/bin/python3 -m venv /opt/hermes \
&& /opt/hermes/bin/pip install --no-cache-dir hermes-agent==0.19.0 \
&& ln -sf /opt/hermes/bin/hermes /usr/local/bin/hermes
ARG CURSOR_VERSION=2026.09.02-c22c1a3
ARG CURSOR_SHA256_AMD64=b73b59854762535c0fc20d7ccc51c3b5a356a851491088d60a362be48750f53c
ARG CURSOR_VERSION=2026.09.18-9a7762b
ARG CURSOR_SHA256_AMD64=b1308f5a2fc05458b9d8966752986bb23a971bbcc67c842c1df94c4b8132bad9
RUN set -eu; \
arch="$(dpkg --print-architecture)"; \
[ "$arch" = "amd64" ] || { echo "FATAL: cursor pin only covers amd64, not $arch" >&2; exit 1; }; \
@@ -81,8 +81,8 @@ RUN set -eu; \
ln -sf /usr/local/bin/cursor-agent /usr/local/bin/agent; \
chmod -R a+rX /opt/cursor
ARG GH_VERSION=2.100.0
ARG GH_SHA256_AMD64=e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be
ARG GH_VERSION=2.101.0
ARG GH_SHA256_AMD64=9bca2d1c16825f109907a23307628a2f0698fbf99662b73a5cf0b020293072b8
RUN set -eu; \
arch="$(dpkg --print-architecture)"; \
[ "$arch" = "amd64" ] || { echo "FATAL: gh pin only covers amd64, not $arch" >&2; exit 1; }; \
@@ -132,6 +132,9 @@ RUN /bin/sh -lc 'set -eu; \
for command_name in acpx claude-agent-acp codex-acp; do \
command -v "$command_name" >/dev/null || { echo "FATAL: $command_name not on PATH for daytona user" >&2; exit 1; }; \
done; \
test "$(codex --version)" = "codex-cli 0.156.0"; \
test "$(claude --version)" = "2.1.280 (Claude Code)"; \
test "$(opencode --version)" = "1.18.32"; \
test "$(acpx --version)" = "0.13.1"; \
test "$(claude-agent-acp --version)" = "0.73.0"; \
test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"'
+34 -6
View File
@@ -2,7 +2,7 @@
This image is the Paperclip Cloud fleet sandbox image plus a source-built
`paperclip-runnerd` and immutable provider pack. The pack contains Node 24.11,
OpenCode 1.18.29, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP
OpenCode 1.18.32, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP
agents, and the production lockfile. Its manifest digests each executable bridge
and binds the pack to the runner source revision, avoiding artifact upload and
npm installation on every fresh lease.
@@ -12,8 +12,34 @@ The fleet pins are intentionally copied from
Update both definitions together until the fleet base is published as a stable
image that this Dockerfile can extend directly.
## Harness versions
The September 22, 2026 refresh pins Codex 0.156.0, Claude Agent SDK
0.3.280 (Claude Code 2.1.280), and OpenCode 1.18.32 in the shared provider
pack. Claude Code 2.1.280 is the minimum for
[Opus 5.5](https://code.claude.com/docs/en/model-config); it also supports
Fable 5.1. Codex uses the current
[GPT-6 Sol and Luna model IDs](https://learn.chatgpt.com/docs/models).
Grok CLI 1.0.41 supports the current
[Grok 4.7](https://docs.x.ai/developers/grok-4-7) model family.
Keep the patched ACP bridge versions separate from their CLI runtime pins.
Their executable digests do not change when only the runtime dependency
changes. Refresh the runtime executable digests from integrity-verified npm
release archives for every supported platform, and keep the native runner,
provider manifest, and remote controller version checks aligned.
## Build and verify
Run `pnpm --filter @paperclipai/paperclip-runner test:opencode:qualification`
after installing dependencies to exercise the actual pinned OpenCode executable.
It checks health/version, session creation and retrieval, SSE messages, an async
prompt, and session deletion against a loopback mock provider. It uses an
isolated home, starts no paid model request, and retires its process group.
Set `PAPERCLIP_TEST_OPENCODE_BINARY` to the materialized Linux executable when
qualifying an assembled provider pack.
The fleet image is currently amd64-only because the pinned Cursor and GitHub CLI
checksums cover amd64.
@@ -69,11 +95,13 @@ Do not bake provider credentials, Paperclip bootstrap tickets, or Daytona
preview tokens into this image. They remain per-run secret material.
Provider CLI updates are manifest-only changes: repository CI owns the root
lockfile. The image build resolves the complete workspace manifest graph before
its frozen install, matching CI when a source commit precedes the lockfile bot.
lockfile. Resolve the complete workspace manifest graph in the build context
before invoking Docker, matching CI when a source commit precedes the lockfile
bot. The trusted workflow supplies this resolved lockfile as an immutable artifact.
The complete resolved lockfile must match `PAPERCLIP_RUNNER_LOCK_SHA256` before
package installation or lifecycle execution. Review and refresh that digest
with source dependency changes; registry-time resolution drift fails closed.
Keep one latest stable CLI installation per provider; refresh exact runtime
versions and qualification digests together, never install a private older copy
or download dependencies when a task starts.
Refresh exact runtime versions and qualification digests together; never download
dependencies when a task starts. Grok's additive native ACP profile keeps its
qualified 1.0.13 executable at a verified package path. It does not replace the
legacy adapter's `grok` command on PATH.
+1 -1
View File
@@ -24,7 +24,7 @@ All environment variables that Paperclip uses for server configuration.
| `PAPERCLIP_RUNNER_CA_BUNDLE_PATH` | (unset) | Optional PEM CA bundle for direct runner WSS. Platform roots remain enabled. There is no insecure TLS bypass. |
| `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` | (host build) | Host-local path to a `paperclip-runnerd` artifact built for the remote target OS and architecture. Required when Paperclip and the remote sandbox do not share a compatible platform; build metadata and the required transport mode are verified before launch. |
| `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH` | (unset) | Optional host-local path to a Codex executable built for the remote target OS and architecture. For remote Codex-backed runners, Paperclip stages and verifies this executable beside `paperclip-runnerd`. |
| `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.153.4`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. |
| `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.156.0`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. |
| `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | (unset) | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. The pack includes its target-built Node 24.11 runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. |
| `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). |
| `PAPERCLIP_SETTING_DEFAULTS` | (unset) | JSON object replacing the schema default of selected instance settings, for hosting operators. See [Operator setting defaults](#operator-setting-defaults). |
+2 -2
View File
@@ -116,8 +116,8 @@
"postgres@3.4.9": "patches/postgres@3.4.9.patch"
},
"overrides": {
"@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.153.4",
"@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.263",
"@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.156.0",
"@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.280",
"rollup": ">=4.59.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -1,7 +1,21 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js";
import { claudeLocalReasoningEffortsForModel, DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js";
import { minimumClaudeCliVersionForModel } from "./server/cli-capabilities.js";
describe("Claude model defaults", () => {
it.each(["claude-opus-5-5", "us.anthropic.claude-opus-5-5", "global.anthropic.claude-opus-5-5[1m]"])("requires a current CLI and offers all efforts for %s", (model) => {
expect(minimumClaudeCliVersionForModel(model)).toBe("2.1.280");
expect(claudeLocalReasoningEffortsForModel(model)).toEqual(["low", "medium", "high", "xhigh", "max"]);
});
it("keeps model-specific Claude reasoning limits", () => {
expect(claudeLocalReasoningEffortsForModel("claude-fable-5-1")).toContain("xhigh");
expect(claudeLocalReasoningEffortsForModel("claude-sonnet-5")).toContain("max");
expect(claudeLocalReasoningEffortsForModel("claude-sonnet-4-6")).toEqual(["low", "medium", "high", "max"]);
expect(claudeLocalReasoningEffortsForModel("claude-haiku-4-5")).toEqual([]);
expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]);
});
it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => {
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5");
expect(resolveClaudeModel(model)).toBe("claude-opus-5");
+13 -1
View File
@@ -22,6 +22,16 @@ export function resolveClaudeModel(
}
export const type = "claude_local";
export function claudeLocalReasoningEffortsForModel(model: string): readonly string[] {
const id = model.trim().replace(/\[1m\]$/, "").replace(/^(?:(?:us|eu|apac|global)\.)?anthropic\./, "");
if (/^claude-haiku-/.test(id)) return [];
if (/^claude-(?:opus-5(?:-5)?|opus-4-[78]|sonnet-5|fable-5(?:-1)?)$/.test(id)) {
return ["low", "medium", "high", "xhigh", "max"];
}
if (/^claude-(?:opus|sonnet)-4-6(?:-v1)?$/.test(id)) return ["low", "medium", "high", "max"];
return ["low", "medium", "high"];
}
export const label = "Claude Code";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
@@ -32,6 +42,7 @@ export const models = [
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
{ id: "claude-fable-5", label: "Claude Fable 5" },
{ id: "claude-mythos-5", label: "Claude Mythos 5" },
{ id: "claude-opus-5-5", label: "Claude Opus 5.5" },
{ id: "claude-opus-5", label: "Claude Opus 5" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
@@ -49,7 +60,7 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
- model (string, optional): Claude model id. Missing or blank defaults to ${DEFAULT_CLAUDE_LOCAL_MODEL} in both CLI and ACP, including existing agents. Explicit model IDs and ANTHROPIC_MODEL overrides are preserved. Bedrock/Vertex without an explicit model retain their provider default.
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- effort (string, optional): model-specific reasoning effort passed via --effort (low|medium|high; current Opus, Sonnet 5, and Fable models also support xhigh|max)
- chrome (boolean, optional): pass --chrome when running Claude
- promptTemplate (string, optional): run prompt template
- maxTurnsPerRun (number, optional): max turns for one run
@@ -77,6 +88,7 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Claude Opus 5.5 uses model ID \`claude-opus-5-5\` and requires Claude Code v2.1.280 or later.
- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and explicit engine="cli"; default or explicit ACP is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly.
- The Claude ACP lane requires Node >=24.11.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Missing prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically.
- For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine.
@@ -6,11 +6,6 @@ const effortFlagSupportCache = new Map<string, Promise<boolean | null>>();
export const CLAUDE_FABLE_5_1_MIN_CLI_VERSION = "2.1.251";
const CLAUDE_FABLE_5_1_MODEL_IDS = new Set([
"claude-fable-5-1",
"us.anthropic.claude-fable-5-1",
]);
export function claudeCommandLooksLike(command: string, expected = "claude"): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
@@ -41,7 +36,9 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul
}
export function minimumClaudeCliVersionForModel(model: string): string | null {
return CLAUDE_FABLE_5_1_MODEL_IDS.has(model.trim())
const modelId = model.trim().replace(/\[1m\]$/, "").replace(/^(?:(?:us|eu|apac|global)\.)?anthropic\./, "");
if (modelId === "claude-opus-5-5") return "2.1.280";
return modelId === "claude-fable-5-1"
? CLAUDE_FABLE_5_1_MIN_CLI_VERSION
: null;
}
@@ -18,7 +18,7 @@ const {
signal: null,
timedOut: false,
stdout: args.includes("--version")
? "2.1.251 (Claude Code)\n"
? "2.1.280 (Claude Code)\n"
: [
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }),
@@ -466,14 +466,14 @@ describe("claude remote execution", () => {
return { args: call?.[2] ?? [], result };
}
it("passes the exact configured Fable 5.1 ID as --model on the CLI lane", async () => {
it.each(["claude-fable-5-1", "claude-opus-5-5"])("passes %s as --model on the CLI lane", async (model) => {
const { args } = await executeWithModel("paperclip-claude-model-direct-", {
model: "claude-fable-5-1",
model,
});
const modelFlag = args.indexOf("--model");
expect(modelFlag).toBeGreaterThanOrEqual(0);
expect(args[modelFlag + 1]).toBe("claude-fable-5-1");
expect(args[modelFlag + 1]).toBe(model);
});
it("passes the Bedrock-native Fable 5.1 ID as --model under Bedrock auth", async () => {
@@ -496,27 +496,30 @@ describe("claude remote execution", () => {
expect(args).not.toContain("--model");
});
it("rejects Fable 5.1 before launch when the CLI is older than 2.1.251", async () => {
it.each([
["claude-fable-5-1", "2.1.251", "2.1.247"],
["claude-opus-5-5", "2.1.280", "2.1.279"],
])("rejects %s before launch below CLI %s", async (model, minimumVersion, detectedVersion) => {
runChildProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "2.1.247 (Claude Code)\n",
stdout: `${detectedVersion} (Claude Code)\n`,
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
});
const { args, result } = await executeWithModel("paperclip-claude-model-old-cli-", {
model: "claude-fable-5-1",
model,
});
expect(args).toEqual([]);
expect(result.errorCode).toBe("claude_cli_version_incompatible");
expect(result.errorMessage).toContain("requires Claude Code 2.1.251 or newer");
expect(result.errorMessage).toContain(`${model} requires Claude Code ${minimumVersion} or newer`);
expect(result.resultJson).toMatchObject({
requiredClaudeCodeVersion: "2.1.251",
detectedClaudeCodeVersion: "2.1.247",
requiredClaudeCodeVersion: minimumVersion,
detectedClaudeCodeVersion: detectedVersion,
});
});
@@ -1286,7 +1286,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? `detected ${detectedCliVersion}`
: "could not determine the installed version";
const errorMessage =
`Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane; ${detected}. ` +
`${model} requires Claude Code ${minimumCliVersion} or newer on the CLI lane; ${detected}. ` +
"Upgrade Claude Code or restore the default ACP lane before retrying.";
await onLog("stderr", `[paperclip] ${errorMessage}\n`);
return {
@@ -9,10 +9,15 @@ const ANTHROPIC_API_VERSION = "2023-06-01";
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
{ id: "us.anthropic.claude-opus-4-8", label: "Bedrock Opus 4.8" },
{ id: "us.anthropic.claude-opus-5-5", label: "Bedrock Opus 5.5" },
{ id: "us.anthropic.claude-opus-5", label: "Bedrock Opus 5" },
{ id: "us.anthropic.claude-sonnet-5", label: "Bedrock Sonnet 5" },
// Fable 5.1's documented geo inference ID carries no -v1 suffix, unlike earlier entries.
{ id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" },
{ id: "us.anthropic.claude-fable-5-v1", label: "Bedrock Fable 5" },
{ id: "us.anthropic.claude-fable-5", label: "Bedrock Fable 5" },
{ id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
@@ -639,14 +639,17 @@ describe("claude CLI local hello probe hardening", () => {
expect(JSON.stringify(spawnedEnv)).not.toContain("caller-proxy");
});
it("warns without executing when runtime PATH selects a different local Claude executable", async () => {
it.each([
["claude-fable-5-1", "2.1.251"],
["claude-opus-5-5", "2.1.280"],
])("warns without executing %s when runtime PATH selects a different executable", async (model, minimumVersion) => {
const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cli-runtime-path-"));
const runtimeClaudePath = path.join(runtimeDir, "claude");
await writeFile(runtimeClaudePath, "#!/bin/sh\nexit 0\n");
await chmod(runtimeClaudePath, 0o755);
try {
probeResult.value = { exitCode: 0, stdout: "2.1.251 (Claude Code)\n", stderr: "" };
probeResult.value = { exitCode: 0, stdout: `${minimumVersion} (Claude Code)\n`, stderr: "" };
const result = await testEnvironment({
companyId: "company-1",
@@ -654,7 +657,7 @@ describe("claude CLI local hello probe hardening", () => {
config: {
engine: "cli",
command: "claude",
model: "claude-fable-5-1",
model,
env: { PATH: runtimeDir },
},
executionTarget: null,
@@ -664,6 +667,7 @@ describe("claude CLI local hello probe hardening", () => {
expect(result.status).toBe("warn");
expect(result.checks).toContainEqual(expect.objectContaining({
code: "claude_cli_version_probe_mismatch",
hint: `Ensure the runtime-selected Claude Code is ${minimumVersion} or newer. Execution will verify that exact executable before launch.`,
level: "warn",
}));
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
@@ -141,10 +141,13 @@ describe("claude sandbox auth-missing check", () => {
});
describe("claude CLI model compatibility check", () => {
it("fails before the hello probe when Fable 5.1 is configured with an older CLI", async () => {
it.each([
["claude-fable-5-1", "2.1.251", "2.1.247"],
["claude-opus-5-5", "2.1.280", "2.1.279"],
])("fails before the hello probe for %s below CLI %s", async (model, minimumVersion, detectedVersion) => {
probeResult.value = {
exitCode: 0,
stdout: "2.1.247 (Claude Code)\n",
stdout: `${detectedVersion} (Claude Code)\n`,
stderr: "",
};
@@ -154,7 +157,7 @@ describe("claude CLI model compatibility check", () => {
config: {
engine: "cli",
command: "claude",
model: "claude-fable-5-1",
model,
},
executionTarget: sandboxTarget,
environmentName: "Daytona",
@@ -164,7 +167,8 @@ describe("claude CLI model compatibility check", () => {
expect(result.checks).toContainEqual(expect.objectContaining({
code: "claude_cli_version_incompatible",
level: "error",
detail: "Detected Claude Code 2.1.247.",
message: `${model} requires Claude Code ${minimumVersion} or newer on the CLI lane.`,
detail: `Detected Claude Code ${detectedVersion}.`,
}));
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
@@ -266,9 +266,9 @@ export async function testEnvironment(
code: "claude_cli_version_probe_mismatch",
level: "warn",
message:
"Skipped Fable 5.1 readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.",
`Skipped ${configuredModel} readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.`,
hint:
"Ensure the runtime-selected Claude Code is 2.1.251 or newer. Execution will verify that exact executable before launch.",
`Ensure the runtime-selected Claude Code is ${minimumCliVersion} or newer. Execution will verify that exact executable before launch.`,
});
} else if (canRunProbe && minimumCliVersion && versionProbeCommand) {
const versionProbeEnv = localProbe?.env ?? env;
@@ -289,7 +289,7 @@ export async function testEnvironment(
checks.push({
code: "claude_cli_version_incompatible",
level: "error",
message: `Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`,
message: `${configuredModel} requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`,
detail: detectedCliVersion
? `Detected Claude Code ${detectedCliVersion}.`
: "Could not determine the installed Claude Code version.",
@@ -14,21 +14,24 @@ describe("codex local adapter metadata", () => {
// Default to the concrete gpt-5.6-sol slug — Codex ships no metadata for the bare gpt-5.6
// alias, so it must not be advertised or used as the default (it triggers a fallback warning).
expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.6-sol");
expect(modelIds.slice(0, 4)).toEqual([
expect(modelIds.slice(0, 6)).toEqual([
"gpt-5.6-sol",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-terra",
"gpt-5.6-luna",
]);
expect(modelIds).not.toContain("gpt-5.6");
expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true);
expect(isCodexLocalFastModeSupported("gpt-6-astra")).toBe(true);
expect(isCodexLocalFastModeSupported("gpt-6-sol")).toBe(true);
expect(modelIds).not.toContain("gpt-5.3-codex");
expect(modelIds).not.toContain("gpt-5.3-codex-spark");
});
it("uses the reasoning efforts advertised for GPT-6 Astra", () => {
expect(codexLocalReasoningEffortsForModel("gpt-6-astra")).toEqual([
it.each(["gpt-6-astra", "gpt-6-sol", " gpt-6-sol ", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6"])("uses the reasoning efforts advertised for %s", (model) => {
expect(codexLocalReasoningEffortsForModel(model)).toEqual([
"low",
"medium",
"high",
@@ -36,7 +39,10 @@ describe("codex local adapter metadata", () => {
"max",
"ultra",
]);
expect(codexLocalReasoningEffortsForModel("gpt-5.6-sol")).toEqual([
});
it.each(["gpt-5.5", "custom-model"])("preserves legacy efforts for %s", (model) => {
expect(codexLocalReasoningEffortsForModel(model)).toEqual([
"minimal",
"low",
"medium",
@@ -45,6 +51,11 @@ describe("codex local adapter metadata", () => {
]);
});
it.each(["gpt-6-luna", "gpt-5.6-luna"])("caps %s at max and supports Fast mode", (model) => {
expect(codexLocalReasoningEffortsForModel(model)).toEqual(["low", "medium", "high", "xhigh", "max"]);
expect(isCodexLocalFastModeSupported(model)).toBe(true);
});
it("normalizes the legacy bare gpt-5.6 alias to the concrete gpt-5.6-sol slug", () => {
expect(normalizeCodexModel("gpt-5.6")).toBe("gpt-5.6-sol");
expect(normalizeCodexModel(" gpt-5.6 ")).toBe("gpt-5.6-sol");
+26 -8
View File
@@ -14,6 +14,8 @@ export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex;
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
@@ -41,18 +43,22 @@ const CODEX_LOCAL_DEFAULT_REASONING_EFFORTS = [
"xhigh",
] as const;
const CODEX_LOCAL_ASTRA_REASONING_EFFORTS = [
const CODEX_LOCAL_MAX_REASONING_EFFORTS = [
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
const CODEX_LOCAL_ULTRA_REASONING_EFFORTS = [
...CODEX_LOCAL_MAX_REASONING_EFFORTS,
"ultra",
] as const;
export type CodexLocalReasoningEffort =
| (typeof CODEX_LOCAL_DEFAULT_REASONING_EFFORTS)[number]
| (typeof CODEX_LOCAL_ASTRA_REASONING_EFFORTS)[number];
| (typeof CODEX_LOCAL_ULTRA_REASONING_EFFORTS)[number];
export function normalizeCodexModel(model: string | null | undefined): string {
const normalizedModel = normalizeModelId(model);
@@ -62,9 +68,19 @@ export function normalizeCodexModel(model: string | null | undefined): string {
export function codexLocalReasoningEffortsForModel(
model: string | null | undefined,
): readonly CodexLocalReasoningEffort[] {
return normalizeCodexModel(model) === "gpt-6-astra"
? CODEX_LOCAL_ASTRA_REASONING_EFFORTS
: CODEX_LOCAL_DEFAULT_REASONING_EFFORTS;
const normalizedModel = normalizeCodexModel(model);
switch (normalizedModel) {
case "gpt-6-astra":
case "gpt-6-sol":
case "gpt-5.6-sol":
case "gpt-5.6-terra":
return CODEX_LOCAL_ULTRA_REASONING_EFFORTS;
case "gpt-6-luna":
case "gpt-5.6-luna":
return CODEX_LOCAL_MAX_REASONING_EFFORTS;
default:
return CODEX_LOCAL_DEFAULT_REASONING_EFFORTS;
}
}
export function isCodexLocalKnownModel(model: string | null | undefined): boolean {
@@ -94,6 +110,8 @@ export const models = [
// DEFAULT_CODEX_LOCAL_MODEL is gpt-5.6-sol, so it doubles as the first (default) 5.6 entry.
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
{ id: "gpt-6-sol", label: "gpt-6-sol" },
{ id: "gpt-6-luna", label: "gpt-6-luna" },
{ id: "gpt-5.6-terra", label: "gpt-5.6-terra" },
{ id: "gpt-5.6-luna", label: "gpt-5.6-luna" },
{ id: "gpt-5.4", label: "gpt-5.4" },
@@ -116,10 +134,10 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override passed via -c model_reasoning_effort=...; GPT-6 Astra supports low|medium|high|xhigh|max|ultra
- modelReasoningEffort (string, optional): reasoning effort override passed via -c model_reasoning_effort=...; GPT-6 Astra/Sol and GPT-5.6 Sol/Terra support low|medium|high|xhigh|max|ultra; GPT-6 Luna and GPT-5.6 Luna support low|medium|high|xhigh|max
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-6 (astra/sol/luna), GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
@@ -150,7 +168,7 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances/<id>/companies/<companyId>/agents/<agentId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- Fast mode is supported on GPT-6 (astra/sol/luna), GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn.
- The CLI engine defaults to a writable workspace sandbox with network access for unattended work and Paperclip API calls. It does not enable the dangerous bypass flag. Explicit sandbox modes/profiles and network overrides in extraArgs retain their meaning. An execution-target network denial remains enforced.
@@ -508,15 +508,15 @@ describe("codex_local ACP lane", () => {
});
});
it("forwards GPT-6 Astra controls to the ACPX Codex target", () => {
it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("forwards %s controls to the ACPX Codex target", (model, effort) => {
expect(buildCodexAcpConfig({
engine: "acp",
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
model,
modelReasoningEffort: effort,
fastMode: true,
})).toMatchObject({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
model,
modelReasoningEffort: effort,
fastMode: true,
});
});
@@ -9,14 +9,14 @@ describe("buildCodexExecArgs", () => {
if (resumeSessionId) expect(args.slice(-3)).toEqual(["resume", resumeSessionId, "-"]);
});
it("forwards GPT-6 Astra, its ultra reasoning effort, and fast mode", () => {
it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("forwards %s, its supported reasoning effort, and fast mode", (model, effort) => {
const result = buildCodexExecArgs({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
model,
modelReasoningEffort: effort,
fastMode: true,
});
expect(result.model).toBe("gpt-6-astra");
expect(result.model).toBe(model);
expect(result.fastModeApplied).toBe(true);
expect(result.fastModeIgnoredReason).toBeNull();
expect(result.args).toEqual([
@@ -24,9 +24,9 @@ describe("buildCodexExecArgs", () => {
"--json",
"--dangerously-bypass-approvals-and-sandbox",
"--model",
"gpt-6-astra",
model,
"-c",
'model_reasoning_effort="ultra"',
`model_reasoning_effort="${effort}"`,
"-c",
'service_tier="fast"',
"-c",
@@ -148,7 +148,7 @@ describe("buildCodexExecArgs", () => {
expect(result.fastModeRequested).toBe(true);
expect(result.fastModeApplied).toBe(false);
expect(result.fastModeIgnoredReason).toContain(
"currently only supported on gpt-6-astra, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs",
"currently only supported on gpt-6-astra, gpt-6-sol, gpt-6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs",
);
expect(result.args).toEqual([
"exec",
@@ -63,18 +63,18 @@ describe("buildCodexLocalConfig", () => {
});
});
it("persists the exact GPT-6 Astra model and supported controls", () => {
it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("persists the exact %s model and supported controls", (model, effort) => {
const config = buildCodexLocalConfig(
makeValues({
model: "gpt-6-astra",
thinkingEffort: "ultra",
model,
thinkingEffort: effort,
fastMode: true,
}),
);
expect(config).toMatchObject({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
model,
modelReasoningEffort: effort,
fastMode: true,
});
});
@@ -14,6 +14,18 @@ export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
const CURSOR_FALLBACK_MODEL_IDS = [
"auto",
"composer-2.5",
"claude-opus-5-5",
"claude-fable-5-1",
"claude-sonnet-5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gemini-3.8-flash",
"muse-spark-1.3",
"grok-4.7",
"grok-4.6",
"grok-4.5",
"composer-1.5",
"composer-1",
"gpt-5.3-codex-low",
+7 -2
View File
@@ -13,11 +13,16 @@ export const models = [
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-pro-preview-customtools", label: "Gemini 3.1 Pro Preview (Custom Tools)" },
{ id: "gemini-3.8-flash", label: "Gemini 3.8 Flash" },
{ id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },
{ id: "gemini-3.6-flash", label: "Gemini 3.6 Flash" },
{ id: "gemini-3.5-flash", label: "Gemini 3.5 Flash" },
{ id: "gemini-3.5-flash-lite", label: "Gemini 3.5 Flash Lite" },
{ id: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite" },
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
];
export const agentConfigurationDoc = `# gemini_local agent configuration
+10 -1
View File
@@ -5,8 +5,17 @@ export const DEFAULT_GROK_LOCAL_MODEL = "grok-build";
export const models = [
{ id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL },
{ id: "grok-4.7", label: "Grok 4.7" },
{ id: "grok-4.6", label: "Grok 4.6" },
{ id: "grok-4.5", label: "Grok 4.5" },
];
export function grokLocalReasoningEffortsForModel(model: string): readonly string[] {
return model.trim() === "grok-4.7" || model.trim() === "grok-4.6"
? ["low", "medium", "high", "xhigh"]
: ["low", "medium", "high"];
}
export const agentConfigurationDoc = `# grok_local agent configuration
Adapter: grok_local
@@ -27,7 +36,7 @@ Core fields:
- promptTemplate (string, optional): run prompt template
- model (string, optional): Grok model id. Defaults to grok-build.
- permissionMode (string, optional): Grok permission mode passed via \`--permission-mode\`. Unset by default: Grok >= 1.0 enforces \`dontAsk\` as deny-by-default and it overrides \`--always-approve\`, so unattended runs rely on \`--always-approve\` alone unless you explicitly need a mode
- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\`
- reasoningEffort (string, optional): Grok reasoning effort (low|medium|high; grok-4.7 and grok-4.6 also accept xhigh) passed via \`--reasoning-effort\`
- maxTurns (number, optional): maximum agent turns for the run
- command (string, optional): defaults to "grok"
- extraArgs (string[], optional): additional CLI args
@@ -160,6 +160,19 @@ async function makeCtx(runId: string, cwd: string): Promise<AdapterExecutionCont
}
describe("grok_local execute", () => {
it.each(["grok-4.7", "grok-4.6"])("forwards the explicit %s model and xhigh effort", async (model) => {
const root = await makeTempRoot();
const ctx = await makeCtx("model-selection", root);
ctx.config = { cwd: root, model, reasoningEffort: "xhigh" };
runProcessMock.mockResolvedValue(makeSuccessfulRunResult());
await execute(ctx);
const args = runProcessMock.mock.calls[0][3] as string[];
expect(args[args.indexOf("--model") + 1]).toBe(model);
expect(args[args.indexOf("--reasoning-effort") + 1]).toBe("xhigh");
});
beforeEach(() => {
mocks.state.isRemote = false;
mocks.state.prepareRuntimeResult = null;
+8 -3
View File
@@ -8,9 +8,10 @@ export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@moonshot-
export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-code/kimi-for-coding";
export const models = [
{ id: DEFAULT_KIMI_LOCAL_MODEL, label: "K2.7 Coding" },
{ id: DEFAULT_KIMI_LOCAL_MODEL, label: "K2.8 Preview" },
{ id: "kimi-code/kimi-for-coding-highspeed", label: "K2.7 Coding Highspeed" },
{ id: "kimi-code/k3", label: "K3" },
{ id: "kimi-code/k3-256k", label: "K3 (256K)" },
];
/**
@@ -26,7 +27,11 @@ export type KimiEffort = (typeof KIMI_SUPPORTED_EFFORTS)[number];
* Models that advertise `support_efforts` in Kimi's model catalog. Keep in
* sync with `models` above; only these accept KIMI_MODEL_THINKING_EFFORT.
*/
export const EFFORT_CAPABLE_MODELS = new Set<string>(["kimi-code/k3"]);
export const EFFORT_CAPABLE_MODELS = new Set<string>([
DEFAULT_KIMI_LOCAL_MODEL,
"kimi-code/k3",
"kimi-code/k3-256k",
]);
export function modelSupportsEffort(model: string): boolean {
return EFFORT_CAPABLE_MODELS.has(model.trim());
@@ -65,7 +70,7 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt. Sibling files in the same directory (HEARTBEAT.md, SOUL.md, TOOLS.md) are made readable via --add-dir for local runs.
- promptTemplate (string, optional): run prompt template
- model (string, optional): Kimi model alias (provider/model). Defaults to kimi-code/kimi-for-coding.
- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (currently kimi-code/k3); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters.
- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (K2.8 Preview, K3, and K3 256K); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters.
- command (string, optional): defaults to "kimi"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
@@ -333,7 +333,7 @@ describe("kimi_local execute", () => {
expect(seenEnv.TERM).toBe("xterm-256color");
});
it("forwards configured effort as KIMI_MODEL_THINKING_EFFORT for effort-capable models", async () => {
it.each(["kimi-code/k3", "kimi-code/k3-256k", "kimi-code/kimi-for-coding"])("forwards configured effort for %s", async (model) => {
const root = await makeTempRoot();
let seenEnv: Record<string, string> = {};
runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => {
@@ -341,7 +341,7 @@ describe("kimi_local execute", () => {
return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" };
});
await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3", effort: "high" } }));
await execute(makeContext(root, { config: { cwd: root, model, effort: "high" } }));
expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBe("high");
});
@@ -368,7 +368,7 @@ describe("kimi_local execute", () => {
});
await execute(makeContext(root, {
config: { cwd: root, model: "kimi-code/kimi-for-coding", effort: "high" },
config: { cwd: root, model: "kimi-code/kimi-for-coding-highspeed", effort: "high" },
}));
expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBeUndefined();
@@ -53,6 +53,18 @@ export function isValidOpenCodeModelId(value: unknown): value is string {
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-6-astra", label: "openai/gpt-6-astra" },
{ id: "openai/gpt-6-sol", label: "openai/gpt-6-sol" },
{ id: "openai/gpt-6-luna", label: "openai/gpt-6-luna" },
{ id: "openai/gpt-5.6-sol", label: "openai/gpt-5.6-sol" },
{ id: "openai/gpt-5.6-terra", label: "openai/gpt-5.6-terra" },
{ id: "openai/gpt-5.6-luna", label: "openai/gpt-5.6-luna" },
{ id: "anthropic/claude-opus-5-5", label: "anthropic/claude-opus-5-5" },
{ id: "anthropic/claude-opus-5", label: "anthropic/claude-opus-5" },
{ id: "anthropic/claude-fable-5-1", label: "anthropic/claude-fable-5-1" },
{ id: "anthropic/claude-sonnet-5", label: "anthropic/claude-sonnet-5" },
{ id: "google/gemini-3.8-flash", label: "google/gemini-3.8-flash" },
{ id: "xai/grok-4.7", label: "xai/grok-4.7" },
{ id: "openai/gpt-5.5", label: "openai/gpt-5.5" },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
{ id: "openai/gpt-5.4-mini", label: "openai/gpt-5.4-mini" },
@@ -177,25 +177,25 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:828 | optional_agent_tool | skills/paperclip/references/api-reference.md:828 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:857 | optional_agent_tool | skills/paperclip/references/api-reference.md:857 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:861 | optional_agent_tool | skills/paperclip/references/api-reference.md:861 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:893 | optional_agent_tool | skills/paperclip/references/api-reference.md:893 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:902 | always_agent_tool | skills/paperclip/references/api-reference.md:902 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:999 | always_agent_tool | skills/paperclip/references/api-reference.md:999 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1057 | always_agent_tool | skills/paperclip/references/api-reference.md:1057 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1172 | optional_agent_tool | skills/paperclip/references/api-reference.md:1172 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1282 | optional_agent_tool | skills/paperclip/references/api-reference.md:1282 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1288 | always_agent_tool | skills/paperclip/references/api-reference.md:1288 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1306 | always_agent_tool | skills/paperclip/references/api-reference.md:1306 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1336 | control_plane_owned | skills/paperclip/references/api-reference.md:1336 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1350 | optional_agent_tool | skills/paperclip/references/api-reference.md:1350 |
| skill:skills/paperclip/references/api-reference.md:agents:1352 | optional_agent_tool | skills/paperclip/references/api-reference.md:1352 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1373 | optional_agent_tool | skills/paperclip/references/api-reference.md:1373 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1413 | optional_agent_tool | skills/paperclip/references/api-reference.md:1413 |
| skill:skills/paperclip/references/api-reference.md:routines:1437 | optional_agent_tool | skills/paperclip/references/api-reference.md:1437 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1453 | optional_agent_tool | skills/paperclip/references/api-reference.md:1453 |
| skill:skills/paperclip/references/api-reference.md:secrets:1475 | optional_agent_tool | skills/paperclip/references/api-reference.md:1475 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1488 | optional_agent_tool | skills/paperclip/references/api-reference.md:1488 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1588 | optional_agent_tool | skills/paperclip/references/api-reference.md:1588 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1628 | optional_agent_tool | skills/paperclip/references/api-reference.md:1628 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:926 | optional_agent_tool | skills/paperclip/references/api-reference.md:926 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:935 | always_agent_tool | skills/paperclip/references/api-reference.md:935 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1032 | always_agent_tool | skills/paperclip/references/api-reference.md:1032 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1090 | always_agent_tool | skills/paperclip/references/api-reference.md:1090 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1205 | optional_agent_tool | skills/paperclip/references/api-reference.md:1205 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1315 | optional_agent_tool | skills/paperclip/references/api-reference.md:1315 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1321 | always_agent_tool | skills/paperclip/references/api-reference.md:1321 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1339 | always_agent_tool | skills/paperclip/references/api-reference.md:1339 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1369 | control_plane_owned | skills/paperclip/references/api-reference.md:1369 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1383 | optional_agent_tool | skills/paperclip/references/api-reference.md:1383 |
| skill:skills/paperclip/references/api-reference.md:agents:1385 | optional_agent_tool | skills/paperclip/references/api-reference.md:1385 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1406 | optional_agent_tool | skills/paperclip/references/api-reference.md:1406 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1446 | optional_agent_tool | skills/paperclip/references/api-reference.md:1446 |
| skill:skills/paperclip/references/api-reference.md:routines:1470 | optional_agent_tool | skills/paperclip/references/api-reference.md:1470 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1486 | optional_agent_tool | skills/paperclip/references/api-reference.md:1486 |
| skill:skills/paperclip/references/api-reference.md:secrets:1508 | optional_agent_tool | skills/paperclip/references/api-reference.md:1508 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1521 | optional_agent_tool | skills/paperclip/references/api-reference.md:1521 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1621 | optional_agent_tool | skills/paperclip/references/api-reference.md:1621 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1661 | optional_agent_tool | skills/paperclip/references/api-reference.md:1661 |
## Legacy MCP Alias Index
@@ -722,180 +722,180 @@
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:893",
"id": "skill:skills/paperclip/references/api-reference.md:926",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L893:ceo-strategy-approval",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L926:ceo-strategy-approval",
"heading": "CEO strategy approval",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:902",
"id": "skill:skills/paperclip/references/api-reference.md:935",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L902:questions-and-waiting-for-human-input",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L935:questions-and-waiting-for-human-input",
"heading": "Questions and waiting for human input",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:999",
"id": "skill:skills/paperclip/references/api-reference.md:1032",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L999:issue-thread-confirmations",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1032:issue-thread-confirmations",
"heading": "Issue-thread confirmations",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1057",
"id": "skill:skills/paperclip/references/api-reference.md:1090",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1057:checkbox-confirmations",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1090:checkbox-confirmations",
"heading": "Checkbox confirmations",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1172",
"id": "skill:skills/paperclip/references/api-reference.md:1205",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1172:item-verdict-requests",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1205:item-verdict-requests",
"heading": "Item verdict requests",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1282",
"id": "skill:skills/paperclip/references/api-reference.md:1315",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1282:checking-approval-status",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1315:checking-approval-status",
"heading": "Checking approval status",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1288",
"id": "skill:skills/paperclip/references/api-reference.md:1321",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1288:approval-follow-up-requesting-agent",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1321:approval-follow-up-requesting-agent",
"heading": "Approval follow-up (requesting agent)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1306",
"id": "skill:skills/paperclip/references/api-reference.md:1339",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1306:issue-lifecycle",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1339:issue-lifecycle",
"heading": "Issue Lifecycle",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1336",
"id": "skill:skills/paperclip/references/api-reference.md:1369",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1336:error-handling",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1369:error-handling",
"heading": "Error Handling",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1350",
"id": "skill:skills/paperclip/references/api-reference.md:1383",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1350:full-api-reference",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1383:full-api-reference",
"heading": "Full API Reference",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1352",
"id": "skill:skills/paperclip/references/api-reference.md:1385",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1352:agents",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1385:agents",
"heading": "Agents",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1373",
"id": "skill:skills/paperclip/references/api-reference.md:1406",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1373:issues-tasks",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1406:issues-tasks",
"heading": "Issues (Tasks)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1413",
"id": "skill:skills/paperclip/references/api-reference.md:1446",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1413:companies-projects-goals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1446:companies-projects-goals",
"heading": "Companies, Projects, Goals",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1437",
"id": "skill:skills/paperclip/references/api-reference.md:1470",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1437:routines",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1470:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1453",
"id": "skill:skills/paperclip/references/api-reference.md:1486",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1453:approvals-costs-activity-dashboard",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1486:approvals-costs-activity-dashboard",
"heading": "Approvals, Costs, Activity, Dashboard",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1475",
"id": "skill:skills/paperclip/references/api-reference.md:1508",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1475:secrets",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1508:secrets",
"heading": "Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1488",
"id": "skill:skills/paperclip/references/api-reference.md:1521",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1488:agent-secret-proposals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1521:agent-secret-proposals",
"heading": "Agent secret proposals",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1541",
"id": "skill:skills/paperclip/references/api-reference.md:1574",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1541:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1574:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
"heading": "Re-bind an existing secret under a new path (no secret ID)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1588",
"id": "skill:skills/paperclip/references/api-reference.md:1621",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1588:agent-secret-access",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1621:agent-secret-access",
"heading": "Agent secret access",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1628",
"id": "skill:skills/paperclip/references/api-reference.md:1661",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1628:common-mistakes",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1661:common-mistakes",
"heading": "Common Mistakes",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 156
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `b42cfa2dbf4d314914ca18c77829f54e06b585892e38b1c4857b583989367a03`
- Deterministic content SHA-256: `5a87f905c451fb79ec5d5fc9a961d3cbc4ba96c8486265088b5da2fda30e4831`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -1206,6 +1206,82 @@
"title": "List company agents",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Create one native Paperclip Runner teammate for the current company and task. The new agent reports to you, inherits your native runtime, and receives no provider, adapter, environment, or credential configuration from the tool. Reuse an existing teammate when appropriate and follow any approval returned by the API.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"capabilities": {
"description": "Optional concise capability summary.",
"maxLength": 2000,
"type": [
"string",
"null"
]
},
"instructions": {
"description": "Optional persona or task instructions.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"name": {
"description": "Name for the new teammate.",
"maxLength": 200,
"minLength": 1,
"type": "string"
},
"role": {
"default": "general",
"enum": [
"ceo",
"cto",
"cmo",
"cfo",
"security",
"engineer",
"designer",
"pm",
"qa",
"devops",
"researcher",
"general"
]
},
"title": {
"description": "Optional teammate title.",
"maxLength": 300,
"type": [
"string",
"null"
]
}
},
"required": [
"name"
],
"type": "object"
},
"operationId": "hire_agent",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [
"delegation:agents:create"
],
"schema": "paperclip.semantic-action.v1",
"title": "Hire a native agent",
"version": 1
},
{
"allowedModes": [
"standard",
+4 -3
View File
@@ -160,16 +160,17 @@
"browser:preview": "vite preview --config vite.config.ts",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet",
"verify:rootless": "bash scripts/verify-rootless-linux.sh",
"install:grok": "pnpm --filter @paperclipai/grok-acp install:binary"
"install:grok": "pnpm --filter @paperclipai/grok-acp install:binary",
"test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.73.0",
"@agentclientprotocol/codex-acp": "1.6.2",
"@openai/codex": "0.153.4",
"@openai/codex": "0.156.0",
"acpx": "0.13.1",
"ajv": "^8.20.0",
"json-schema-to-ts": "^3.1.1",
"opencode-ai": "1.18.29",
"opencode-ai": "1.18.32",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"smol-toml": "^1.4.2",
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:73321806bf90df701a9677a7a9527c5b3d39485078e4d3fa4bb3a244a74a0c3b",
"catalogSha256": "sha256:1fa8ecf5d3c28e62a32e818de95633ff3efe5d4af1a100310516ff9b5ab2358d",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -70,7 +70,7 @@
{
"path": "schemas/provider-event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
"sha256": "e0ffb95cbde4afb65783106f471c5154cbcc59055461f96838cd8a5239e5ec56"
"sha256": "0b138a41bd3bd0b1a115f5536a2d7349537e79e36ede3b51d003f9f21ce73b50"
},
{
"path": "schemas/question-adapter-fixture.schema.json",
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "ab9247489ae2427fec5a86c54b55698ac3e211bd65d93cf4e973e363050455fa",
"sha256": "5f3144ea856ad27778f47de73eb542703b1d9df2abad2c427838fbaf69c3d59f",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -54,6 +54,7 @@
"target": { "$ref": "#/$defs/safePath" },
"namespace": { "type": ["string", "null"], "maxLength": 240 },
"readOnly": { "type": ["boolean", "null"] },
"inputUpdated": { "type": "boolean" },
"status": { "enum": ["running", "completed", "failed", "cancelled", "interrupted"] },
"durationMs": { "type": ["integer", "null"], "minimum": 0 },
"exitCode": { "type": ["integer", "null"] },
@@ -151,7 +151,7 @@ impl AcpxProviderDescriptor {
"@agentclientprotocol/claude-agent-acp",
"0.73.0",
Some("@anthropic-ai/claude-agent-sdk"),
Some("0.3.263"),
Some("0.3.280"),
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
),
"codex" => (
@@ -159,7 +159,7 @@ impl AcpxProviderDescriptor {
"@agentclientprotocol/codex-acp",
"1.6.2",
Some("@openai/codex"),
Some("0.153.4"),
Some("0.156.0"),
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
),
"grok" => (
@@ -1860,7 +1860,7 @@ mod tests {
"@agentclientprotocol/claude-agent-acp",
"0.73.0",
json!("@anthropic-ai/claude-agent-sdk"),
json!("0.3.263"),
json!("0.3.280"),
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
)
} else {
@@ -1869,7 +1869,7 @@ mod tests {
"@agentclientprotocol/codex-acp",
"1.6.2",
json!("@openai/codex"),
json!("0.153.4"),
json!("0.156.0"),
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
)
};
@@ -24,7 +24,7 @@ use crate::qualified_launch::verify_launch_artifact;
use crate::question_response::validate_question_response;
pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
const QUALIFIED_OPENCODE_VERSION: &str = "1.18.29";
const QUALIFIED_OPENCODE_VERSION: &str = "1.18.32";
const DEFAULT_PROVIDER_TRACE_MAX_BYTES: usize = 64 * 1024 * 1024;
const MAX_BUFFERED_MESSAGES: usize = 1_024;
const MAX_BUFFERED_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
@@ -4887,7 +4887,7 @@ mod tests {
CodexProviderConfig {
provider: "opencode".to_owned(),
driver: "opencode_server".to_owned(),
provider_version: "1.18.29".to_owned(),
provider_version: "1.18.32".to_owned(),
command: PathBuf::from("node"),
args: Vec::new(),
cwd: std::env::current_dir()
@@ -5248,7 +5248,7 @@ mod tests {
CodexProviderConfig {
provider: "opencode".to_owned(),
driver: "opencode_server".to_owned(),
provider_version: "1.18.29".to_owned(),
provider_version: "1.18.32".to_owned(),
command: PathBuf::from("node"),
args: Vec::new(),
cwd: std::env::current_dir()
@@ -1168,6 +1168,9 @@ fn normalize_acpx_tool_call(
.unwrap_or(Value::Null)
},
});
if let Some(input_updated) = payload.get("inputUpdated").and_then(Value::as_bool) {
normalized["inputUpdated"] = Value::Bool(input_updated);
}
if let (Some(object), Value::Object(output)) =
(normalized.as_object_mut(), bounded_output(&output))
{
@@ -1424,6 +1427,33 @@ mod tests {
}
}
#[test]
fn preserves_only_typed_acpx_input_update_marker() {
for input_updated in [
Value::Bool(true),
Value::Bool(false),
json!("secret"),
Value::Null,
] {
let events = normalize_acpx_tool_call(
&json!({
"type": "tool_call", "tag": "tool_call_update", "status": "pending",
"title": "mcp__paperclip__hire_agent", "inputUpdated": input_updated,
"rawInput": {"private": "secret-input"},
}),
"provider-tool-id",
"execute",
);
assert_eq!(events[0].event_type, "tool.execution.progressed");
if input_updated.is_boolean() {
assert_eq!(events[0].payload["inputUpdated"], input_updated);
} else {
assert!(events[0].payload.get("inputUpdated").is_none());
}
assert!(!events[0].payload.to_string().contains("secret"));
}
}
#[test]
fn maps_codex_plan_without_retaining_native_envelope() {
let events = normalize_codex_notification(
@@ -83,7 +83,7 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo
"providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol",
"acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp",
"agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex",
"agentRuntimeVersion": "0.153.4", "commandDigest": PROFILE_DIGEST,
"agentRuntimeVersion": "0.156.0", "commandDigest": PROFILE_DIGEST,
"sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0,
"normalizedSessionId": "session-1", "runId": "run-1", "cwd": fixture.0,
"instructions": "Complete the supplied work.",
@@ -168,7 +168,7 @@ fn prepare_payload(directory: &Path, agent: &str) -> Value {
fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value {
let operations = Vec::new();
let (runtime_package, runtime_version) = if agent == "codex" {
(json!("@openai/codex"), json!("0.153.4"))
(json!("@openai/codex"), json!("0.156.0"))
} else {
(Value::Null, Value::Null)
};
@@ -265,7 +265,7 @@ fn opencode_prepare_payload(directory: &Path) -> Value {
"kind": "opencode",
"provider": "opencode",
"driver": "opencode_server",
"providerVersion": "1.18.29",
"providerVersion": "1.18.32",
"command": directory.join("qualified-opencode-proxy-command"),
"args": [directory.join("qualified-opencode-proxy-script")],
"cwd": directory,
@@ -296,8 +296,8 @@ try {
const payload = {
pins: {
nodeMinimum: minimumNodeVersion.join("."),
codex: "0.153.4",
opencode: "1.18.29",
codex: "0.156.0",
opencode: "1.18.32",
acpx: "0.13.1",
grok: "1.0.13",
claudeAcp: "0.73.0",
@@ -13,7 +13,7 @@ import {
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const OPENCODE_VERSION = "1.18.29";
const OPENCODE_VERSION = "1.18.32";
const BASELINE_PACKAGE = "opencode-linux-x64-baseline";
function readPackage(path) {
@@ -35,14 +35,14 @@ async function fixture(options = {}) {
join(packageRoot, "package.json"),
JSON.stringify({
name: "opencode-ai",
version: options.packageVersion ?? "1.18.29",
version: options.packageVersion ?? "1.18.32",
}),
),
writeFile(
join(baselineRoot, "package.json"),
JSON.stringify({
name: "opencode-linux-x64-baseline",
version: options.baselineVersion ?? "1.18.29",
version: options.baselineVersion ?? "1.18.32",
}),
),
writeFile(join(packageRoot, "bin", "opencode.exe"), "sentinel\n"),
@@ -50,11 +50,11 @@ async function fixture(options = {}) {
const source = join(baselineRoot, "bin", "opencode");
if (options.symlinkSource) {
const realSource = join(root, "real-opencode");
await writeFile(realSource, "#!/bin/sh\necho 1.18.29\n");
await writeFile(realSource, "#!/bin/sh\necho 1.18.32\n");
await chmod(realSource, 0o755);
await symlink(realSource, source);
} else {
await writeFile(source, "#!/bin/sh\necho 1.18.29\n");
await writeFile(source, "#!/bin/sh\necho 1.18.32\n");
await chmod(source, 0o755);
}
return packageRoot;
@@ -67,7 +67,7 @@ test("materializes the pinned baseline executable with a verified version", asyn
platform: "linux",
architecture: "x64",
});
assert.equal(result.version, "1.18.29");
assert.equal(result.version, "1.18.32");
assert.match(result.sourceDigest, /^[0-9a-f]{64}$/);
});
@@ -80,7 +80,7 @@ test("refuses version, file-type, and platform drift", async () => {
platform: "linux",
architecture: "x64",
}),
/Expected opencode-linux-x64-baseline@1\.18\.29/,
/Expected opencode-linux-x64-baseline@1\.18\.32/,
);
const symlinkSource = await fixture({ symlinkSource: true });
@@ -0,0 +1,146 @@
// Opt-in qualification of the installed CLI, not the fake protocol server.
// PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs
import assert from "node:assert/strict";
import { spawn, execFileSync } from "node:child_process";
import { once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import test from "node:test";
const enabled = process.env.PAPERCLIP_OPENCODE_QUALIFY === "1";
const packageRoot = resolve(import.meta.dirname, "..");
const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
test("the pinned OpenCode executable serves health, sessions, SSE, and a local-provider prompt", {
skip: !enabled,
timeout: 60_000,
}, async (t) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-qualification-"));
let provider;
let child;
let exited;
const streamAbort = new AbortController();
const killGroup = (signal) => {
if (!child?.pid) return;
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
};
t.after(async () => {
streamAbort.abort();
if (child?.pid) {
killGroup("SIGTERM");
const kill = setTimeout(() => killGroup("SIGKILL"), 3000);
try { await exited; } finally { clearTimeout(kill); killGroup("SIGKILL"); }
}
if (provider) { provider.closeAllConnections(); await new Promise((done) => provider.close(done)); }
await rm(root, { recursive: true, force: true });
});
const command = resolve(process.env.PAPERCLIP_TEST_OPENCODE_BINARY ?? join(packageRoot, "node_modules/opencode-ai/bin/opencode.exe"));
assert.equal(execFileSync(command, ["--version"], { encoding: "utf8", timeout: 10_000 }).trim(), manifest.dependencies["opencode-ai"]);
const requests = [];
provider = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
requests.push({ path: request.url, body });
if (request.url !== "/v1/chat/completions") {
response.writeHead(404).end();
return;
}
response.writeHead(200, { "Content-Type": "text/event-stream" });
for (const [delta, finish_reason] of [
[{ role: "assistant", content: "paperclip-opencode-qualified" }, null],
[{}, "stop"],
]) {
response.write(`data: ${JSON.stringify({ id: "chatcmpl-qualification", object: "chat.completion.chunk", created: 1, model: "qualification", choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
}
response.end("data: [DONE]\n\n");
});
provider.listen(0, "127.0.0.1");
await once(provider, "listening");
const providerUrl = `http://127.0.0.1:${provider.address().port}/v1`;
const config = join(root, "opencode.json");
await writeFile(config, JSON.stringify({
model: "openrouter/qualification",
small_model: "openrouter/qualification",
share: "disabled", autoupdate: false, plugin: [],
enabled_providers: ["openrouter"],
provider: { openrouter: { options: { baseURL: providerUrl, apiKey: "local-fixture-only" }, models: { qualification: { name: "qualification", limit: { context: 10000, output: 1000 } } } } },
permission: { "*": "deny" },
}));
const workspace = join(root, "workspace");
await mkdir(workspace);
child = spawn(command, ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
cwd: workspace,
env: {
PATH: `${dirname(process.execPath)}:${process.env.PATH ?? "/usr/bin:/bin"}`,
HOME: root, XDG_CONFIG_HOME: join(root, "config"), XDG_DATA_HOME: join(root, "data"), XDG_CACHE_HOME: join(root, "cache"),
OPENCODE_CONFIG: config,
OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_SERVER_USERNAME: "paperclip", OPENCODE_SERVER_PASSWORD: "local-fixture-only",
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let output = "";
for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { output = `${output}${chunk}`.slice(-16384); });
exited = once(child, "exit");
let baseUrl;
for (let attempt = 0; attempt < 150; attempt++) {
baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0];
if (baseUrl) break;
assert.equal(child.exitCode, null, output);
await delay(100);
}
assert.ok(baseUrl, `OpenCode did not start: ${output}`);
const headers = { Authorization: `Basic ${Buffer.from("paperclip:local-fixture-only").toString("base64")}`, "Content-Type": "application/json" };
const api = async (path, options = {}) => {
const response = await fetch(`${baseUrl}${path}`, { headers, signal: AbortSignal.timeout(15_000), ...options });
assert.ok(response.ok, `${path}: ${response.status} ${await response.clone().text()}`);
return response;
};
const health = await (await api("/global/health")).json();
assert.deepEqual(health, { healthy: true, version: manifest.dependencies["opencode-ai"] });
const session = await (await api("/session", { method: "POST", body: JSON.stringify({ title: "Runtime qualification" }) })).json();
assert.equal((await (await api(`/session/${session.id}`)).json()).id, session.id);
const stream = await fetch(`${baseUrl}/event`, { headers, signal: streamAbort.signal });
assert.equal(stream.status, 200);
assert.match(stream.headers.get("content-type"), /text\/event-stream/);
const reader = stream.body.getReader();
const events = [];
const readEvents = (async () => {
let buffer = "";
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2);
for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6)));
}
}
} catch (error) { if (!streamAbort.signal.aborted) throw error; }
})();
const prompt = await api(`/session/${session.id}/prompt_async`, {
method: "POST",
body: JSON.stringify({ model: { providerID: "openrouter", modelID: "qualification" }, parts: [{ type: "text", text: "Reply with the qualification marker." }] }),
});
assert.equal(prompt.status, 204);
let messages;
for (let attempt = 0; attempt < 150; attempt++) {
messages = await (await api(`/session/${session.id}/message`)).json();
if (messages.some((message) => message.info.role === "assistant" && message.parts.some((part) => part.type === "text" && part.text === "paperclip-opencode-qualified"))) break;
await delay(100);
}
assert.ok(messages.some((message) => message.info.role === "assistant" && message.parts.some((part) => part.type === "text" && part.text === "paperclip-opencode-qualified")), JSON.stringify(messages));
assert.ok(requests.some((request) => request.path === "/v1/chat/completions" && request.body.model === "qualification"));
assert.ok(events.some((event) => event.type === "message.part.updated" || event.type === "message.part.delta"));
streamAbort.abort();
await readEvents;
assert.equal((await api(`/session/${session.id}`, { method: "DELETE" })).status, 200);
});
@@ -34,7 +34,7 @@ async function fixture() {
provider: "opencode",
driver: "opencode_server",
model: "openrouter/example/model",
opencodeVersion: "1.18.29",
opencodeVersion: "1.18.32",
};
const evalCase = {
schema: "paperclip-runner/eval-case/v1",
@@ -2054,9 +2054,9 @@
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:893",
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:926",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:893",
"sourceAnchor": "skills/paperclip/references/api-reference.md:926",
"title": "CEO strategy approval",
"expectedSemantics": "Skill guidance headed “CEO strategy approval”.",
"primaryDisposition": "optional_agent_tool",
@@ -2065,13 +2065,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:893"
"skill:skills/paperclip/references/api-reference.md:926"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:902",
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:935",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:902",
"sourceAnchor": "skills/paperclip/references/api-reference.md:935",
"title": "Questions and waiting for human input",
"expectedSemantics": "Skill guidance headed “Questions and waiting for human input”.",
"primaryDisposition": "always_agent_tool",
@@ -2080,13 +2080,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:902"
"skill:skills/paperclip/references/api-reference.md:935"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:999",
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1032",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:999",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1032",
"title": "Issue-thread confirmations",
"expectedSemantics": "Skill guidance headed “Issue-thread confirmations”.",
"primaryDisposition": "always_agent_tool",
@@ -2095,13 +2095,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:999"
"skill:skills/paperclip/references/api-reference.md:1032"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1057",
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1090",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1057",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1090",
"title": "Checkbox confirmations",
"expectedSemantics": "Skill guidance headed “Checkbox confirmations”.",
"primaryDisposition": "always_agent_tool",
@@ -2110,13 +2110,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1057"
"skill:skills/paperclip/references/api-reference.md:1090"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1172",
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1205",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1172",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1205",
"title": "Item verdict requests",
"expectedSemantics": "Skill guidance headed “Item verdict requests”.",
"primaryDisposition": "optional_agent_tool",
@@ -2125,13 +2125,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1172"
"skill:skills/paperclip/references/api-reference.md:1205"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1282",
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1315",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1282",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1315",
"title": "Checking approval status",
"expectedSemantics": "Skill guidance headed “Checking approval status”.",
"primaryDisposition": "optional_agent_tool",
@@ -2140,13 +2140,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1282"
"skill:skills/paperclip/references/api-reference.md:1315"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1288",
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1321",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1288",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1321",
"title": "Approval follow-up (requesting agent)",
"expectedSemantics": "Skill guidance headed “Approval follow-up (requesting agent)”.",
"primaryDisposition": "always_agent_tool",
@@ -2155,13 +2155,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1288"
"skill:skills/paperclip/references/api-reference.md:1321"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1306",
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1339",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1306",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1339",
"title": "Issue Lifecycle",
"expectedSemantics": "Skill guidance headed “Issue Lifecycle”.",
"primaryDisposition": "always_agent_tool",
@@ -2170,13 +2170,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1306"
"skill:skills/paperclip/references/api-reference.md:1339"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1336",
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1369",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1336",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1369",
"title": "Error Handling",
"expectedSemantics": "Skill guidance headed “Error Handling”.",
"primaryDisposition": "control_plane_owned",
@@ -2185,13 +2185,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1336"
"skill:skills/paperclip/references/api-reference.md:1369"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1350",
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1383",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1350",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1383",
"title": "Full API Reference",
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
"primaryDisposition": "optional_agent_tool",
@@ -2200,13 +2200,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1350"
"skill:skills/paperclip/references/api-reference.md:1383"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agents:1352",
"id": "skill:skills/paperclip/references/api-reference.md:agents:1385",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1352",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1385",
"title": "Agents",
"expectedSemantics": "Skill guidance headed “Agents”.",
"primaryDisposition": "optional_agent_tool",
@@ -2215,13 +2215,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1352"
"skill:skills/paperclip/references/api-reference.md:1385"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1373",
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1406",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1373",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1406",
"title": "Issues (Tasks)",
"expectedSemantics": "Skill guidance headed “Issues (Tasks)”.",
"primaryDisposition": "optional_agent_tool",
@@ -2230,13 +2230,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1373"
"skill:skills/paperclip/references/api-reference.md:1406"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1413",
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1446",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1413",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1446",
"title": "Companies, Projects, Goals",
"expectedSemantics": "Skill guidance headed “Companies, Projects, Goals”.",
"primaryDisposition": "optional_agent_tool",
@@ -2245,13 +2245,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1413"
"skill:skills/paperclip/references/api-reference.md:1446"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:routines:1437",
"id": "skill:skills/paperclip/references/api-reference.md:routines:1470",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1437",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1470",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@@ -2260,13 +2260,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1437"
"skill:skills/paperclip/references/api-reference.md:1470"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1453",
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1486",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1453",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1486",
"title": "Approvals, Costs, Activity, Dashboard",
"expectedSemantics": "Skill guidance headed “Approvals, Costs, Activity, Dashboard”.",
"primaryDisposition": "optional_agent_tool",
@@ -2275,13 +2275,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1453"
"skill:skills/paperclip/references/api-reference.md:1486"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1475",
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1508",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1475",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1508",
"title": "Secrets",
"expectedSemantics": "Skill guidance headed “Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -2290,13 +2290,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1475"
"skill:skills/paperclip/references/api-reference.md:1508"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1488",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1521",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1488",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1521",
"title": "Agent secret proposals",
"expectedSemantics": "Skill guidance headed “Agent secret proposals”.",
"primaryDisposition": "optional_agent_tool",
@@ -2305,13 +2305,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1488"
"skill:skills/paperclip/references/api-reference.md:1521"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1588",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1621",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1588",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1621",
"title": "Agent secret access",
"expectedSemantics": "Skill guidance headed “Agent secret access”.",
"primaryDisposition": "optional_agent_tool",
@@ -2320,13 +2320,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1588"
"skill:skills/paperclip/references/api-reference.md:1621"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1628",
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1661",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1628",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1661",
"title": "Common Mistakes",
"expectedSemantics": "Skill guidance headed “Common Mistakes”.",
"primaryDisposition": "optional_agent_tool",
@@ -2335,7 +2335,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1628"
"skill:skills/paperclip/references/api-reference.md:1661"
]
}
]
@@ -7,7 +7,7 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 47,
"actions": 48,
"legacyRequirements": 106
},
"actions": [
@@ -802,6 +802,25 @@
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "hire_agent",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:hire_agent",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::hire_agent has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:hire_agent"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::hire_agent has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "list_approvals",
"ownership": "optional_agent_tool",
@@ -58,7 +58,7 @@
{
"id": "delegation_dependencies",
"description": "Create or reassign delegated work and maintain dependency edges.",
"operationIds": ["create_task", "reassign_task", "set_dependencies"]
"operationIds": ["create_task", "reassign_task", "set_dependencies", "hire_agent"]
},
{
"id": "governance",
@@ -180,7 +180,7 @@
"legacyGroup": 6,
"name": "Subtasks",
"owner": "optional delegation tools",
"operationIds": ["create_task", "reassign_task"],
"operationIds": ["create_task", "reassign_task", "hire_agent"],
"controlPlaneOperationIds": ["route_wake"],
"realSurface": "company issue create, child issue, guarded reassignment, and wake services",
"mockStateDomains": ["company", "task", "actor", "blockers", "wake", "audit"],
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **47 reconciled semantic operations** (14 always, 33 optional), and **106 scenarios in 16 behavior groups**.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **48 reconciled semantic operations** (14 always, 34 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
### Optional operations (33) and grant groups (13)
### Optional operations (34) and grant groups (13)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
@@ -95,7 +95,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| --- | --- | --- | --- |
| `discovery` | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `projects` | `create_project`<br>`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`<br>`reassign_task`<br>`set_dependencies` | `delegation:tasks:assign`<br>`delegation:tasks:create`<br>`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `delegation_dependencies` | `create_task`<br>`reassign_task`<br>`set_dependencies`<br>`hire_agent` | `delegation:agents:create`<br>`delegation:tasks:assign`<br>`delegation:tasks:create`<br>`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
@@ -132,6 +132,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority active issue/run + accepted plan revision`<br>bound company/assignment query plus exact accepted document revision projection<br>catalog PRP status: `bound` |
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_workspace_runtime` | `optional_agent_tool` | `workspace:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_workspace` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `hire_agent` | `optional_agent_tool` | `delegation:agents:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and agent-hire state diff with activity record.<br>catalog PRP status: `bound` |
| `inspect_operation_result` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `operation_result` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
@@ -174,7 +175,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`<br>`finish_task`<br>`block_task`<br>`request_review` | `reconcile_run`<br>`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`<br>`comments`<br>`interactions`<br>`blockers`<br>`audit`<br>`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. |
| [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`<br>`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`<br>`comments`<br>`actor`<br>`idempotency`<br>`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. |
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. |
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task`<br>`reassign_task` | `route_wake` | company issue create, child issue, guarded reassignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task`<br>`reassign_task`<br>`hire_agent` | `route_wake` | company issue create, child issue, guarded reassignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
| [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`<br>`set_dependencies` | `schedule_blocker_wake`<br>`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`<br>`blockers`<br>`wake`<br>`actor`<br>`audit`<br>`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. |
| [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`<br>`read_document`<br>`list_document_revisions`<br>`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`<br>`documents`<br>`interactions`<br>`idempotency`<br>`audit`<br>`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. |
| [`ix` — Interactions](#behavior-group-ix-interactions) | always tools + addressed resolver | `request_human_input` | `route_wake` | issue-thread interaction create/respond/accept/reject/withdraw services | `task`<br>`documents`<br>`interactions`<br>`wake`<br>`actor`<br>`idempotency` | 9 | interaction proposal/materialization/response/delivery and attention events | Production binding and semantic resolution receipts are unbound. |
@@ -458,10 +459,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **39** operations.
- Live dispatcher catalog: **35** operations.
- Shared: **27**; union/canonical authority: **47**.
- Live dispatcher catalog: **36** operations.
- Shared: **27**; union/canonical authority: **48**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `schedule_wake`, `search_api`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
@@ -69,7 +69,7 @@ function transportDriverIdentity(input: NativeExecutionInput): {
return {
kind: "opencode_server",
displayName: "OpenCode server",
version: "1.18.29",
version: "1.18.32",
};
case "claude_managed":
return {
@@ -101,7 +101,7 @@ function acpxExecution(
? "@openai/codex"
: "@anthropic-ai/claude-agent-sdk",
agentRuntimeVersion:
agent === "pi" ? "0.84.2" : agent === "codex" ? "0.153.4" : "0.3.263",
agent === "pi" ? "0.84.2" : agent === "codex" ? "0.156.0" : "0.3.280",
commandDigest:
agent === "codex"
? "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"
@@ -266,7 +266,7 @@ describe("native backend factory", () => {
await expect(backend.descriptor()).resolves.toMatchObject({
kind: "runner",
name: "opencode_server",
version: "1.18.29",
version: "1.18.32",
capabilities: {
steering: false,
resume: true,
@@ -418,7 +418,7 @@ describe("native backend factory", () => {
await expect(backend.descriptor()).resolves.toMatchObject({
kind: "runner",
name: "opencode_server",
version: "1.18.29",
version: "1.18.32",
capabilities: {
resume: true,
interruption: true,
@@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(39);
expect(summary.liveCount).toBe(35);
expect(summary.liveCount).toBe(36);
expect(summary.sharedCount).toBe(27);
expect(summary.unionCount).toBe(47);
expect(summary.unionCount).toBe(48);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
@@ -31,6 +31,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"get_agent",
"get_approval",
"get_approval_context",
"hire_agent",
"list_project_repositories",
"schedule_wake",
"search_api",
@@ -52,7 +53,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(47);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(48);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -86,7 +87,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(47);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(48);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -112,7 +113,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 34,
live_codex: 35,
scenario_mock: 12,
test_only: 1,
});
@@ -69,7 +69,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
expect(operationIds).toHaveLength(34);
expect(operationIds).toHaveLength(35);
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
@@ -78,6 +78,17 @@ describe("semantic action catalog", () => {
).toBe(true);
});
it("declares hire_agent as a native identity-only mutation", () => {
const hire = paperclipSemanticAction("hire_agent");
expect(hire).toMatchObject({
effect: "write",
requiredClaims: ["delegation:agents:create"],
allowedModes: ["standard", "skill_test"],
});
expect(hire?.inputSchema.properties).not.toHaveProperty("adapterConfig");
expect(hire?.inputSchema.properties).not.toHaveProperty("env");
});
it("compiles every operation input and output schema", () => {
const ajv = new Ajv2020({
allErrors: true,
@@ -323,6 +323,30 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
placement: "optional",
requiredClaims: ["discovery:agents:read"],
}),
descriptor({
operationId: "hire_agent",
title: "Hire a native agent",
description:
"Create one native Paperclip Runner teammate for the current company and task. The new agent reports to you, inherits your native runtime, and receives no provider, adapter, environment, or credential configuration from the tool. Reuse an existing teammate when appropriate and follow any approval returned by the API.",
placement: "optional",
effect: "write",
requiredClaims: ["delegation:agents:create"],
allowedModes: STANDARD_MODE,
inputSchema: object(
{
name: text("Name for the new teammate.", 200),
role: {
enum: ["ceo", "cto", "cmo", "cfo", "security", "engineer", "designer", "pm", "qa", "devops", "researcher", "general"],
default: "general",
},
title: nullableText("Optional teammate title.", 300),
capabilities: nullableText("Optional concise capability summary.", 2_000),
instructions: nullableText("Optional persona or task instructions.", 20_000),
},
["name"],
),
outputSchema: openObject,
}),
descriptor({
operationId: "get_agent",
title: "Get company agent",
@@ -15,6 +15,7 @@ export type PaperclipSemanticActionId =
| "block_task"
| "request_review"
| "list_agents"
| "hire_agent"
| "get_agent"
| "search_tasks"
| "list_approvals"
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js";
import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js";
import {
awaitSidecarCleanupWithin,
closeActiveSidecarHostWithin,
@@ -30,6 +31,43 @@ afterEach(async () => {
});
describe("qualified ACPX runtime sidecar", () => {
it("preserves ACP input presence through the bounded sidecar handoff", () => {
const source = readFileSync(
fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)),
"utf8",
);
const start = source.indexOf("function boundRuntimeEventForNormalization");
const end = source.indexOf("\nfunction sanitizeRuntimeEvent", start);
if (start < 0 || end < 0) throw new Error("sidecar normalization source not found");
const functionSource = source
.slice(start, end)
.replace(
/function boundRuntimeEventForNormalization\(\n event: AcpRuntimeEvent,\n\): AcpRuntimeEvent \{/,
"function boundRuntimeEventForNormalization(event) {",
)
.replace(" } as BoundedRuntimeToolEvent;", " };");
const bound = new Function(
"boundedOptionalText", "stableProviderIdentity", "safeAcpxLocations", "openParams", "safeOutput",
`return (${functionSource});`,
)(
(value: unknown, fallback: string, max: number) => typeof value === "string" ? value.slice(0, max) : fallback,
(value: string) => value,
() => [],
null,
() => ({ output: null, outputBytes: 0, outputTruncated: false, outputDigest: null }),
) as (event: Record<string, unknown>) => Record<string, unknown>;
const bounded = bound({
type: "tool_call", toolCallId: "provider-tool", title: "search", kind: "other",
status: "pending", rawInput: { secret: "must not cross" }, rawOutput: null,
});
expect(bounded.inputUpdated).toBe(true);
expect(bounded).not.toHaveProperty("rawInput");
const canonical = canonicalProviderEventsFromAcpxRuntimeEvent(bounded as never, "fallback")[0]!;
expect(canonical.payload).toMatchObject({ inputUpdated: true });
expect(JSON.stringify(canonical.payload)).not.toContain("must not cross");
expect(source).toContain("? boundedTool.inputUpdated");
});
it.each(["paperclip_finish", "paperclip_block"])(
"bounds pending %s calls before reserved handling and resumes admission",
async (operationId) => {
@@ -778,6 +778,7 @@ function rejectTurnWaiters(terminalTurnId: string, message: string): void {
type BoundedRuntimeToolEvent = AcpRuntimeEvent & {
paperclipBoundedTool: true;
inputUpdated: boolean;
paperclipOutput: Record<string, unknown>;
};
@@ -804,6 +805,8 @@ function boundRuntimeEventForNormalization(
text: boundedOptionalText(event.text, "", 4_000),
status: boundedOptionalText(event.status, "", 100),
tag: boundedOptionalText(event.tag, "", 160),
// Keep only whether this update carried input; rawInput is intentionally dropped.
inputUpdated: Object.prototype.hasOwnProperty.call(event, "rawInput") && event.rawInput !== undefined,
paperclipBoundedTool: true,
paperclipOutput: safeOutput(event.rawOutput),
} as BoundedRuntimeToolEvent;
@@ -882,6 +885,10 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
: null,
title: toolTitle,
text: boundedOptionalText(event.text, "", 4_000) || null,
// Preserve only the presence bit; rawInput itself never crosses the sidecar boundary.
inputUpdated: boundedTool.paperclipBoundedTool === true
? boundedTool.inputUpdated
: Object.prototype.hasOwnProperty.call(event, "rawInput") && event.rawInput !== undefined,
...toolClassification,
};
return boundedSidecarValue(
@@ -159,9 +159,9 @@ export function evalSessionProviderVersion(
request: EvalSessionRequest,
): string | null {
if (request.provider === "opencode") {
const version = request.opencodeVersion ?? "1.18.29";
if (version !== "1.18.29") {
throw new Error(`OpenCode evals require exact version 1.18.29; received ${version}`);
const version = request.opencodeVersion ?? "1.18.32";
if (version !== "1.18.32") {
throw new Error(`OpenCode evals require exact version 1.18.32; received ${version}`);
}
return version;
}
@@ -331,7 +331,7 @@ async function handle(message: RpcMessage): Promise<void> {
case "initialize":
result = {
user: { sessionId: "opencode" },
serverInfo: { name: "opencode", version: "1.18.29" },
serverInfo: { name: "opencode", version: "1.18.32" },
};
break;
case "thread/start":
@@ -445,7 +445,7 @@ describe("ACPX installation integrity", () => {
},
{
name: "@anthropic-ai/claude-agent-sdk",
version: "0.3.263",
version: "0.3.280",
directory: join(dependencyRoot, "claude-agent-sdk"),
},
{
@@ -45,13 +45,13 @@ const VERIFIED_PROVIDER_RUNTIME_TARGET_ENV =
const QUALIFIED_CLAUDE_LINUX_X64_RUNTIME = Object.freeze({
runtimePackageName: "@anthropic-ai/claude-agent-sdk",
runtimePackageVersion: "0.3.263",
runtimePackageVersion: "0.3.280",
packageName: "@anthropic-ai/claude-agent-sdk-linux-x64",
packageVersion: "0.3.263",
dependencyDeclaration: "0.3.263",
packageVersion: "0.3.280",
dependencyDeclaration: "0.3.280",
relativeExecutable: "claude",
executableDigest:
"sha256:26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba",
"sha256:1e08503dbdf3c2cb0d706d32f3408277388d1c76ef108673e8fe42c1b322925b",
environmentVariable: "CLAUDE_CODE_EXECUTABLE",
});
@@ -59,24 +59,24 @@ const QUALIFIED_CLAUDE_DARWIN_RUNTIMES = {
arm64: Object.freeze({
...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME,
packageName: "@anthropic-ai/claude-agent-sdk-darwin-arm64",
executableDigest: "sha256:ef5d2909c8af49f31ab6d5487e90316777bc2fac170adfe8160716caa8aaf4f9",
executableDigest: "sha256:387a5c5dcdbb815085edf0baf79591f9d8894efe922bceaf3d75b1b08055229d",
}),
x64: Object.freeze({
...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME,
packageName: "@anthropic-ai/claude-agent-sdk-darwin-x64",
executableDigest: "sha256:a94a8b229fa85c3a316c6b4a35e0aa22bec1aabbd3d1422826ce1d10ddc88751",
executableDigest: "sha256:c1d32d87630482250633208ab77855429b24010ae3086a7ff7539b57b93168d4",
}),
};
const QUALIFIED_CODEX_LINUX_X64_RUNTIME = Object.freeze({
runtimePackageName: "@openai/codex",
runtimePackageVersion: "0.153.4",
runtimePackageVersion: "0.156.0",
packageName: "@openai/codex-linux-x64",
packageVersion: "0.153.4-linux-x64",
dependencyDeclaration: "npm:@openai/codex@0.153.4-linux-x64",
packageVersion: "0.156.0-linux-x64",
dependencyDeclaration: "npm:@openai/codex@0.156.0-linux-x64",
relativeExecutable: "vendor/x86_64-unknown-linux-musl/bin/codex",
executableDigest:
"sha256:56ef98ab4032d317ab26e9b5e5a175650717351edb16ed9cde0cb6d1734d62da",
"sha256:78a11f06e0a2dda42d13fba1d50dc62e8cbdb2d5f69789722f4d4d99b5cdbe30",
environmentVariable: "CODEX_PATH",
});
@@ -92,8 +92,8 @@ const QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES = Object.freeze([
}),
Object.freeze({
packageName: "@anthropic-ai/claude-agent-sdk",
packageVersion: "0.3.263",
// The package's own package.json still declares 0.3.257 — 0.3.263 is
packageVersion: "0.3.280",
// The package's own package.json still declares 0.3.257 — 0.3.280 is
// only what pnpm resolves, forced by the
// "claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk" override in
// the workspace root. This field binds the declared string, not the
@@ -18,7 +18,7 @@ describe("qualified ACPX profiles", () => {
}
});
it.each(["claude-opus-5", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => {
it.each(["claude-opus-5-5", "claude-fable-5-1", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => {
expect(resolveQualifiedAcpxProfile("claude", model)).toMatchObject({
qualificationModel: model, reportedModelId: model,
commandDigest: QUALIFIED_ACPX_PROFILES.claude.commandDigest,
@@ -34,14 +34,14 @@ describe("qualified ACPX profiles", () => {
it("binds Codex ACP to the CLI runtime it launches", () => {
expect(QUALIFIED_ACPX_PROFILES.codex).toMatchObject({
agentRuntimePackage: "@openai/codex",
agentRuntimeVersion: "0.153.4",
agentRuntimeVersion: "0.156.0",
});
});
it("binds Claude ACP to the SDK and native CLI runtime it launches", () => {
expect(QUALIFIED_ACPX_PROFILES.claude).toMatchObject({
agentRuntimePackage: "@anthropic-ai/claude-agent-sdk",
agentRuntimeVersion: "0.3.263",
agentRuntimeVersion: "0.3.280",
});
});
});
@@ -64,7 +64,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
agentServerPackage: "@agentclientprotocol/claude-agent-acp",
agentServerVersion: "0.73.0",
agentRuntimePackage: "@anthropic-ai/claude-agent-sdk",
agentRuntimeVersion: "0.3.263",
agentRuntimeVersion: "0.3.280",
commandDigest:
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
qualificationModel: "claude-sonnet-5",
@@ -80,7 +80,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
agentServerPackage: "@agentclientprotocol/codex-acp",
agentServerVersion: "1.6.2",
agentRuntimePackage: "@openai/codex",
agentRuntimeVersion: "0.153.4",
agentRuntimeVersion: "0.156.0",
commandDigest:
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
qualificationModel: "gpt-5.6-sol",
@@ -309,7 +309,7 @@ describe("OpenCodeServerDriver", () => {
headers: { "Content-Type": "application/json" },
});
if (url.pathname === "/global/health")
return json({ healthy: true, version: "1.18.29" });
return json({ healthy: true, version: "1.18.32" });
if (url.pathname === "/event") {
return new Response(
new ReadableStream<Uint8Array>({
@@ -681,7 +681,7 @@ describe("OpenCodeServerDriver", () => {
output: 2,
costUsd: 0.001,
provider: "openrouter",
driverVersion: "1.18.29",
driverVersion: "1.18.32",
});
await session.interrupt?.({ turnId: turn.turnId });
const snapshot = await session.snapshot();
@@ -73,7 +73,7 @@ import { nativeMcpLaunchBinding } from "../native-mcp.js";
import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js";
export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const;
export const QUALIFIED_OPENCODE_VERSION = "1.18.29" as const;
export const QUALIFIED_OPENCODE_VERSION = "1.18.32" as const;
export const QUALIFIED_OPENCODE_MODEL =
"openrouter/deepseek/deepseek-v4-flash-0731" as const;
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
canonicalOperations: 47,
canonicalOperations: 48,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
expect(report.coverage.operations).toHaveLength(47);
expect(report.coverage.operations).toHaveLength(48);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
"47 operations · 106 capability cases · 12 workflows",
"48 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
@@ -873,7 +873,7 @@ describe("Capability live runnerd and Codex session", () => {
expect(session.snapshot().config).toMatchObject({
provider: "opencode",
driver: "opencode_server",
providerVersion: "1.18.29",
providerVersion: "1.18.32",
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
});
await service.shutdown(session.id);
@@ -925,7 +925,7 @@ export class CapabilityLiveSessionService {
? "aws_agentcore_harness_api"
: input.provider === "acpx" ? "acpx_runtime" : "codex_app_server",
providerVersion: input.provider === "opencode"
? "1.18.29"
? "1.18.32"
: input.provider === "claude_managed"
? input.managedProfile!.agentVersion
: input.provider === "aws_agentcore"
@@ -4594,7 +4594,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
? "opencode_server"
: "codex_app_server",
providerVersion:
provider === "opencode" ? "1.18.29" : "codex-app-server-v1",
provider === "opencode" ? "1.18.32" : "codex-app-server-v1",
command:
provider === "opencode"
? providerNodeCommand
@@ -0,0 +1,63 @@
/** Canonical definition and documentation for `hire_agent`. */
export const hireAgentAction = {
id: "hire_agent",
canonical: {
operationId: "hire_agent",
surfaces: ["live"],
placement: "optional_agent_tool",
optionalGroup: "delegation_dependencies",
requiredClaims: ["delegation:agents:create"],
taskModes: ["standard", "skill_test"],
sideEffectClass: "company_write",
idempotency: "required",
disabledByDefault: false,
realBindingStatus: "live_codex",
realServiceBinding: "PaperclipRunnerToolAuthority",
prpEvidence: "Authenticated PRP tool input/result and agent-hire state diff with activity record.",
prpBindingStatus: "bound",
legacyAliases: [],
},
documentation: {
title: "Hire a native agent",
description: "Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here.",
note: "Use list_agents first when a suitable teammate may already exist.",
},
examples: {
call: {
operationId: "hire_agent",
input: { name: "Reviewer", role: "qa" },
},
success: {
ok: true,
operationId: "hire_agent",
result: {},
},
},
live: {
order: 15,
descriptor: {
schema: "paperclip.semantic-tool.v1",
operationId: "hire_agent",
version: 1,
title: "Hire a native agent",
description: "Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.",
exposure: "optional",
requiredClaims: ["delegation:agents:create"],
allowedModes: ["standard", "skill_test"],
inputSchema: {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 200 },
role: { type: "string", enum: ["ceo", "cto", "cmo", "cfo", "security", "engineer", "designer", "pm", "qa", "devops", "researcher", "general"] },
title: { type: ["string", "null"], maxLength: 300 },
capabilities: { type: ["string", "null"], maxLength: 2000 },
instructions: { type: ["string", "null"], maxLength: 20000 },
},
required: ["name"],
additionalProperties: false,
},
outputSchema: { type: "object", additionalProperties: true },
},
},
scenario: null,
} as const;
@@ -22,6 +22,7 @@ import { getTaskHistoryAction } from "./get-task-history.js";
import { getWorkspaceRuntimeAction } from "./get-workspace-runtime.js";
import { inspectOperationResultAction } from "./inspect-operation-result.js";
import { listAgentsAction } from "./list-agents.js";
import { hireAgentAction } from "./hire-agent.js";
import { listApprovalsAction } from "./list-approvals.js";
import { listCasesAction } from "./list-cases.js";
import { listCompanySkillsAction } from "./list-company-skills.js";
@@ -71,6 +72,7 @@ export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
getWorkspaceRuntimeAction,
inspectOperationResultAction,
listAgentsAction,
hireAgentAction,
listApprovalsAction,
listCasesAction,
listCompanySkillsAction,
@@ -1094,6 +1094,9 @@ export const providerEventSchema = {
"null"
]
},
"inputUpdated": {
"type": "boolean"
},
"status": {
"enum": [
"running",
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
@@ -685,6 +685,42 @@ describe("provider-neutral events", () => {
expect(unnamed.payload).toMatchObject({ name: null, operation: "unknown" });
});
it("records only ACP tool-input presence without exporting input values", () => {
const update = canonicalProviderEventsFromAcpxRuntimeEvent(
{
type: "tool_call",
tag: "tool_call_update",
toolCallId: "acpx-input-1",
title: "paperclip.search_tasks",
kind: "other",
status: "pending",
rawInput: { query: "private value" },
},
"fallback",
)[0]!;
const statusOnly = canonicalProviderEventsFromAcpxRuntimeEvent(
{
type: "tool_call",
tag: "tool_call_update",
toolCallId: "acpx-input-1",
title: "paperclip.search_tasks",
kind: "other",
status: "pending",
},
"fallback",
)[0]!;
expect(update.payload).toMatchObject({ inputUpdated: true });
expect(update.payload).not.toHaveProperty("rawInput");
expect(JSON.stringify(update.payload)).not.toContain("private value");
expect(statusOnly.payload).not.toHaveProperty("inputUpdated");
expect(
canonicalProviderEventsFromAcpxRuntimeEvent(
{ ...statusOnly.payload, type: "tool_call", inputUpdated: false } as never,
"fallback",
)[0]!.payload,
).toMatchObject({ inputUpdated: false });
});
it("presents OpenCode's server-qualified semantic tool with its canonical name", () => {
const event = canonicalProviderEventsFromOpenCodePart({
id: "finish-1",
@@ -15,6 +15,7 @@ export interface AcpRuntimeEventShape {
text?: string;
status?: string;
rawOutput?: unknown;
inputUpdated?: boolean;
tag?: string;
entries?: Array<{ content: string; status?: string }>;
[key: string]: unknown;
@@ -1150,6 +1151,11 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
target: safeAcpLocation(event.locations?.[0]),
namespace: mcp?.namespace ?? null,
readOnly: ["read", "search", "list"].includes(operation),
...(Object.prototype.hasOwnProperty.call(event, "rawInput")
? { inputUpdated: event.rawInput !== undefined }
: typeof event.inputUpdated === "boolean"
? { inputUpdated: event.inputUpdated }
: {}),
status,
durationMs: null,
exitCode: null,
@@ -28,6 +28,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
block_task: "active_task",
request_review: "active_task",
list_agents: "discovery",
hire_agent: "delegation",
get_agent: "discovery",
search_tasks: "discovery",
list_approvals: "governance",
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(35);
expect(names).toHaveLength(36);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@@ -57,10 +57,10 @@ const nativeSessionExecutor = await readFile(
);
test("the runner pins every qualified ACPX production dependency", () => {
assert.equal(runnerPackage.dependencies["@openai/codex"], "0.153.4");
assert.equal(runnerPackage.dependencies["@openai/codex"], "0.156.0");
assert.equal(runnerPackage.dependencies["@anthropic-ai/claude-agent-sdk"], undefined);
assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/codex-acp@1.6.2>@openai/codex"], runnerPackage.dependencies["@openai/codex"]);
assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk"], "0.3.263");
assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk"], "0.3.280");
assert.equal(runnerPackage.optionalDependencies, undefined);
assert.equal(runnerPackage.dependencies.node, undefined);
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
@@ -142,7 +142,7 @@ test("old and new pnpm configuration both apply the exact runtime patches", () =
providerPackBuilder,
/copyFileSync\(process\.execPath, stableNodeCommand\)/,
);
assert.match(codexPatch, /\+ "@openai\/codex": "0\.153\.4"/);
assert.match(codexPatch, /\+ "@openai\/codex": "0\.156\.0"/);
});
test("the ACPX patch preserves launch-only state and verified spawning", () => {
@@ -222,7 +222,7 @@ const server = createServer(async (request, response) => {
if (request.headers.authorization !== expectedAuth)
return json(response, 401, { error: "unauthorized" });
if (request.url === "/global/health")
return json(response, 200, { healthy: true, version: "1.18.29" });
return json(response, 200, { healthy: true, version: "1.18.32" });
if (request.url === "/event") {
eventConnections += 1;
response.writeHead(200, {
+1
View File
@@ -152,6 +152,7 @@ export type {
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
PluginOrganizationSwitcherProps,
PluginRouteSidebarProps,
PluginProjectSidebarItemProps,
PluginCommentAnnotationProps,
+22
View File
@@ -16,6 +16,7 @@
import type {
AnchorHTMLAttributes,
ReactNode,
MouseEvent as ReactMouseEvent,
} from "react";
import type {
@@ -270,6 +271,27 @@ export interface PluginDetailTabProps {
};
}
/** A single installed contribution replaces the organization menu. The host
* reserves the trigger while loading and uses its built-in menu when absent,
* ambiguous, or on render failure.
* These values/callbacks are presentation context, never authorization. */
export interface PluginOrganizationSwitcherProps {
organizationSwitcher: {
/** Host-local company branding, not an external account/organization label.
* Plugins resolve external organization identity at their owning service. */
currentCompany: { name: string; logoUrl: string | null } | null;
collapsed: boolean;
open: boolean;
onOpenChange(open: boolean): void;
/** Close mobile navigation before leaving the current organization. */
onNavigate(): void;
/** Host-owned logout preserves its session/cache invalidation behavior. */
onSignOut(): void;
signingOut: boolean;
renderIcon(name: string, logoUrl: string | null, inMenu: boolean): ReactNode;
};
}
/**
* Props passed to a plugin sidebar component.
*
+1
View File
@@ -1483,6 +1483,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"projectSidebarItem",
"globalToolbarButton",
"appShellOverlay",
"organizationSwitcher",
"toolbarButton",
"contextMenuItem",
"commentAnnotation",
+3
View File
@@ -133,6 +133,9 @@ export type BuiltInAgentReset = z.infer<typeof builtInAgentResetSchema>;
export const createAgentHireSchema = createAgentSchema.extend({
sourceIssueId: z.string().guid().optional().nullable(),
sourceIssueIds: z.array(z.string().guid()).optional(),
// Agent-authored hires may explicitly request the caller's native runner
// settings. The server consumes this intent; it is never an agent column.
inheritRuntimeFrom: z.literal("caller").optional(),
});
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
@@ -6,7 +6,7 @@ diff --git a/package.json b/package.json
"dependencies": {
"@agentclientprotocol/sdk": "^1.3.0",
- "@openai/codex": "^0.148.0",
+ "@openai/codex": "0.153.4",
+ "@openai/codex": "0.156.0",
"diff": "^9.0.0",
"open": "^11.0.0",
"vscode-jsonrpc": "^9.0.1",
+2 -2
View File
@@ -32,8 +32,8 @@ patchedDependencies:
# Agent CLIs share the current runtime used by the native provider pack.
# pnpm patches change package files, but overrides control dependency resolution.
overrides:
"@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.153.4"
"@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.263"
"@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.156.0"
"@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.280"
rollup: ">=4.59.0"
react: "^19.2.8"
react-dom: "^19.2.8"
+1 -1
View File
@@ -100,7 +100,7 @@ try {
if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
if (isOpenRouter) {
providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim();
if (providerVersion !== "1.18.29") throw new Error("OpenCode profile requires version 1.18.29");
if (providerVersion !== "1.18.32") throw new Error("OpenCode profile requires version 1.18.32");
}
const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => {
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
@@ -1,7 +1,7 @@
// Opt-in real Codex qualification; no model turn or live account data.
// Run from the repository root:
// node --import ./server/node_modules/tsx/dist/loader.mjs scripts/tests/native-cleanup-paginated-codex.mjs
// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.153.4 binary.
// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.156.0 binary.
// Fresh synthetic fixture directories are retained for inspection; never reuse live homes.
import { spawn, execFileSync } from "node:child_process";
import {
@@ -26,10 +26,10 @@ if (
execFileSync(codexBinary, ["--version"], {
encoding: "utf8",
timeout: 10000,
}).trim() !== "codex-cli 0.153.4"
}).trim() !== "codex-cli 0.156.0"
) {
throw new Error(
"This opt-in qualification requires Codex CLI 0.153.4. Requalify deliberately before changing the pin.",
"This opt-in qualification requires Codex CLI 0.156.0. Requalify deliberately before changing the pin.",
);
}
const home = await mkdtemp(join(tmpdir(), "paperclip-paginated-probe-"));
@@ -132,7 +132,7 @@ const lines =
timestamp,
cwd: home,
originator: "codex",
cli_version: "0.153.4",
cli_version: "0.156.0",
source: "cli",
model_provider: "openai",
selected_capability_roots: [],
@@ -88,7 +88,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
}).trim();
assert.equal(
version,
"codex-cli 0.153.4",
"codex-cli 0.156.0",
"qualified_codex_version_required",
);
const ledger = join(fixture, "provider-process-starts.txt");
@@ -309,7 +309,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
kind: "codex",
provider: "codex",
driver: "codex_app_server",
providerVersion: "0.153.4",
providerVersion: "0.156.0",
command: shim,
args: [
"--cd",
+42 -7
View File
@@ -71,6 +71,7 @@ describe("adapter model listing", () => {
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
expect(models.some((model) => model.id === "claude-opus-5")).toBe(true);
expect(models).toContainEqual({ id: "claude-opus-5-5", label: "Claude Opus 5.5" });
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -93,6 +94,7 @@ describe("adapter model listing", () => {
expect(first).toEqual(second);
expect(first.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true);
expect(first.some((model) => model.id === "claude-opus-4-8")).toBe(true);
expect(first.some((model) => model.id === "claude-opus-5-5")).toBe(true);
});
it("refreshes cached claude models on demand", async () => {
@@ -131,18 +133,21 @@ describe("adapter model listing", () => {
expect(models).toEqual(claudeFallbackModels);
});
it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
it.each([
["claude-fable-5-1", "Claude Fable 5.1"],
["claude-opus-5-5", "Claude Opus 5.5"],
])("does not duplicate %s when discovery returns the identical ID", async (id, displayName) => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
data: [{ id, display_name: displayName }],
}),
} as Response);
const models = await listAdapterModels("claude_local");
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
expect(models.filter((model) => model.id === id)).toEqual([{ id, label: displayName }]);
// Curated fallbacks discovery did not return are still merged in.
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
@@ -154,13 +159,42 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("claude_local");
// The Bedrock default (first entry) is unchanged.
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
// Keep Opus 4.8 first, using its documented dateless Bedrock ID.
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8");
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([
"us.anthropic.claude-opus-5-5", "us.anthropic.claude-opus-5", "us.anthropic.claude-sonnet-5",
"us.anthropic.claude-fable-5-1", "us.anthropic.claude-opus-4-7", "us.anthropic.claude-sonnet-4-6",
]));
expect(models.map((model) => model.id)).not.toEqual(expect.arrayContaining(["us.anthropic.claude-opus-4-8-v1"]));
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each([
["gemini_local", ["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-flash-lite", "gemini-3-flash-preview"]],
["grok_local", ["grok-build", "grok-4.7", "grok-4.6", "grok-4.5"]],
["kimi_local", ["kimi-code/kimi-for-coding", "kimi-code/k3", "kimi-code/k3-256k"]],
])("lists current %s models without a provider login", async (adapter, expectedIds) => {
const models = await listAdapterModels(adapter as string);
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(expectedIds as string[]));
expect(new Set(models.map((model) => model.id)).size).toBe(models.length);
if (adapter === "gemini_local") {
expect(models.some((model) => model.id.startsWith("gemini-2.0-"))).toBe(false);
}
if (adapter === "kimi_local") {
expect(models).toContainEqual({ id: "kimi-code/kimi-for-coding", label: "K2.8 Preview" });
}
});
it("includes current Cursor fallbacks when runtime discovery is unavailable", async () => {
setCursorModelsRunnerForTests(() => ({ status: 1, stdout: "", stderr: "", hasError: true }));
const models = await listAdapterModels("cursor");
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([
"composer-2.5", "claude-opus-5-5", "claude-fable-5-1", "claude-sonnet-5",
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "grok-4.7", "gemini-3.8-flash", "muse-spark-1.3",
]));
});
it("loads codex models dynamically and merges fallback options", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
@@ -232,12 +266,13 @@ describe("adapter model listing", () => {
expect(models).toEqual(cursorFallbackModels);
});
it("returns opencode fallback models including gpt-5.4", async () => {
it("returns current provider-qualified OpenCode models when discovery is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(["openai/gpt-6-astra", "openai/gpt-6-sol", "openai/gpt-6-luna", "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna", "anthropic/claude-opus-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5-1", "anthropic/claude-sonnet-5", "google/gemini-3.8-flash", "xai/grok-4.7"]));
});
it("loads cursor models dynamically and caches them", async () => {
@@ -339,8 +339,8 @@ describe("server adapter registry", () => {
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.153.4")}; fi`;
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.29")}; fi`;
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.156.0")}; fi`;
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.32")}; fi`;
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "claude",
@@ -175,6 +175,46 @@ describe("agent-created hires use managed AI connections", () => {
try { expect(runtime.attribution.connectionId).toBe(f.account.connectionId); } finally { await runtime.cleanup(); }
});
it.each([
["codex", { provider: "codex", model: "gpt-5.6-sol", codexPermissionMode: "never", lifecycleMode: "per_turn" }],
["claude", { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" }],
] as const)("caller runtime inheritance preserves safe %s settings only", async (_name, parentConfig) => {
const f = await fixture("anthropic", "subscription");
await db.update(agents).set({
adapterType: "paperclip_runner",
adapterConfig: {
...parentConfig,
cwd: "/private/parent-workspace",
env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "parent-secret" } },
instructionsFilePath: "/private/parent-instructions.md",
runtimeSessionId: "parent-session",
},
}).where(eq(agents.id, f.agentId));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
name: "Inherited teammate", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
}));
expect(agent.adapterConfig).toMatchObject(parentConfig);
expect(agent.adapterConfig).not.toHaveProperty("cwd");
expect(agent.adapterConfig).not.toHaveProperty("env");
expect(agent.adapterConfig.instructionsFilePath).not.toBe("/private/parent-instructions.md");
expect(agent.adapterConfig).not.toHaveProperty("runtimeSessionId");
expect(agent.runtimeConfig.aiConnection).toMatchObject({
provider: parentConfig.provider === "codex" ? "openai" : "anthropic",
mode: "responsible_user",
});
});
it("rejects caller inheritance when the request supplies competing runtime settings", async () => {
const f = await fixture("anthropic", "subscription");
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
name: "Conflicting teammate", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
adapterConfig: { model: "caller.override" },
});
expect(response.status).toBe(422);
expect(response.body.error).toContain("cannot be combined");
});
it.each([true, false])("preserves shared connection access boundaries (company access: %s)", async (allAgents) => {
const f = await fixture("anthropic");
const account = await aiConnectionService(db).save(f.companyId, f.userId, { provider: "anthropic", method: "api_key", name: "Shared Claude", ownership: "shared", apiKey: "fixture", agentIds: [f.agentId], allAgents }, "fixture");
@@ -16,6 +16,8 @@ import {
companySecretVersions,
companySecrets,
createDb,
environments,
approvals,
principalPermissionGrants,
userSecretDeclarations,
userSecretDefinitions,
@@ -78,6 +80,7 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
}, 60_000);
afterEach(async () => {
await db.delete(approvals);
await db.delete(activityLog);
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
@@ -88,6 +91,7 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
});
@@ -458,6 +462,88 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
expect(Object.keys(childEnvOf(res))).toHaveLength(0);
});
it("rejects native runtime inheritance for a board actor", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
const res = await hire(userActor(), companyId, {
name: "Board Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(403);
expect(parent.adapterType).toBe("paperclip_runner");
});
it("rejects inheritance from a legacy caller", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "codex_local", {});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Legacy Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(422);
expect(res.body.error).toContain("paperclip_runner adapter");
});
it("rejects native runtime inheritance across companies", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const parent = await seedParentAgent(parentCompanyId, "paperclip_runner", { provider: "codex", model: "gpt-5.6-sol" });
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Cross Company Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(403);
expect(await db.select().from(agents).where(eq(agents.companyId, targetCompanyId))).toHaveLength(0);
});
it("records only inherited safe settings in an approval snapshot", async () => {
const companyId = await seedCompany();
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
await db.update(agents).set({ adapterConfig: {
provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all",
env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent",
invocationLimits: { maxIterations: 3, credentials: "never-copy" },
} }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(201);
expect(res.body.agent.status).toBe("pending_approval");
expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined();
expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" });
expect(res.body.approval.payload.adapterConfig.env).toBeUndefined();
expect(res.body.approval.payload.adapterConfig.cwd).toBeUndefined();
expect(res.body.approval.payload.adapterConfig.invocationLimits).toEqual({ maxIterations: 3 });
});
it.each([
["claude_managed", "managedProfileId", "managedAgentsRetentionAcknowledged", "Managed Agent"],
["aws_agentcore", "agentCoreProfileId", "agentCoreRetentionAcknowledged", "Remote Agent"],
])("revalidates the inherited %s company profile before creating a hire", async (provider, profileKey, retentionKey, label) => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
await db.update(agents).set({ adapterConfig: {
provider, [profileKey]: randomUUID(), [retentionKey]: true,
} }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Missing Profile Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe(`${label} profile not found`);
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
});
it("validates and preserves an inherited caller default environment", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
const environmentId = randomUUID();
await db.insert(environments).values({ id: environmentId, name: `Local ${environmentId}`, driver: "local" });
await db.update(agents).set({ defaultEnvironmentId: environmentId }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Invalid Environment Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.agent.defaultEnvironmentId).toBe(environmentId);
});
it("inherits the fixed Claude OAuth binding, keeps its version, and leaks no token value", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "claude_local", {
@@ -211,6 +211,12 @@ describe("cloudControlMiddleware", () => {
app.all("/api/instance/task-drain", (req, res) => {
res.json({ actor: req.actor });
});
app.all("/api/instance/lifecycle", (req, res) => {
res.json({ actor: req.actor });
});
app.all("/api/instance/lifecycle/unarchive-primary", (req, res) => {
res.json({ actor: req.actor });
});
app.get("/api/instance/settings", (req, res) => {
res.json({ actor: req.actor });
});
@@ -263,6 +269,38 @@ describe("cloudControlMiddleware", () => {
expect(res.body.error).toBe("invalid_cloud_control_assertion");
});
it("authorizes the lifecycle endpoints, each bound to its own action", async () => {
const app = createApp();
const read = await request(app)
.get("/api/instance/lifecycle")
.set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read"));
expect(read.status).toBe(200);
expect(read.body.actor).toMatchObject({ type: "board", isInstanceAdmin: true, source: "cloud_control" });
const unarchive = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:unarchive-primary"));
expect(unarchive.status).toBe(200);
expect(unarchive.body.actor).toMatchObject({ source: "cloud_control" });
// Cross-binding: a task-drain assertion opens no lifecycle door, a
// read assertion cannot unarchive, and lifecycle endpoints accept no
// extra methods.
const crossAction = await request(app)
.get("/api/instance/lifecycle")
.set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read"));
expect(crossAction.status).toBe(401);
const readAsWrite = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read"));
expect(readAsWrite.status).toBe(401);
const wrongMethod = await request(app)
.post("/api/instance/lifecycle")
.set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read"));
expect(wrongMethod.status).toBe(400);
expect(wrongMethod.body.error).toBe("cloud_control_wrong_endpoint");
});
it("accepts the conventional trailing-slash form of the endpoint", async () => {
const app = createApp();
const res = await request(app)
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import {
isCloudPinnedPrimaryCompany,
notifyCloudOfPrimaryCompanyLifecycleChange,
} from "../services/cloud-lifecycle-sync.js";
import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js";
const STACK_ID = "stack-lifecycle-sync";
const PRIMARY_ID = cloudTenantPrimaryCompanyId(STACK_ID);
const CLOUD_ENV = {
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-token-test",
PAPERCLIP_CLOUD_STACK_ID: STACK_ID,
PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.test",
} as NodeJS.ProcessEnv;
describe("isCloudPinnedPrimaryCompany", () => {
it("matches only the derived primary company of a cloud-managed instance", () => {
expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, CLOUD_ENV)).toBe(true);
expect(isCloudPinnedPrimaryCompany("some-other-company", CLOUD_ENV)).toBe(false);
// Self-hosted: no cloud signal, never primary.
expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, {} as NodeJS.ProcessEnv)).toBe(false);
});
});
describe("notifyCloudOfPrimaryCompanyLifecycleChange", () => {
it("rings the harness doorbell with the tenant token and stack id", async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ outcome: "archived" }), { status: 200 }));
await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, {
env: CLOUD_ENV,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit];
expect(url).toBe("https://cloud.example.test/v1/tenant/lifecycle-changed");
expect(init.method).toBe("POST");
const headers = new Headers(init.headers);
expect(headers.get("authorization")).toBe("Bearer tenant-token-test");
expect(headers.get("x-paperclip-cloud-stack-id")).toBe(STACK_ID);
});
it("is a silent no-op for non-primary companies and incomplete cloud metadata", async () => {
const fetchImpl = vi.fn();
await notifyCloudOfPrimaryCompanyLifecycleChange("some-other-company", {
env: CLOUD_ENV,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, {
env: { ...CLOUD_ENV, PAPERCLIP_CLOUD_API_ORIGIN: undefined } as NodeJS.ProcessEnv,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, {
env: {} as NodeJS.ProcessEnv,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(fetchImpl).not.toHaveBeenCalled();
});
it("retries transient failures a bounded number of times and never throws", async () => {
const fetchImpl = vi.fn(async () => {
throw new Error("connection refused");
});
const sleeps: number[] = [];
await expect(
notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, {
env: CLOUD_ENV,
fetchImpl: fetchImpl as unknown as typeof fetch,
sleep: async (ms) => {
sleeps.push(ms);
},
}),
).resolves.toBeUndefined();
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(sleeps).toEqual([2_000, 10_000]);
});
it("does not retry once the harness answered, even non-2xx", async () => {
// Any answer means the harness heard the ring; it does its own
// verified read-back, so a 4xx outcome is final.
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: "stack_not_found" }), { status: 404 }));
await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, {
env: CLOUD_ENV,
fetchImpl: fetchImpl as unknown as typeof fetch,
sleep: async () => {
throw new Error("must not sleep");
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});
+42 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import {
activityLog,
@@ -24,6 +24,15 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
// Observe the Cloud lifecycle doorbell without any network: the real
// implementation is env-gated (a no-op off Cloud), so these tests assert
// WHEN the service rings it, not what the ring does.
const notifyCloudSpy = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../services/cloud-lifecycle-sync.js", () => ({
isCloudPinnedPrimaryCompany: () => false,
notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy,
}));
import { companyService } from "../services/companies.js";
import { deriveIssuePrefixBase } from "../services/issue-prefix.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
@@ -809,6 +818,38 @@ describeEmbeddedPostgres("companyService", () => {
expect(archiveActivity[0]).toMatchObject({ details: { agentsPaused: 1, runsCancelled: 0 } });
});
it("rings the Cloud lifecycle doorbell exactly on archived-boundary transitions", async () => {
notifyCloudSpy.mockClear();
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Doorbell Test Co",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const svc = companyService(db);
const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null };
// A plain edit never rings.
await svc.update(companyId, { name: "Doorbell Test Co (renamed)" }, actor);
expect(notifyCloudSpy).not.toHaveBeenCalled();
// archive() rings once; re-archiving (no transition) does not.
await svc.archive(companyId, actor);
expect(notifyCloudSpy).toHaveBeenCalledTimes(1);
expect(notifyCloudSpy).toHaveBeenLastCalledWith(companyId);
await svc.archive(companyId, actor);
expect(notifyCloudSpy).toHaveBeenCalledTimes(1);
// Unarchiving through update() rings again.
await svc.update(companyId, { status: "active" }, actor);
expect(notifyCloudSpy).toHaveBeenCalledTimes(2);
// update() into archived rings too (the status-patch archive path).
await svc.update(companyId, { status: "archived" }, actor);
expect(notifyCloudSpy).toHaveBeenCalledTimes(3);
});
it("runs the archive cascade when update() transitions a paused company to archived", async () => {
const companyId = randomUUID();
const idleAgentId = randomUUID();
@@ -23,11 +23,16 @@ const mockEnvironmentService = vi.hoisted(() => ({
findManagedSandboxEnvironment: vi.fn(),
update: vi.fn(),
}));
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockPublishActivity = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
companyService: () => mockCompanyService,
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
@@ -52,8 +57,12 @@ function defaultTransactionImplementation(fn: (tx: unknown) => Promise<unknown>)
// Module-scoped (not rebuilt per createApp call) so a test can assert how
// many times a request opened a transaction — the task-drain audit writes
// for every company must share ONE transaction, not one each.
// Rows the lifecycle read-back's plain `db.select().from(companies)` sees;
// tests assign per case.
let mockCompanyRows: Array<{ id: string; status: string }> = [];
const mockDb = {
transaction: vi.fn(defaultTransactionImplementation),
select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })),
};
describe("instance settings routes", () => {
@@ -1289,4 +1298,157 @@ describe("instance settings routes", () => {
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
});
});
describe("cloud lifecycle read-back and unarchive", () => {
const STACK_ID = "stack-lifecycle-routes";
const adminActor = {
type: "board",
userId: "paperclip-cloud",
source: "cloud_control",
isInstanceAdmin: true,
companyIds: [],
};
const memberActor = {
type: "board",
userId: "member-1",
source: "cloud_tenant",
isInstanceAdmin: false,
companyIds: ["company-1"],
};
// The pinned primary company id the routes derive from the stack id.
let primaryId: string;
beforeEach(async () => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token";
process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID;
const { cloudTenantPrimaryCompanyId } = await vi.importActual<
typeof import("../services/cloud-instance.js")
>("../services/cloud-instance.js");
primaryId = cloudTenantPrimaryCompanyId(STACK_ID);
mockCompanyRows = [];
});
afterEach(() => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
delete process.env.PAPERCLIP_CLOUD_STACK_ID;
});
it("reports the primary company status and how many other companies are not archived", async () => {
mockCompanyRows = [
{ id: primaryId, status: "archived" },
{ id: "company-sibling-live", status: "active" },
{ id: "company-sibling-paused", status: "paused" },
{ id: "company-sibling-archived", status: "archived" },
];
const app = await createApp(adminActor);
const res = await request(app).get("/api/instance/lifecycle");
expect(res.status).toBe(200);
expect(res.body).toEqual({
primaryCompanyId: primaryId,
primaryCompanyStatus: "archived",
// paused counts: any non-archived sibling keeps the stack alive.
otherUnarchivedCompanyCount: 2,
});
});
it("reports a missing primary company without failing", async () => {
mockCompanyRows = [{ id: "company-other", status: "active" }];
const app = await createApp(adminActor);
const res = await request(app).get("/api/instance/lifecycle");
expect(res.status).toBe(200);
expect(res.body.primaryCompanyStatus).toBe("missing");
});
it("hides the cross-company lifecycle summary from non-admin board members", async () => {
mockCompanyRows = [{ id: primaryId, status: "archived" }];
const app = await createApp(memberActor);
const res = await request(app).get("/api/instance/lifecycle");
expect(res.status).toBe(403);
});
it("answers 404 when the instance is not cloud-managed", async () => {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
delete process.env.PAPERCLIP_CLOUD_STACK_ID;
const readRes = await request(await createApp(adminActor)).get("/api/instance/lifecycle");
expect(readRes.status).toBe(404);
// The admin gate runs first, so prove the 404 with an admin actor.
const unarchiveRes = await request(await createApp(adminActor))
.post("/api/instance/lifecycle/unarchive-primary")
.send({});
expect(unarchiveRes.status).toBe(404);
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("unarchives an archived primary company as a system actor", async () => {
mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" });
mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" }));
const app = await createApp(adminActor);
const res = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "active", changed: true });
expect(mockCompanyService.update).toHaveBeenCalledWith(
primaryId,
{ status: "active" },
{ actorType: "system", actorId: "paperclip-cloud", agentId: null, runId: null },
);
});
it("attributes a human admin's unarchive to that admin, not to Cloud", async () => {
mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" });
mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" }));
const humanAdmin = {
type: "board",
userId: "human-admin",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
};
const app = await createApp(humanAdmin);
const res = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.send({});
expect(res.status).toBe(200);
expect(mockCompanyService.update).toHaveBeenCalledWith(
primaryId,
{ status: "active" },
expect.objectContaining({ actorType: "user", actorId: "human-admin" }),
);
});
it("is idempotent: an unarchived primary company reports changed:false", async () => {
mockCompanyService.getById.mockResolvedValue({ id: "", status: "active" });
const app = await createApp(adminActor);
const res = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "active", changed: false });
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("requires instance admin rights to unarchive", async () => {
const app = await createApp(memberActor);
const res = await request(app)
.post("/api/instance/lifecycle/unarchive-primary")
.send({});
expect(res.status).toBe(403);
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});
});
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more