Reconcile qualified Pi stack with current master and source attestation

This commit is contained in:
Dotta committed 2026-10-08 14:54:15 -05:00
commit 8dbfea7998
728 files changed
+117813 -64165

No files matched your search

+4
View File
@@ -43,6 +43,10 @@ action on the left and the primary action on the right. A step owns its whole
footer: do not render Save & exit in a separate parent block below it. Check this
alignment in every step and conditional state, not just the first screen.
Completed Slack setup is a success view: show the full agent avatar, success message,
and one centered Done action. Omit Save & exit after the connection has been
confirmed. The shared footer rule applies to its unfinished setup steps.
## Mobile navigation and text fields
The fixed bottom navigation uses an opaque surface so scrolling content cannot
+5 -1
View File
@@ -14,8 +14,12 @@
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://www.star-history.com/paperclipai/paperclip"><img src="https://api.star-history.com/badge?repo=paperclipai/paperclip" alt="Star History Rank" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join-7289da" alt="Discord" /></a>
<a href="https://superagent.sh"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.superagent.sh/api/badge/paperclipai/paperclip/shield.svg?theme=dark&accent=teal&variant=scanned" /><img src="https://www.superagent.sh/api/badge/paperclipai/paperclip/shield.svg?theme=light&accent=teal&variant=scanned" alt="Security posture verified by Superagent" /></picture></a>
</p>
<p align="center">
<a href="https://www.star-history.com/paperclipai/paperclip"><img src="https://api.star-history.com/badge?repo=paperclipai/paperclip" alt="Star History Rank" /></a>
</p>
<br/>
+30
View File
@@ -1472,6 +1472,36 @@ describe("worktree helpers", () => {
}
});
it("creates an explicitly empty worktree without inherited signing secrets or deferred copying", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-empty-"));
const originalCwd = process.cwd();
const originalJwt = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const originalSigning = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
try {
const repoRoot = path.join(tempRoot, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
process.chdir(repoRoot);
process.env.PAPERCLIP_AGENT_JWT_SECRET = "source-jwt-secret";
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "source-signing-secret";
await worktreeInitCommand({ empty: true, fromConfig: path.join(tempRoot, "missing.json"), home: path.join(tempRoot, "instances") });
const env = fs.readFileSync(path.join(repoRoot, ".paperclip/.env"), "utf8");
expect(env).not.toContain("source-jwt-secret");
expect(env).not.toContain("source-signing-secret");
expect(env).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-manifest.json"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-pending"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-empty"))).toBe(true);
await worktreeInitCommand({ seed: false, force: true, fromConfig: path.join(tempRoot, "missing.json"), home: path.join(tempRoot, "instances") });
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-empty"))).toBe(false);
expect(readWorktreeSeedManifest(path.join(repoRoot, ".paperclip/config.json"))?.state).toBe("pending");
} finally {
process.chdir(originalCwd);
if (originalJwt === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwt;
if (originalSigning === undefined) delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; else process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalSigning;
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
+3
View File
@@ -8,6 +8,7 @@ export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export const WORKTREE_SEED_MANIFEST = "seed-manifest.json";
export const WORKTREE_SEED_PENDING_MARKER = "seed-pending";
export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete";
export const WORKTREE_SEED_EMPTY_MARKER = "seed-empty";
export const WORKTREE_SEED_LOCK_MARKER = "seed.lock";
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
@@ -98,6 +99,7 @@ export type WorktreeSeedMarkerPaths = {
manifest: string;
pending: string;
complete: string;
empty: string;
lock: string;
};
@@ -107,6 +109,7 @@ export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeed
manifest: path.resolve(configDir, WORKTREE_SEED_MANIFEST),
pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER),
complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER),
empty: path.resolve(configDir, WORKTREE_SEED_EMPTY_MARKER),
lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER),
};
}
+35 -11
View File
@@ -16,7 +16,7 @@ import {
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { Readable } from "node:stream";
import * as p from "@clack/prompts";
@@ -118,6 +118,7 @@ type WorktreeInitOptions = {
serverPort?: number;
dbPort?: number;
seed?: boolean;
empty?: boolean;
seedMode?: string;
preserveLiveWork?: boolean;
force?: boolean;
@@ -245,6 +246,7 @@ export type EnsureWorktreeSeededResult = {
| "seeded"
| "verified_manifest"
| "complete_marker"
| "explicitly_empty"
| "legacy_unmarked"
| "legacy_database";
details?: SeedWorktreeDatabaseResult;
@@ -2221,6 +2223,9 @@ export async function ensureWorktreeSeeded(
): Promise<EnsureWorktreeSeededResult> {
const configPath = resolveConfigPath(opts.config);
const markers = resolveWorktreeSeedMarkerPaths(configPath);
if (existsSync(markers.empty)) {
return { seeded: false, reason: "explicitly_empty" };
}
const initialManifest = readWorktreeSeedManifest(configPath);
if (initialManifest?.state === "verified") {
return { seeded: false, reason: "verified_manifest" };
@@ -2282,6 +2287,9 @@ export async function ensureWorktreeSeeded(
mkdirSync(path.dirname(markers.lock), { recursive: true });
const releaseLock = await acquireWorktreeSeedLock(markers.lock);
try {
if (existsSync(markers.empty)) {
return { seeded: false, reason: "explicitly_empty" };
}
// These checks deliberately happen under the cross-process lock. A second
// service process waits for the first seed transaction, then observes the
// verified manifest instead of cloning the same database concurrently.
@@ -2434,8 +2442,10 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
rmSync(paths.configPath, { force: true });
rmSync(paths.envPath, { force: true });
const seedMarkers = resolveWorktreeSeedMarkerPaths(paths.configPath);
rmSync(seedMarkers.manifest, { force: true });
rmSync(seedMarkers.pending, { force: true });
rmSync(seedMarkers.complete, { force: true });
rmSync(seedMarkers.empty, { force: true });
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
@@ -2464,6 +2474,11 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
});
try {
// Persist the no-copy choice before the config becomes visible to managed startup.
if (opts.empty) {
mkdirSync(paths.repoConfigDir, { recursive: true });
writeFileSync(resolveWorktreeSeedMarkerPaths(paths.configPath).empty, "Explicitly empty instance; automatic database copying is disabled.\n", { mode: 0o600 });
}
writeConfig(selectedConfig, paths.configPath);
writeWorktreePortRegistry(paths.homeDir, [
...registeredConfigPaths,
@@ -2471,6 +2486,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
]);
} catch (error) {
rmSync(paths.configPath, { force: true });
if (opts.empty) rmSync(resolveWorktreeSeedMarkerPaths(paths.configPath).empty, { force: true });
throw error;
}
@@ -2481,18 +2497,20 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
};
},
);
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
if (!opts.empty) {
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
}
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
const existingAgentJwtSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
const existingToolActionSigningSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET);
mergePaperclipEnvEntries(
{
@@ -2511,7 +2529,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
let pausedScheduledRoutineCount: number | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!opts.empty && opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
@@ -2601,6 +2619,8 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
spinner.stop("Seeded isolated worktree database (minimal).");
} else if (result.reason === "legacy_database") {
spinner.stop("Validated and adopted an existing legacy worktree database.");
} else if (result.reason === "explicitly_empty") {
spinner.stop("Explicitly empty instance; automatic database copying is disabled.");
} else {
spinner.stop("Worktree database already has a verified seed manifest.");
}
@@ -2618,7 +2638,7 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
);
}
}
p.outro(pc.green("Worktree database seed complete."));
p.outro(pc.green(result.reason === "explicitly_empty" ? "Empty worktree ready." : "Worktree database seed complete."));
} catch (error) {
spinner.stop(pc.red("Failed to seed worktree database."));
throw error;
@@ -4320,6 +4340,8 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
expectedCompanyId: nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID) ?? undefined,
seedDatabase: seedWorktreeDatabase,
});
// An operator-confirmed successful reseed replaces the earlier empty-instance choice.
rmSync(markers.empty, { force: true });
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
p.log.message(pc.dim(`Source: ${source.configPath}`));
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
@@ -4456,6 +4478,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -4473,6 +4496,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
+53 -3
View File
@@ -226,15 +226,15 @@ When authoring migrations or one-time backfills:
- Split schema changes, index creation, and data backfill into separate phases so each step has clear locking and rollback behavior.
- Treat the `check:migrations` CI gate as the enforcement backstop for these rules. If it flags a migration, rewrite the migration or add a suppression comment with the indexed predicate, batch bound, and reason the remaining scan is safe.
Private-task migration `0313` is explicitly allowlisted in the
Paperclip executor to run outside a file-wide transaction. Its idempotent
Private-task migrations `0313` and `0314` are explicitly allowlisted in the
Paperclip executor to run outside a file-wide transaction. Their idempotent
keyset batches commit every 1,000 rows, and migration history is recorded only
when all batches finish. The executor repairs invalid concurrent indexes on
retry. A reserved connection holds a session advisory lock across all batch
commits, so concurrent migrators recheck history only after the preceding runner
finishes. Privacy triggers are replaced atomically. Bootstrap uses the same
executor; other migrations remain transactional
per file. Apply this migration through `pnpm db:migrate` using a direct
per file. Apply these migrations through `pnpm db:migrate` using a direct
connection, before enabling the new server and UI.
## Migration snapshots
@@ -631,3 +631,53 @@ reads and server startup do not provision them. Normal backups preserve identity
rows and need the matching secrets master key for recovery. Both development seed
modes omit identity rows, including with live-work preservation, so copied agents
get fresh identities. See [Agent cryptographic identity](AGENT-IDENTITY.md).
### Slack app registration
`chat_slack_registrations` stores one company-scoped app registration per chat
endpoint. A composite foreign key binds `(company_id, endpoint_id)` to the
endpoint's company. It contains the creation request ID, immutable manifest
snapshot/hash, OAuth callback URI, app/client IDs, vault references, installation
identity, status, safe failure code, creator, and timestamps. It contains no
plaintext configuration token, OAuth code, signing/client secret, or bot token.
Creation records `creating` before dispatch. An interrupted attempt becomes
`uncertain`; a new request needs explicit confirmation that no app exists.
`install` means the app exists. `credentials_saved` means the OAuth bot token is
vaulted and connection checks can resume. `configured` means runtime credentials
are durably bound; staged duplicates are cleaned and the client secret remains
available for reauthorization. `removed` invalidates registration and keeps the
safe app management link for provider-side cleanup.
`chat_endpoints.setup.slackAvatar` records optional avatar provisioning as
`pending`, `uploaded` with its confirmation timestamp, or `failed` with a fixed
safe error code. It survives reloads and restarts. App credentials are durably
bound before icon upload; an interrupted upload never triggers another app
creation. This JSON state stores no token, image URL, or provider error payload.
`chat_endpoints.setup.slackAccount` stores the OAuth installer’s Slack ID, the
initiating Paperclip user ID, pending/linked status, durable welcome-DM and
optional verification-DM status, and the returned DM channel ID. It stores no
user OAuth token or provider payload.
The account uses the existing company-scoped `chat_identity_links` table; it
preserves conflicting/revoked links and does not change on reauthorization.
Both message dispatches move pending → sending before network I/O, then sent/failed;
a restart or ambiguous response moves sending → uncertain without replay.
Signed Request URL verification can precede installation. Internal setup state
retains a signing-secret fingerprint and observed URL so configuration preserves
that evidence only for the same secret and callback. No plaintext secret is stored
in setup state, and the fingerprint is excluded from endpoint responses.
For automatically registered apps, `webhookVerifiedAt` also records successful
delivery of an authenticated message/app-mention event to the current callback
URL. The current signing secret, saved app/workspace/bot binding, active connection,
and runtime generation are checked before recording it. This is Paperclip's
connection evidence, not Slack's settings-page URL-verification flag. Activity
identifies this evidence as `authenticated_event`; no message body is recorded.
Slack install attempts use `tool_oauth_states` with the `slack-install.` namespace.
They expire after ten minutes, bind the company/connection/endpoint, registration
request ID, app ID, initiating actor/session, callback URI, and requested scopes,
and are atomically deleted before code exchange. The `code_verifier` column holds
this non-secret binding for this namespace; Slack bot installation does not use
PKCE. Removal and manual recovery invalidate outstanding attempts under the same
credential-mutation lease used by configuration.
+148 -30
View File
@@ -92,6 +92,12 @@ The vite dev server serves an unbundled module graph. This is fast to reload on
The preview server binds `0.0.0.0` and accepts any Host, so a tailnet or LAN address (e.g. `http://<host>.ts.net:3101/`) works out of the box. The `/api` proxy sets `x-forwarded-host` and `x-forwarded-proto`, which the server's board mutation guard uses to trust the browser's Origin — mutations from `:3101` succeed against the API on `:3100` without further configuration. An HTTPS tunnel in front of the preview server (ngrok, tailscale funnel) is also supported: the tunnel's `x-forwarded-proto` header is preserved when set.
Static UI mode compresses built JavaScript, CSS, and HTML with Brotli or gzip
when the browser supports it. Hashed assets retain their immutable cache policy;
byte-range responses retain their original representation. This reduces initial
page downloads through bandwidth-limited HTTPS tunnels such as Tailscale Funnel.
API responses and MCP transports keep their existing compression behavior.
## Storybook
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
@@ -103,6 +109,11 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
reports, security probes, workspace previews, and opening a report from a task.
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
supported content, and browser verification command.
**Composer → New task** includes agent/user/project/task mentions, skill and
routine slash commands, and populated rich chips on desktop and mobile. Agent
mentions use the same avatars in suggestions, inserted chips, and the mocked
@@ -163,6 +174,16 @@ checkout, including a non-primary checkout. Changing projects clears the
previous worktree choice. Switching from reuse to a new worktree or the project
workspace restores the project default checkout. Task creation uses the selected company and has
no separate heading or settings control.
Task execution-policy controls apply the shared schema defaults when stages or
participants are omitted. An invalid policy shows an unavailable notice and
disables policy edits while leaving other task properties usable. Refresh to
retry. Optional browser error monitoring reports only a fixed field category,
once per mounted control; it never includes the policy or task identifiers.
Edits in the reviewer and monitor controls retain an explicitly configured
review-round limit when the last reviewer or monitor is removed, so adding a
reviewer later uses the saved limit.
Fresh tasks start with an empty request and the last task assignee chosen in that
company, including a human. If that assignee is unavailable, the CEO is the
default, or the first eligible agent when the company has no CEO. Explicit launch
@@ -617,25 +638,34 @@ case.
### Slack chat setup in a test drive
Enable **Chat connectors** in Instance Settings, then open **Connectors → Slack →
Chat with an agent**. Before connecting, configure a public HTTPS URL that Slack
can reach. The setup page shows this requirement above the app details.
Slack app name, bot display name, and slash command are editable while the
connection is a draft; valid edits save when a field loses focus. **Create Slack
app** opens Slack with the generated manifest prefilled. **View Slack App Manifest**
opens the read-only manifest in a modal to inspect or copy it. Once connected,
the app details are locked so reconnecting cannot silently change the registered
command. Slack still requires workspace selection, installation approval, and
copying the bot token and signing secret back into Paperclip.
Chat with an agent**. Before creating an app, configure a public HTTPS board URL
and webhook ingress that Slack can reach. The setup page shows this prerequisite;
a loopback test drive can exercise the UI before HTTPS is configured.
After Slack verifies its Events Request URL, the wizard asks you to send
`/<your-command> connect`. This command works before a sender or channel is
allowed to start work. It records the Slack identity and sends a private,
one-time confirmation link that expires after 15 minutes; it creates no task
and grants no access. You can confirm **This is my Slack account** in the wizard,
or follow the private link and sign into Paperclip. Both paths check company
membership before linking, and future messages use the linked user's current
permissions. The wizard only lists identities that sent the connect command to
this endpoint during the current test.
New Slack connections use five steps: choose an agent, enter an app configuration
token, install the app, verify Slack delivery, and try a conversation. Agent
selection generates readable defaults for the Slack app name, bot display name,
and slash command; edit them under **Advanced** if needed. **Get your App
configuration token** opens Slack app settings. Enter the temporary token directly
into Paperclip, then select **Create Slack app**. Paperclip creates the app,
vaults its credentials, and attempts avatar upload before discarding the token.
App details lock after creation dispatch, including an uncertain result.
**Install in Slack** obtains the bot token through OAuth, links the installing
Slack account to the Paperclip account that started installation, and sends one
welcome DM. Approve with your own Slack account. After Slack verifies the Events
Request URL, use **Open your Slack DM**, send the suggested message, and continue
in the thread. **Done** finishes setup; the conversation test is optional.
See [Automatic Slack app setup](connections/SLACK-AUTOMATIC-SETUP.md) for
HTTPS configuration and recovery.
**Create manually** and **Use an existing app** are under **Advanced** on the
token step. These paths retain manual credential entry and a separate personal
account-linking step. Send `/<your-command> connect` to discover your identity
without starting work, then confirm **This is my Slack account** in the wizard
or use the private confirmation link. The link expires after 15 minutes and
requires company membership. Future messages use the linked user's current
permissions. Additional or different accounts can be linked later in **Access**.
New Slack connections disable **Allow unlinked people** by default. The Access
page includes the shareable connect command and instructions for other users.
@@ -653,14 +683,11 @@ company. Preview, access-request, and confirmation APIs also enforce the chat
connector rollout flag on the server; invitees cannot read board experimental
settings before they join. Expired or consumed tokens grant no access.
The final wizard step suggests `@<your-bot> you there?`, then continuing in
the agent's thread. Select the bot from Slack's @mention suggestions so the
message includes a real mention. It detects a message or task command from the current user's
linked Slack identity during this setup session and shows a checkmark. This
conversation test is optional: **I've sent the test message** and **Skip test and
finish** both finish setup once webhook verification and account linking are
complete. The separate strict connection-test API retains its conversation and
delivery checks.
The manual path suggests `@<your-bot> you there?`, then continuing in the agent's
thread. Select the bot from Slack's @mention suggestions so the message includes
a real mention. **Done** finishes setup once webhook verification and required
account linking are complete, whether or not a test message was sent. The
separate strict connection-test API retains its conversation and delivery checks.
### Chat activity pagination and callback diagnostics
@@ -749,6 +776,36 @@ PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
No Docker or external database is required for this mode.
## Issue Privacy Rollout
`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the canonical opt-in issue/project privacy predicate:
- `enforce` (default): return private issues only to implicit principals, issue grantees, or private-project access members; hide private projects from non-members
- `shadow`: log structured would-deny decisions but preserve existing reads; use only for rollout diagnosis
- `off`: skip the predicate and shadow logging
Grant checks read current database state; there is no positive privacy cache.
Sharing a task grants access to that task and its private descendants. Parents
and siblings need separate grants. An agent's access is intersected with its
run's responsible user's current access.
`enforce` is the production default. `shadow` and `off` disclose private data and
are only temporary diagnostic overrides. The old branch's historical sign-off
does not qualify the current runtime. See [ISSUE-PRIVACY.md](./ISSUE-PRIVACY.md)
for the model, validation coverage, break-glass behavior, and residual risks.
Issue-bound heartbeat runs inherit the same read predicate through the stored
`heartbeat_runs.scope_kind = 'issue'` and `issue_id` binding. Issue deletion
nulls the foreign key but preserves the explicit issue scope as a fail-closed
tombstone. Workspace-operation history is either deleted with its direct issue
or run binding, or remains bound to that fail-closed run tombstone, so it cannot
become company-level maintenance data through foreign-key nulling. In enforce mode, direct run detail, transcript,
event, log, and workspace-operation reads return `404` to non-members. Company
run-history and live-run lists retain a metadata-only row with timing, status,
token usage, and cost for budget oversight; issue identifiers, summaries, and
run content are omitted. Runs without an issue binding keep company-level
maintenance-run visibility.
## Storage in Dev (Auto-Handled)
For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at:
@@ -823,10 +880,12 @@ If a repository is detached or its source configuration changes, its previous ta
## Config Freshness
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with.
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. Native runners project explicitly configured task environment variables into provider processes and tool commands, including custom `PAPERCLIP_*` names such as `PAPERCLIP_PAGE_BUCKET`. Adding, changing, or removing a projected variable replaces a retained provider process at the next run boundary. Unchanged variables permit process reuse. The projection contains names only; secret values stay in the child environment. Ambient host secrets are not projected. Native projections allow up to 128 variable names, 64 KiB per entry, and 256 KiB of values in total. These native launch limits do not apply to legacy adapter configuration. Runtime authority, provider login credentials, and process-loader settings use their existing restricted paths.
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback.
## Workspace Git Scan Protection
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical buffered scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Streaming snapshot scans have caller-owned sinks, so they use separate jobs in the same queue and are never cached or coalesced. Correctness-sensitive runtime guards bypass the result cache.
@@ -885,7 +944,16 @@ Seed modes:
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
- `--no-seed` defers database copying until the worktree is first used
- `--empty` creates an empty isolated instance, with fresh signing secrets and no deferred copy; use this for synthetic test drives
`--empty` persists a `.paperclip/seed-empty` marker before writing the instance config.
The CLI and managed runtime provisioner honor it even when a registered source
instance is present. An explicit successful `worktree reseed`, or a replacement
`worktree init --force` without `--empty`, clears the choice.
While that marker exists, startup uses the instance's saved JWT and tool-action
signing keys even if the shell inherited another instance's keys. Better Auth
uses its saved key or the fresh instance JWT key. Missing saved keys stop startup.
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.
@@ -984,7 +1052,8 @@ The workspace UI surfaces `Provisioning database`, `Validating clone`, `Ready`,
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--no-seed` | Defer database copying until first use |
| `--empty` | Create an empty instance with fresh signing secrets and disable automatic copying |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
@@ -1085,7 +1154,8 @@ Managed workspace repair uses this same verified full-reseed contract through `P
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--no-seed` | Defer database copying until first use |
| `--empty` | Create an empty instance with fresh signing secrets and disable automatic copying |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
@@ -1144,6 +1214,37 @@ In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to th
When a workspace service runs Paperclip for browser OAuth QA, configure its `expose.urlTemplate` with the canonical URL the browser can reach. Paperclip preserves explicit `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL` settings; otherwise it uses a valid exposed HTTPS origin (or loopback HTTP) as the managed runtime fallback for Better Auth and `/api/tools/oauth/callback`. Internal service names such as `http://paperclip-dev:<port>` are rejected unless that hostname is genuinely the browser route. Use a unique origin per isolated worktree. See [Execution Workspaces And Runtime Services](../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#browser-reachable-origins-for-oauth-qa) for configuration and verification.
## Heartbeat Service Extractions
Keep relevant heartbeat extractions and their focused tests in `server/src/services/heartbeat/`.
Task assignment Markdown is rendered by `server/src/services/heartbeat/task-markdown.ts`.
`heartbeat.ts` calls the renderer and re-exports it for existing callers. Keep prompt
formatting changes in the renderer and its tests, separate from run orchestration.
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
event delivery. Existing public helpers remain available from `heartbeat.ts`.
Workspace preparation is in `server/src/services/heartbeat/workspaces.ts`. It
owns managed checkout materialization, workspace validation and reuse, referenced
project resolution, and session/workspace configuration freshness. Its
`createHeartbeatWorkspaceResolver(db)` factory binds the run workspace resolvers
to a service's database. The checkout single-flight map stays at module scope so
all service instances share in-flight materialization. Existing public helpers
and the workspace validation error class remain available from `heartbeat.ts`.
Keep workspace policy changes separate from scheduling and run execution changes.
Run preparation is in `server/src/services/heartbeat/run-preparation.ts`. It owns
issue and wake context, responsible-user resolution, routine environment snapshots,
skill mentions, adapter environment configuration, and MCP/tool access setup.
`createHeartbeatRunPreparation(db)` binds the context loaders to a service's database
without doing database work during construction. `heartbeat.ts` keeps queueing,
dispatch, retries, cancellation, and execution order, and re-exports the existing
public helpers and configuration-incomplete error class. Keep preparation policy
changes in this module and its tests.
## Wake Context Delivery
Built-in adapters deliver wake context through the run prompt, including structured
@@ -1235,6 +1336,15 @@ agent workspace. The host `HOME` itself, a directory that contains it, a
filesystem root, a `CODEX_HOME` overlap, or a canonical path outside the
assigned workspace is rejected before provider startup.
Fresh remote Codex Runner runs recover from a supported image CLI that is too old
for the selected model. Preparation selects the closest compatible older model
of the same class, then the stable Runner default. The task shows a warning with
the requested model, effective model, and CLI version. Agent and task settings
stay unchanged. Update the image CLI to restore the requested model on later
runs. Explicit `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH` and
`PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` settings take precedence. See
[execution semantics](execution-semantics.md#remote-codex-model-compatibility).
### Sandbox ACP input delivery
The legacy sandbox process bridge retries recognized Daytona and Cloudflare
@@ -1311,6 +1421,14 @@ registers a correlated recovery request before it signals the dev supervisor.
An uncoordinated server restart uses the same durable recovery classifier
without trusting a handoff marker.
Recovery retains each run's saved execution prompt, revision, and context
digest across server upgrades. The shared parser validates the saved prompt's
SHA-256 and aggregate context digest. The revision is non-empty metadata; it
does not need to match the current release or a catalog of past prompts. New
runs use the current prompt. Do not rewrite saved execution inputs to the latest
prompt. Existing execution-schema, ownership, checkpoint, and permission checks
still determine whether recovery can proceed.
Startup binds the HTTP and PRP listener before it classifies native runs. Public
health reports a startup state until every candidate is reattached, dispatched
for same-run resume, finalized from durable evidence, or held for explicit
+9
View File
@@ -53,6 +53,15 @@ Paperclip can usually store `http://127.0.0.1:8642` as the gateway URL. For
Docker, LAN, tailnet, or reverse-proxy setups, use a URL reachable by the
Paperclip server process.
If a run reports a loopback connection refusal, check the listener from the
Paperclip server's host or container. Its loopback address does not refer to
your browser's machine or an agent sandbox. `hermes_gateway` does not start
Hermes: start the API server at the configured address, or set `apiBaseUrl`
to its reachable address. Choose `hermes_local` when Paperclip should launch
the Hermes CLI itself. Connection guidance does not prove whether a remote
run was accepted before a redirect failed; the task remains failed and the
normal failure reporting and retry rules still apply.
Plain HTTP is accepted for loopback. Non-loopback HTTP is denied by default in
the join flow; use HTTPS for real remote gateways. For private local
development only, the join payload can set
+204
View File
@@ -0,0 +1,204 @@
# Private tasks and projects
Paperclip work is company-open by default. A task or project can instead be
marked private when its title, discussion, documents, attachments, work
products, or agent run traces should be limited to named participants.
## Who can read private work
A private task is readable by its responsible user, creating user, current
user or agent assignee, active task grantees, and members of its private
project. Restrictions flow down through children and run-created handoffs. Sharing or
assigning a task grants access to that task and its descendants; ancestors and
siblings require a separate grant. An assignment grant stays
active until it is explicitly revoked. Current assignment, ownership, private-project
membership, and inherited access remain independent reasons for access. The
sharing panel shows the source; revoking one grant does not remove other reasons.
Making a parent private protects existing descendants in the same transaction.
Moving a task under a private parent inherits privacy. A child cannot be made
public while it inherits a private boundary; making a parent public leaves its
existing private children private.
Private projects have a separate access-member list. A task-level grant can
expose one task without exposing its containing private project. Direct reads
by non-members return `404`, and list, count, search, attention, status-card,
activity, run-history, and tree-control surfaces apply the same predicate.
Visible blocker and mention edges may show a locked identifier-only stub; they
never include the private task's title or content.
Audit logs remain company-wide because they are the sanctioned oversight path.
They contain entity identifiers rather than private task content. Dashboard and
sidebar aggregate counts also remain company-wide: they may include private
work in totals, but do not expose titles, descriptions, or identifiers. The
issue list/count APIs themselves are viewer-filtered.
## Audited break-glass
Company owners and admins do not silently inherit private-task access. A normal
task detail request still returns `404`. When an owner or admin has a legitimate
emergency need, they must deliberately request the task with
`?breakGlass=true`.
Every successful break-glass read writes both:
- an `issue.break_glass_read` audit row containing the actor, task id, and time;
- a warning system notice on the private task so its owners can see that access occurred.
The flag does not change the canonical read predicate, create a grant, or make
future reads implicit. Non-admin members and agents cannot use it.
Cloud customer-success inspection is a separate operator capability. When an
operator enables it, the dedicated Cloud-signed permit can read private task
content within its company scope. Ordinary users, agent API keys, and plugins
cannot use that permit. Cloud enforces the inspection grant, expiration,
revocation, replay protection, and audit trail; this path does not create a task
grant or a task break-glass notice. Operators who require task ACLs on every
content read must leave `PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED` disabled.
See [Cloud customer-success inspection support](CUSTOMER-SUCCESS-INSPECTION.md).
## Agent runs and shared-agent residual risk
Issue-bound run detail, events, transcripts, logs, and workspace operations use
the task ACL. Run authority is the intersection of the agent and the run's responsible user.
Reusing an agent does not transfer a different user's private-task permission.
Company run lists retain only timing/status/token/cost metadata for
non-members so budget oversight continues without exposing task identity or run
content.
Workspace operation records retain their original task and run sources even if
those entities are deleted or the operation is relinked. Deletion preserves the
audit record; unresolved sources make its details and log inaccessible. A
company-scoped run does not override a private task linked to the operation.
> **Residual risk — trusted agents (`trust-agent`).** Paperclip enforces privacy
> on reads from the control plane, but an agent that legitimately processes a
> private task may write learned content into shared persistent memory, its home
> directory, an external tool, or a later public response. V1 deliberately
> trusts the agent not to exfiltrate that context. Use isolated execution
> workspaces and appropriately trusted agents for sensitive work; task ACLs are
> not a sandbox or data-loss-prevention system.
## Plugins and notification/digest surfaces
Plugins (including any Slack notifier or digest plugin) are **non-member
principals**: they hold no company membership, task grant, or private-project
access. Rather than an allowlist of individually patched methods, the plugin
host centralizes the synthetic non-member check into shared helpers
(`requirePluginReadableIssue`, `pluginReadableIssueIdsByIds`,
`pluginRedactRelationSummary`) and routes **every** issue-derived read through
them. The complete inventory of issue-derived reads and their disposition:
- **Direct issue content** — `issues.list` (SQL predicate), `issues.get`,
`issues.listComments`, `issues.listAttachments`, `issues.getAttachmentContent`,
`issueDocuments.list`, `issueDocuments.get`: a private task is filtered from
list results, returns `null`/empty on soft reads, and reports "not found"
(indistinguishable from missing) on document reads.
- **Subtree / orchestration** — `issues.getSubtree` and
`issues.getOrchestrationSummary`: a private *root* reports "not found"; an
open root drops private descendants **before** any relation, document, run,
or assignee is fetched, so no private row or its metadata enters the payload.
- **Relationship metadata** — `issues.getRelations` (and the summaries echoed
by `issues.setBlockedBy` / `addBlockers` / `removeBlockers`, and the subtree
relation maps): blocker/blocks edges pointing at a private task are dropped,
recursing into nested terminal blockers, so its title, status, and assignee
never surface even on a public task.
- **Interactions** — `issues.listInteractions`: a private task yields no
interaction payloads.
A synthetic `{ type: "none" }` actor resolves to the same public-only scope as
any other non-member. This is why a digest broadcast to a shared channel —
whose audience is non-members by definition — cannot carry private issue
content. Writes that operate on a task must also satisfy its read predicate; plugin
capabilities do not substitute for task access. Returned relationship summaries
remain redacted.
There is intentionally **no plugin oversight exception**: unlike the
company-wide audit log, no plugin read path bypasses the predicate. A plugin
that must surface private work would need an explicit, separately designed
oversight capability; none exists today.
> **Residual metadata disclosure.** A plugin still learns aggregate,
> content-free signals about private work that mirror the accepted
> dashboard/count disclosure: `issues.getOrchestrationSummary` returns
> company-wide `openBudgetIncidents`. Its `costs`/token roll-up is limited to
> the readable subtree, and unreadable relationship edges are omitted entirely.
> No private title, body, comment, attachment, identifier, relationship target,
> or locked-edge signal is exposed.
## Rollout modes
`PAPERCLIP_ISSUE_PRIVACY_MODE=enforce` is the default. `shadow` records
structured would-deny decisions without enforcing them and exists only for
rollout diagnosis. `off` disables the task predicate. Operators should not use
`shadow` or `off` when private-task confidentiality is required.
The production gate includes positive and negative checks: an authorized reader
can read a shared child and its descendants, an unrelated reader cannot, and a
warm HTTP or live subscription loses that grant after revocation. Privacy grants
are read from the database without a cross-request positive cache.
## Migration and current runtime integration
Migration `0313_private_task_access.sql` adds the task/project ACL tables and
backfills privacy parent edges with indexed keyset batches. Run binding is derived
from native task identity, explicit issue identity, or legacy `issueId`/`taskId` context. Missing
source tasks remain issue-scoped tombstones. Context changes cannot turn those
runs into company-wide history. The migration is idempotent for preview installs;
existing explicit grants are preserved. Operators upgrading an unreleased preview
should inspect root grants created under its former whole-tree sharing semantics.
Migration `0314_private_task_draft_assets.sql` accompanies enforcement and binds
historical inline images to their first owned task. Unbound drafts remain
uploader-only under the new asset-content guard.
Both privacy migrations use the Paperclip migration executor's explicit
nontransactional path. Indexes on existing tables build concurrently; invalid builds are repaired on
retry. DDL statements commit individually and each 1,000-row
keyset batch commits before advancing, releasing schema and row locks. The
migration journal is written only after all batches succeed; an interruption
leaves the migration pending and its idempotent statements can be replayed.
Bootstrap uses the same executor, and other migrations retain a transaction per
file. Apply these migrations with `pnpm db:migrate`, before enabling the new
server or private-task UI.
Private output uses the same predicate on native tool searches and task context,
linked approvals, training exports, execution workspace APIs, stored run-response
assets, and WebSocket delivery. Workspace access requires permission for every
linked task. Chat publications recheck recipient identity and task access at the
provider boundary: private output is withheld from shared channels and from DMs
whose known recipients no longer qualify. This does not revoke Gmail or other
connection credentials; connection permissions continue to apply independently.
## CI leak-test inventory
The privacy regression gate is part of the normal server Vitest suite. Its
surface coverage is intentionally distributed beside the routes and services
it protects:
| Surface | Non-member regression coverage |
| --- | --- |
| Downward sharing, responsible-user intersection, mutation denial, provenance, live revocation | `privacy-production-review.test.ts` |
| Task detail, list, count, grants, documents, work products | `issue-access-grants-routes.test.ts`, `company-search-service.test.ts` |
| Search and machine extract | `company-search-service.test.ts`, `company-search-extract-service.test.ts` |
| Attention feed | `attention-service.test.ts` |
| Status-card hydrate and dry-run | `status-cards.test.ts` |
| Activity stream | `activity-service.test.ts`, `activity-routes.test.ts` |
| Run list, live run, detail, transcript, events, logs, operation history | `heartbeat-run-privacy-routes.test.ts` |
| Tree holds and tree control | `issue-tree-control-routes.test.ts` |
| Blocker and mention identifier-only stubs | `issue-access-grants-routes.test.ts` |
| Attachment content | `issue-attachment-routes.test.ts` |
| Private-project list and direct read | `projects-list-archived-routes.test.ts` |
| Plugin issue reads (list, get, comments, attachments, orchestration, subtree, relations, interactions, documents) | `plugin-orchestration-apis.test.ts` |
Adding a new task-derived read surface requires a non-member fixture in this
gate before the surface can ship.
Project privacy management belongs to its recorded creator (the responsible user
for agent-created projects), the personal-project owner, and administrators.
Project read membership alone never allows publishing the project or changing
its audience. Legacy projects recover ownership from their creation audit event;
when that evidence is missing, an administrator manages their privacy.
### Task privacy management hints
`GET /api/issues/:id/privacy-constraints` is available only to a principal who can read and manage that task. It returns the blocked scope kind and whether publishing leaves a personal project. It does not return protected parent or project names, identifiers, owners, or contents. This lets task owners manage their task without requiring access to its surrounding project. Visibility writes still enforce the canonical rules under the privacy-tree lock; these hints do not authorize a write.
+19 -1
View File
@@ -30,6 +30,15 @@ Example: A CEO agent's adapter config tells it to "review what your executives a
Then you define who reports to the CEO: a CTO managing programmers, a CMO managing the marketing team, and so on. Every agent in the tree gets their own adapter configuration.
Each person also has a personal primary agent per company. Their first human-created
agent becomes primary automatically; they can choose another from its profile.
An existing primary is replaced only after confirmation. The crown appears on the
profile and roster, while the primary stays first in the Agents sidebar without a
sidebar crown. Stars remain independent. Task drafts, explicit assignments, and
recent choices take precedence over the primary; Chat similarly reopens a valid
recent conversation before falling back to the primary. Opening Chat does not
start execution. This preference does not change the org chart or permissions.
### Agent Execution
Paperclip supports several ways to run an agent's heartbeat:
@@ -135,7 +144,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not interpret agent-profile privacy flags as project/issue privacy. Work remains company-open by default, while explicitly private issues and projects use their own task ACLs.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
@@ -232,3 +241,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
carry a task filter open All. Old `/inbox` links redirect to the matching view.
### CSV file previews
Task attachment and workspace file tabs render CSV files as tables by default.
The first record supplies column headers. Row numbers, row and column counts,
and sticky headers help operators scan exports. Rendered and raw view icons
remain next to download; raw view preserves the original source. The table
shows up to 500 data rows and 100 columns, with a notice when the preview is
limited. Download retains the complete file.
+111 -5
View File
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Company model | Company is first-order; all business entities are company-scoped |
| Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
@@ -220,6 +220,8 @@ Invariant: at least one root `company` level goal per company.
- `goal_id` uuid fk `goals.id` null
- `name` text not null
- `description` text null
- `visibility` enum-like text: `open | private`, default `open`
- `personal_owner_user_id` text null; unique with `company_id` when present
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
@@ -258,6 +260,9 @@ retain their titles and default to no generation request.
- `project_workspace_id` uuid fk `project_workspaces.id` null
- `goal_id` uuid fk `goals.id` null
- `parent_id` uuid fk `issues.id` null
- `visibility` enum-like text: `open | private`, default `open`
- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata
- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs
- `title` text not null
- `description` text null
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
@@ -310,11 +315,39 @@ Invariants:
- `author_user_id` uuid fk `users.id` null
- `body` text not null
## 7.7.1 `issue_access_grants`
- `issue_id` uuid fk `issues.id` not null
- `subject_type` enum-like text: `user | agent`
- `subject_id` text not null
- `source` enum-like text: `explicit | assignment | project`
- `granted_by_user_id` text null
- `granted_by_agent_id` uuid fk `agents.id` null
- `created_at` timestamptz not null
- `revoked_at` timestamptz null
An issue is readable when it is open outside a private project, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), the actor has an unrevoked grant on the issue or a private ancestor, or the actor is an access member of its private project. This is one canonical predicate shared by detail, list, count, search, and run-derived reads. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access. Sharing a child covers that child and descendants; ancestors and siblings require separate access. Agent reads also require the responsible user to qualify. A private parent protects existing and newly created descendants; making a parent public does not publish private descendants.
## 7.7.2 `project_access_members`
Authorization membership for private projects, distinct from the personal-sidebar `project_memberships` table:
- `id` uuid pk
- `company_id` uuid fk `companies.id` not null
- `project_id` uuid fk `projects.id` not null
- `subject_type` enum-like text: `user | agent`
- `subject_id` text not null
- `created_at` timestamptz not null
- unique `(project_id, subject_type, subject_id)`
Private projects are absent from list and search results for non-members, and direct reads return `404`. An issue-level grant can still expose its issue without exposing the containing project. A user's `My private tasks` project is created lazily and concurrency-safely on their first unprojected private task; the owning user is its initial and non-removable project member. An agent-created private root also grants that creator issue-level access while retaining the responsible user as the human principal.
## 7.8 `heartbeat_runs`
- `id` uuid pk
- `company_id` uuid fk not null
- `agent_id` uuid fk not null
- `issue_id` uuid fk `issues.id` null; first-class binding for issue-scoped run confidentiality
- `invocation_source` enum: `scheduler | manual | callback`
- `status` enum: `queued | running | succeeded | failed | cancelled | timed_out`
- `started_at` timestamptz null
@@ -417,6 +450,7 @@ Operational policy:
- `cost_events(company_id, occurred_at)`
- `cost_events(company_id, agent_id, occurred_at)`
- `heartbeat_runs(company_id, agent_id, started_at desc)`
- `heartbeat_runs(company_id, issue_id, created_at)`
- `approvals(company_id, status, type)`
- `activity_log(company_id, created_at desc)`
- `assets(company_id, created_at desc)`
@@ -453,6 +487,7 @@ Operational policy:
- Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download.
- Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`.
- Script-capable content such as HTML is always served as an attachment with `X-Content-Type-Options: nosniff` and a sandboxed, deny-by-default CSP; it is never rendered inline on the Paperclip origin.
- The board can fetch bounded HTML source for a rich preview in an opaque-origin iframe (`sandbox="allow-scripts"`, without `allow-same-origin`). A trusted CSP precedes artifact markup and blocks remote subresources, API connections, forms, and embeds. Rendered/raw icons sit beside Download. Workspace HTML previews return UTF-8 inside JSON under the existing text limits. See `doc/html-artifact-previews.md`.
- Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking.
- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`.
- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access.
@@ -508,6 +543,32 @@ Decision-desk triage uses company-scoped sidecars rather than adding queue field
- `decision_archive_notification_outbox` records one retry-safe origin-agent notification per source/archive version. The 90-day internal sweeper archives only unkept rows and coalesces delivery per origin agent.
- Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts.
## 7.17 Personal primary agent (2026-10-07)
`user_company_preferences` has a unique company/user key, nullable `primary_agent_id`,
and sticky `primary_agent_initialized` flag. GET/PUT
`/api/companies/:companyId/primary-agent/me` derives the human identity from auth,
checks company access and agent visibility, rejects agent actors, and audits changes.
PUT accepts an approved, non-terminated agent; choosing a previously left agent
rejoins it under the existing membership rules. There is no explicit removal UI or
nullable PUT. Leaving, termination, and deletion clear the reference without
allowing a later creation to initialize it again. Pausing and errors retain it.
Initialization runs in the human-attributed creation transaction, including
onboarding, with uniqueness arbitrating concurrent creation. System provisioning
and agent-authored hires cannot initialize a human preference. Migration backfill
uses the earliest attributable human creation, preserving empty initialized state
when the original is gone, terminated, or left; unknown ownership stays unset.
The profile is the only setting surface. Replacing a primary requires the reviewed
avatar-to-avatar confirmation. A first choice has no modal. Only the profile and
roster show the accessible crown; the Agents sidebar pins the primary first without
duplication. Tasks preserve explicit/draft assignments, then choose a recent
eligible assignee, the eligible primary, or the existing fallback. Chat preserves
a valid recent conversation, then opens the primary, then retains its chooser.
Navigation alone does not create an execution. Failed preference mutations restore
the previous state and offer retry. Preferences and caches are company/user scoped.
## 8. State Machines
## 8.1 Agent Status
@@ -706,7 +767,7 @@ changes so both agent and board edits are visible in the issue activity stream.
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
Paperclip keeps company-scoped visibility as the default. The task-ACL foundation adds opt-in private issues while preserving `open` for all existing and unspecified issues.
The approved term set is:
@@ -720,8 +781,8 @@ The approved term set is:
## 9.5 Core V1 Rule: what “private” means
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
- Work objects remain company-open by default. An issue or project can opt into the shared private-read predicate.
- Organization-wide custom ACL policy remains deferred to Pro/Enterprise controls.
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
@@ -732,7 +793,7 @@ The approved term set is:
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
| Work-object visibility | Company-open by default; opt-in private issues/projects with explicit user or agent access | Policy-driven ACLs and reviewer-only channels |
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
| Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX |
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
@@ -1139,6 +1200,9 @@ Invites tab does not hide this Cloud action.
- `POST /companies/:companyId/issues`
- `GET /issues/:issueId`
- `PATCH /issues/:issueId`
- `GET /issues/:issueId/access-grants`
- `POST /issues/:issueId/access-grants`
- `POST /issues/:issueId/access-grants/:grantId/revoke`
- `GET /issues/:issueId/documents`
- `GET /issues/:issueId/documents/:key`
- `PUT /issues/:issueId/documents/:key`
@@ -1198,6 +1262,11 @@ assignee/run ownership checks, and the `issue.released` activity event still app
- `POST /companies/:companyId/projects`
- `GET /projects/:projectId`
- `PATCH /projects/:projectId`
- `GET /projects/:projectId/access-members`
- `POST /projects/:projectId/access-members`
- `DELETE /projects/:projectId/access-members/:memberId`
Project list, search, direct reads, and nested project routes apply private-project access. Non-members receive the same not-found behavior as missing projects.
## 10.6 Current-user Resource Memberships
@@ -1485,6 +1554,20 @@ for contracts, recovery behavior, Storybook, and acceptance workflows.
Board may override by raising budget or explicitly resuming agent.
Native runs retain final usage receipts during a bounded accounting-only drain
after a governed wait cancels provider work. This does not accept late messages,
tool calls, or new completion proposals. Missing or incomplete receipts continue
to block budget admission.
A controller that detaches for server restart loses checkpoint and completion
write authority. A closing event stream is not proof that the governed run
finished; the replacement controller must adopt and settle the original run.
Complete direct Anthropic API receipts for `claude-sonnet-5` can use a versioned
list-price estimate when the provider supplies no run price. The receipt records
the rates and assumptions. Aggregate cache writes use the one-hour rate because
their TTL is unknown. Estimates are not invoices. Other models, billers, unknown
billing types, and incomplete receipts remain unpriced.
## 13.3 Cost Event Ingestion
`POST /companies/:companyId/cost-events` body:
@@ -1970,6 +2053,13 @@ configuration, plugin packages and outstanding hosted release gates. The
[delivery plan](plans/2026-09-30-paperclip-public-mcp-and-plugins.md) separates
external agent participation and granted third-party tools into later releases.
The experimental OpenAI Dot Runner provider uses a separate `/mcp/runner`
agent OAuth resource. It reuses the public gateway's browser/device consent,
client metadata verification and signed event delivery, but requires one-use
agent pairing and normal run admission. Personal grants cannot authorize
Runner operations. See [OpenAI Dot Runner](openai-dot-runner.md) for the
self-hosted release boundary and remaining account qualification.
### Experimental AI connection routing
Opt-in plugin routers may represent a pool as an AI runtime binding. Core keeps
@@ -1990,6 +2080,16 @@ remove stale instructions after edits or access revocation. See
[Connection instructions](connections/CONNECTION-INSTRUCTIONS.md) for contracts,
UI conventions, custom adapter integration, and initial memory templates.
### Remote Codex model compatibility
Fresh remote Codex Runner preparation can replace a model whose verified CLI
minimum exceeds the supported image CLI version. It selects a compatible older
model in the same class, then the stable Runner default, and saves that effective
model before checkpoint selection. A visible task warning and local run-log event
record the substitution. This does not change agent settings, rewrite admitted
executions, or bypass artifact, ownership, permission, and budget gates. See
[remote Codex compatibility](execution-semantics.md#remote-codex-model-compatibility).
### Native provider capacity retry
Committed, run-bound Codex `serverOverloaded` terminal failures display the model
@@ -2008,3 +2108,9 @@ as free-form text in the instance database. Legacy agents use the default
in standard, ask, and planning modes. Submission never changes task disposition
or routes feedback externally. See [Agent commentary](agent-commentary.md) for
authentication, replay, document-sized limits, inspection, and deletion semantics.
## Company decision-model service
Company Settings → General can configure one shared API-key connection for optional internal decisions. V1 supports OpenAI Decisions and Jev through OpenRouter. Companies start unconfigured; background sponsorship defaults on when configured, while an explicit off setting persists. Human and agent calls retain current responsible-user, connection audience, resource, and agent installation checks. Only explicitly registered internal background features can use company sponsorship.
The internal service provides local-only availability and bounded, reauthorized execution. It records metadata-only invocation history and independent fractional service charges in the existing cost ledger, applying company and applicable agent/project budgets under the accounting lock. Unknown dispatch charges retain reservations until audited resolution. Settings/testing require connection management permission; history uses existing cost visibility. See [decision-models.md](decision-models.md) for the contract, supported models, accounting, privacy, and endpoints.
+24 -1
View File
@@ -91,6 +91,13 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t
## 2. Agent Model [DRAFT]
A human's personal primary agent is a company-scoped navigation and assignment
preference, independent of the org chart, roles, stars, and authority. First human
creation initializes it automatically. Later explicit choices persist across
devices; recent task/chat choices take precedence. The profile owns the setting
and replacement confirmation. See the personal-primary addendum in
`SPEC-implementation.md` for persistence and lifecycle rules.
Every employee is an agent. Agents are the workforce.
### Cryptographic identity
@@ -178,7 +185,7 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
Visibility settings on an agent profile do not control task privacy. Work is company-open by default; opt-in private tasks and projects use centralized authorization as specified in `SPEC-implementation.md` and `ISSUE-PRIVACY.md`. Sharing a child grants access to it and its descendants. Ancestors and siblings require separate access.
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
@@ -326,6 +333,8 @@ recipients must be valid and authorized in the company before a question is
saved. See `SPEC-implementation.md` §9.8.1 for the resolver contract.
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with the task's selected visibility. New execution tasks are ordinary project tasks, not structural children of the conversation; private-source restrictions still flow through their run provenance. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
### Implications
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
@@ -487,6 +496,10 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
Self-contained HTML reports can render in an opaque-origin sandbox. The report
cannot access the board's cookies, storage, or DOM. Users can switch to the raw
source beside Download, which returns the original file.
Users may start a task with only a prompt. Paperclip uses a short prompt slice as
its initial title and asks the assigned agent to name the task early. Explicit
user titles are preserved, and naming does not change task execution state.
@@ -716,6 +729,12 @@ configuration, plugin packages and outstanding hosted release gates. The
[delivery plan](plans/2026-09-30-paperclip-public-mcp-and-plugins.md) separates
external agent participation and granted third-party tools into later releases.
The experimental OpenAI Dot Runner provider uses a separate `/mcp/runner`
agent OAuth resource. It reuses browser/device consent and signed event delivery
while preserving agent pairing, normal run admission and task authority.
Personal grants cannot authorize Runner operations. See
[OpenAI Dot Runner](openai-dot-runner.md) for its supported release boundary.
### Experimental connection routing
A virtual AI connection can rotate new task/agent allocations through an
@@ -731,3 +750,7 @@ as free-form text in the instance database. Legacy agents use the default
in standard, ask, and planning modes. Submission never changes task disposition
or routes feedback externally. See [Agent commentary](agent-commentary.md) for
authentication, replay, document-sized limits, inspection, and deletion semantics.
### Managed decision models
A company may configure a shared decision model for optional Paperclip features. The instance owns credential resolution, authorization, budget admission, and attributable service charges. Company-sponsored background use is enabled by default during configuration; explicit opt-out persists. User and agent requests keep their own access boundaries and cannot become sponsored background requests after denial. Availability is a cheap local capability check, and metadata-only request history makes service usage inspectable. The implemented V1 contract is in [decision-models.md](decision-models.md).
+9
View File
@@ -58,6 +58,15 @@ removes it if startup fails. The child's environment values remain unchanged.
If the launch shell fails before the wrapper emits a protocol event, the run
log retains the shell's stderr alongside the exit code.
The process-session bridge also writes one bounded terminal diagnostic to the
run's stderr. It distinguishes a remote event, input delivery failure, output
poll failure, output stream failure, and unexpected authenticated proxy closure.
Exit codes and signals are validated; unavailable values are `unknown`. The line
contains no raw error message, command, path, or environment value. It records
the first observed terminal event, not proof of remote shutdown. An error frame
drains to the local proxy before the connection closes, and a failed or stalled
diagnostic write does not delay failure delivery or teardown.
The settlement sequence is the one live cleanup owner for every settled path. It
claims the ledger once, makes the pure reuse decision, then runs the ordered
steps:
+1 -1
View File
@@ -10,7 +10,7 @@ No agent defaults or saved model selections are migrated.
| Adapter | Changes from the audit |
| --- | --- |
| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. |
| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. Follow-up (October 6, 2026): with ChatGPT sign-in the Codex backend accepts `gpt-6.1-sol` only from Codex CLI 0.159.0 or newer (0.156.1 and older are rejected with "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."), and `gpt-6-sol` / `gpt-6-luna` entered the bundled catalog in 0.157.0. The adapter now records these floors; the environment Test and the remote runner compare the installed `codex --version` against them, so a sandbox image baked before the pin moved reports the stale CLI instead of an account error. |
| Claude on Bedrock | Add Sonnet 5.5 (`us.anthropic.claude-sonnet-5-5`), using the catalog's existing US inference-profile convention. Sonnet 5.5 IDs (direct or Bedrock-qualified) get the documented `xhigh` and `max` efforts and require Claude Code 2.1.284 or later on the CLI lane. |
| OpenCode | Add `openai/gpt-6.1-sol` and `anthropic/claude-sonnet-5-5` to the static fallback used by remote environments. Both IDs are present in the OpenCode model registry. |
| Claude Code (direct) | No change in this audit. [#14993](https://github.com/paperclipai/paperclip/pull/14993) (merged October 5, 2026, superseding #14816) adds Sonnet 5.5 (`claude-sonnet-5-5`, Claude Code 2.1.284 or later) and refreshes the Claude runtime. |
+13
View File
@@ -57,6 +57,12 @@ grants consent. A provider connection is not proof that its underlying app is
authorized. Multiple matching aggregator apps are returned as candidates; the
agent searches the selected `aggregator.targetService` to get its provider question.
A fuzzy or partial external app match remains a suggestion alongside authorized
installed capability matches. It cannot replace those matches with a mandatory
provider question. For example, a query for a page service can return the
installed page tools alongside a Page X suggestion; selecting the exact Page X
service is required before that external route asks for provider consent.
## Regression coverage
```sh
@@ -69,3 +75,10 @@ Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The nati
tool authority test searches a paragraph longer than the old 200-character limit
and then creates a real connection interaction. Existing tests retain checks for
private metadata, administrative denials, stale identities, and saved declines.
When `connection_request` rejects an unknown `toolNames` entry, its error includes
up to twenty exact names from that eligible connection's active tool catalog.
This is discovery metadata: the request still fails with 422, grants no access,
and creates no approval card. The agent must select the needed exact names and
submit a new request; normal user approval, catalog-version and policy checks
still apply. Ineligible connections and inactive catalog entries are excluded.
+3
View File
@@ -206,3 +206,6 @@ translate the intent into Apps v2:
Do not add new work to the retired v1 branch. If an old ticket still describes a
valid product gap, retarget it to an active Apps v2 issue or close it as
superseded with a link to the replacement.
For Slack chat bot creation, installation, recovery, and live qualification, see
[Automatic Slack app setup](SLACK-AUTOMATIC-SETUP.md).
+241
View File
@@ -0,0 +1,241 @@
# Automatic Slack app setup
New Slack bot drafts default to automatic setup. Existing drafts without a
`slackSetupMethod` remain manual. Paperclip uses Slack's Manifest API and OAuth
v2 directly; neither the operator nor the assisting agent needs the Slack CLI.
## Operator setup
1. Enable Chat connectors in Experimental settings and select an agent in
Connectors → Slack → Chat with an agent.
Once an agent is selected, Advanced reveals the generated app name, bot name,
and slash command. Defaults use the agent name without random suffixes.
2. On App configuration access token, select **Get your App configuration access token**. In
[Slack app settings](https://api.slack.com/apps), open **Your App Configuration
Tokens → Generate Token**, select the workspace, and paste the temporary
**Access Token** directly into Paperclip's password field. Do not supply
its refresh token. Select **Create Slack app**.
3. After creation and configuration succeed, Paperclip advances to **Install Slack app**
and opens Slack installation in a new window. Approve installation in the intended
workspace. If the window is blocked or closed, select **Install in Slack**.
If workspace policy requires administrator approval, return to this saved
draft when approval is available. The existing app is reused. Paperclip links
the Slack account returned by OAuth to the signed-in Paperclip account that
started installation and sends one welcome DM: “Hi, I’m <agent name>. Your Slack
account is connected to Paperclip.” Use your own Slack account.
4. On **Send a message to your agent**, select **Open Slack** and send a message.
Paperclip checks incoming events automatically. A signed message event from the
saved app/workspace or Slack's signed challenge completes the check. The same
fourth screen then shows **Success** with a checkmark and **Done**; there is no
separate Try it screen. The welcome-message event can complete this check before
you send a message. Slack may still display an unverified URL.
If the wizard keeps waiting, expand **Didn’t work?** for Event Subscriptions,
Request URL, and Retry instructions. Opening a settings link is not proof.
A failed welcome DM does not block setup; open Slack and message the bot directly.
Avatar provisioning is automatic and has no wizard step. Failed or interrupted
uploads do not block onboarding; manual upload remains available in the saved
connection’s **Settings → Agent avatar** section.
Automatic creation now omits event subscriptions from the initial manifest,
saves the returned signing secret, then applies the full canonical manifest with
`apps.manifest.update`. This removes the creation-time signing-secret race.
The 2026-10-07 live experiment did not receive a URL challenge after this update;
Slack nevertheless delivered an event, and the operator confirmed receiving an
agent response without clicking Retry. A manifest update alone never advances verification.
Manual creation continues to use the full canonical manifest.
Slack's settings-page Request URL verification remains distinct from Paperclip's
connection check. Automatic setup accepts an authenticated message/app-mention
event only after the runtime accepts the request, with the current signing secret,
expected app/workspace/bot binding, current callback URL, and active connection.
Manual setup retains the signed-challenge requirement. Paperclip
can answer a signed challenge as soon as creation has saved the signing secret,
even before OAuth installation finishes. This path performs no Slack API calls
and does not initialize the bot runtime or wait for the creation operation's lease.
Pre-install verification survives configuration only for the same signing secret
and webhook URL. It does not authorize installation or activate the connection.
The verification DM is queued separately from the HTTP acknowledgement, with
durable pending/sending/sent state. Repeated challenges do not send another DM; failed or ambiguous sends do
not block setup or replay automatically. Background recovery processes pending
messages and quarantines sends interrupted by a restart.
Paperclip captures the signing secret and client secret from creation and obtains
the bot token during installation. Durable secrets are stored in the company
vault and are not returned by setup APIs or added to the agent prompt. The
temporary configuration token is used for app creation, `apps.manifest.update`, and `apps.icon.set`, then
discarded after the request. Save & exit keeps
the draft and all saved non-secret progress.
**Create manually** and **Use an existing app** are under Advanced on the token step.
**Create manually** uses the same generated manifest. **Use an existing app**
keeps the existing credential-entry workflow. These paths retain explicit
personal account linking through the displayed `/command connect` command.
To connect a different account after setup, use the connection’s Access page.
Both options remain available for
recovery on the same draft. The selected automatic, manual, or existing-app method
is saved, so an existing-app draft reopens at credential entry. Agent instructions require the user to enter the temporary token and handle login/admin approval.
**Invite people** copies instructions for the saved app’s `connect` command.
Each recipient gets their own expiring confirmation link in Slack and confirms
their identity while signed in to Paperclip. New organization members require
membership approval. Existing members can self-link; disconnecting an identity
does not permanently block that member from reconnecting. Guest access remains
a separate, explicit setting for restricted work.
The person joining runs `/<saved slash command> connect` themselves (for example,
`/maya connect`), without an `@person` argument. The command discovers their
identity without starting agent work or granting access. Slack conversation
agents receive this saved command on each fresh or resumed turn, alongside the
confirmation and membership-approval instructions. If the saved command is
unavailable, they refer the connection manager to **Access → Invite people**
instead of guessing from the agent name. Inviting a person or bot to a Slack
channel does not grant that person Paperclip access.
**Allowed Channels** updates every five seconds while Slack Settings is open.
Inviting the bot to a new channel enables replies and writes there automatically;
no additional Paperclip configuration is required. An explicit off switch stays
off across repeated invitations, reconnects, and inventory refreshes.
A bare @mention starts a normal agent conversation. Paperclip creates the task,
subscribes to the Slack thread, and starts the agent; later replies in that thread
continue the same task without another mention. Normal account and channel access
rules still apply, and repeated Slack deliveries do not create duplicate tasks.
Agents can ask interactive questions in Slack through Paperclip's saved
`ask_user_questions` interactions. Native runners use `request_human_input`;
legacy adapters use the issue-interactions API documented in the injected
Paperclip skill. Slack shows native answer buttons or an **Answer questions**
button and form, depending on the question and connection capabilities.
Submitting an answer wakes the assigned agent on the same task.
This works even when the adapter's built-in question tool is unavailable.
The app icon upload and Settings download use the agent’s 512×512 PNG on
Paperclip’s dark-mode background (`#171717`). Slack flattens transparent app icons
onto white, so this export has an opaque background before upload. Normal
Paperclip avatars retain transparency. Existing Slack apps need an icon re-upload
to adopt the new background; changing Paperclip does not replace their saved icon.
## Deployment
Automatic setup requires a canonical public HTTPS board origin and a public HTTPS
webhook ingress. The server derives URLs and permissions from its configuration;
the browser cannot supply a manifest, scopes, or callback destination.
- The OAuth redirect is `<canonical board origin>/api/chat-slack/oauth/callback`.
It uses the existing authenticated board callback path and Cloud bootstrap
checks. Keep board authentication enabled.
- Webhook URLs use `PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL` when configured, otherwise
the canonical board origin. They end in `/api/chat-webhooks/<publicId>/slack`.
A separate webhook ingress must route signed webhook requests to the instance.
- Avatar upload renders the assigned agent's preset PNG on the server and sends
its bytes through `apps.icon.set` as multipart form data. Slack does not need to
fetch an image from the tenant's authenticated board origin. The image is
512 × 512 with the Paperclip dark background. The temporary configuration
token stays in the Authorization header and is discarded after setup.
- Cloud resolves its current canonical origin from its claimed runtime identity.
Self-hosted deployments use the configured authentication public base URL
(`PAPERCLIP_AUTH_PUBLIC_BASE_URL` or `PAPERCLIP_PUBLIC_URL`).
Both URLs must have valid certificates and be reachable by their intended
callers. A private Tailscale Serve address is insufficient for Slack webhooks.
- Origin changes invalidate outstanding authorization attempts. Restore the saved
origins or reconcile the existing Slack app and use manual recovery. Never
loosen authentication or signature verification to bypass a failed callback.
## Recovery and invariants
| Saved state | Operator action |
| --- | --- |
| Invalid/expired configuration token | Generate a new configuration token and retry. Draft details are preserved. |
| Creation uncertain after timeout/restart | Inspect Slack app settings. Recover an existing app manually on this draft. Start another creation only after explicitly confirming that no app exists. |
| App saved; event configuration pending after failure/restart | Enter an app configuration access token and select Retry app configuration. The same saved app and canonical manifest are reused; installation is blocked until this update succeeds. |
| App created; installation declined/pending | Install the saved app again. No configuration token is needed. |
| Revoked bot token or uninstalled app | Start fresh installation authorization for the same saved app. Existing workspace, bot, and account bindings are preserved; consent started before revocation cannot restore the connection. |
| Authorization expired or code exchange uncertain | Start fresh installation authorization. Used codes are never replayed. |
| Credentials saved; connection check failed | Use Retry connecting. Paperclip reuses vaulted credentials. If Slack access changed or the token was revoked, use Authorize in Slack again for the same app. |
| Wrong app/workspace/bot or missing scopes | Correct the installation of this app. Activation remains blocked. |
| Avatar upload failed or interrupted | Continue setup with the saved app. Optionally upload the avatar from Settings later. The configuration token is not retained for retries. |
| Existing or revoked personal account link conflicts | Manage account links in Access settings. Installation never overwrites someone else’s link or restores a revoked link. |
| Welcome DM failed or interrupted | Continue setup and open Slack directly. A saved dispatch is never replayed after refresh, restart, or reauthorization. |
| Removed connection | Pending state and app-registration secrets are invalidated. Remove the customer's app separately through its Slack management link if desired. |
App details are immutable after creation dispatch, including when the outcome is
uncertain. The endpoint's existing credential lease serializes creation,
configuration, recovery, and removal across server processes. Generic connection
removal uses the same lock. The unique-bot constraint still applies.
Registration secrets are separate from runtime credentials. The received bot token
is vaulted before `auth.test`, inventory, and configuration. Staged bot/signing
secret copies are deleted only after runtime binding commits. Client credentials
remain in the registration vault for subsequent authorization. OAuth’s
`authed_user.id` identifies the installer; the bot token confirms that person’s
workspace through `users.info`. Company membership and existing identity-link
constraints are checked before linking. Reauthorization preserves the original
linked account. Welcome-DM dispatch is saved before calling `chat.postMessage`;
an interrupted dispatch becomes uncertain instead of sending a duplicate.
The welcome contains no confirmation token and starts no agent work. Local activity
records contain safe IDs and outcome codes. This feature adds no telemetry.
## Verification
Focused checks:
```sh
pnpm exec vitest run packages/shared/src/slack-app-manifest.test.ts ui/src/pages/apps/chat/SlackAutomaticSetup.test.tsx
pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts -t 'automatic Slack registration'
pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/chat-adapters-ui-providers.spec.ts
pnpm check:token-gates
```
The Storybook stories under **Connections / Slack / Automatic setup** render the
production wizard with simulated provider responses. They cover the automatic
journey, manual setup, pending/declined installation, uncertain creation, saved
credential recovery, avatar failure without an extra wizard step, verification
waiting, welcome-DM failure, and mobile layouts. These are fixture checks.
Partial live qualification on 2026-10-07 used an isolated authenticated instance
behind TryCloudflare and the operator's authorized Slack installation. The
operator created and installed the app through the wizard. Safe activity recorded
successful avatar upload, installer linking, and welcome delivery. A signed
Request URL challenge was recorded after Retry; the later verification
confirmation was sent to the already-linked DM using that saved receipt, without
creating another app. No credentials or OAuth codes are included in this evidence.
The new pre-install challenge path, notification concurrency/restart recovery,
revocation, removal, and provider failure cases passed deterministic fixtures.
In a subsequent two-phase creation run, Slack accepted the manifest update but
sent no URL challenge. An Events API callback returned HTTP 200, and the operator
confirmed a real agent reply without clicking Retry. The new automatic advancement
based on that kind of event is covered by fixtures; the operator subsequently confirmed live automatic advancement after another message.
Same-thread follow-up, reauthorization, and removal qualification remain outstanding.
For live acceptance, use an isolated HTTPS Paperclip instance, a dedicated test
app, and an explicitly authorized Slack workspace and destination. Have the user
enter the configuration token directly. Verify app creation, consent return,
signed URL verification, avatar status, automatic installer linking, welcome DM,
a real message and same-thread follow-up, the assigned agent run, and Slack delivery. Resume an
interrupted setup, reauthorize the same app, and remove the Paperclip connection
with an outstanding attempt. Record sanitized IDs/links and outcomes, without
secret fields, OAuth codes, or provider payloads. Record live results separately
from deterministic fixtures.
Provider references: [Manifest creation API](https://docs.slack.dev/reference/methods/apps.manifest.create/),
[OAuth installation](https://docs.slack.dev/authentication/installing-with-oauth/),
[Manifest reference](https://docs.slack.dev/reference/app-manifest/),
[App icon API](https://docs.slack.dev/reference/methods/apps.icon.set/),
[Sending a DM](https://docs.slack.dev/reference/methods/chat.postMessage/).
## Agent behavior checks
Slack per-turn guidance covers saved interactive questions, automatic final
replies, current-chat file handoff, explicit actions, delivery uncertainty, and
action policies. Tool-description changes refresh the stored catalog without
re-enabling removed tools or changing their review ownership.
The [connector probe catalog](../../server/src/services/connectors/slack/evals/README.md)
keeps eleven manual acceptance probes next to that guidance, with related
deterministic checks selectable through `pnpm test:slack-connector`. No automated
model campaign is registered by that command. An attended legacy Codex test on
2026-10-07 delivered real Slack choices, accepted an answer, resumed the same
task, and returned a correct reply; other probes and runtimes remain unqualified
until their own provider evidence is recorded.
+73
View File
@@ -0,0 +1,73 @@
# Company decision models
Company Settings → General → **Decision model** configures one shared API connection for optional Paperclip features. New companies remain unconfigured. A connection manager selects or adds an OpenAI or OpenRouter connection, saves, and can run a small billed setup test. Test answers appear only in that session; request metadata and charges appear under Activity → Costs → Decisions.
**Allow company-sponsored background decisions defaults on.** Saving authorizes internal background features to charge the selected connection without a responsible human. An explicit off setting survives later edits, including changing the connection or omitting the field from an update. Deleting the selected connection or grant preserves the setting and blocks calls until a valid replacement is selected. Disabling decisions prevents new calls. It does not cancel or erase calls already dispatched.
V1 supports native OpenAI Decisions (`gpt-6-luna`) and OpenRouter Decisions (`typesafe/jev-1.13`). It accepts company-owned API keys only. Personal grants, subscription credentials, custom endpoints, fallback chains, images, and public agent/plugin execution tools are outside this version. Decision compatibility is separate from CLI-adapter compatibility.
## Internal service
Register a feature once in its server module, then call `decisionModelService(db)` with trusted server context:
```ts
const classifyTask = defineDecisionFeature("tasks.classify");
const service = decisionModelService(db, { budgetHooks });
const context = { companyId, actor: req.actor, feature: classifyTask, issueId };
const available = await service.availability(context);
if (!available.available) return existingBehavior();
const result = await service.decide(context, {
state: { title: task.title },
questions: {
billing: { type: "boolean", instructions: "Is this about billing?" },
team: { type: "choice", instructions: "Which team should handle it?",
criteria: { billing: "Payments", support: "Product support" } },
urgency: { type: "score", instructions: "How urgent is it?",
criteria: ["Routine", "Soon", "Immediately"] },
},
});
```
The setup test is the only product caller in this release. Future callers must supply the existing budget enforcement hooks, handle unavailable/failure outcomes, and select their own thresholds. Boolean answers are probabilities, choice answers select a category, and scores use the ordered criteria's zero-based positions. Available distributions and provider confidence are preserved in returned answers. `succeeded` and `failed` results include an invocation ID and usage with reported, estimated, or unknown cost. Refusals and invalid responses never become invented answers.
`availability` reads local configuration and permissions only. It does not decrypt credentials, contact a provider, or promise reachability/budget admission. `availabilityForRequest()` returns a memoizing function; keep that function and its context object within one request. Dispatch never trusts this cached authorization.
Human calls use the authenticated user and retain the connection's audience checks. Agent calls reread the active persisted run and its current responsible user, check that user's current membership/resource access, and require an applicable agent/company connection installation. Task and project references must belong to that run/company. Attribution snapshots the effective identity at dispatch. Explicit internal system contexts additionally require a feature registered with `{ background: true }` and company sponsorship. A denied user/agent call cannot fall back to system context.
Credentials are resolved through the existing secret service immediately before admission, with the actual user/agent/system audit attribution. Authorization, connection/configuration revisions, current run identity, and budgets are rechecked before dispatch. Requests allow 1–20 named questions, text/JSON state, and at most 32 KB serialized request data. Execution is limited to 30 seconds and SDK paid retries are disabled.
## Accounting and privacy
`decision_invocations` records feature, actor/responsible user, applicable task/project/agent/run and identity revision, connection/grant, provider/model, request ID, timing, status, question types/count, token counters, and a cost-ledger reference. It does not store state, question text, answers, or raw provider payloads. SDK input/output telemetry is disabled and provider errors are reduced to bounded codes. These are local database records, not first-party telemetry events.
Cost events have `usageKind: decision` and may have no agent. Agentless reporting uses **Paperclip services**. The public agent cost-reporting API still requires an agent; only the internal service receipt path accepts agentless charges. Decision events snapshot responsible-user attribution; historical run costs continue to use their existing attribution rules. Decision events have no heartbeat receipt reference, so run receipt reconciliation cannot count them again.
Admission and settlement use the company accounting lock. Company and applicable agent/project budgets include held reservations and observed spend. Reservations use the largest configured applicable reservation amount. Settlement is idempotent. Explicit provider authentication/validation/rate-limit rejection releases the hold when no model work occurred. Uncertain dispatches, timeouts, and unknown prices remain unpriced with the reservation held. Startup/periodic accounting recovery marks stale invocations interrupted without resubmitting them. Use the existing audited cost adjustment workflow to resolve the charge and release its hold, including a verified zero charge when appropriate.
OpenRouter-reported USD costs are converted to fractional cents. OpenAI uses a dedicated, versioned Decisions rate snapshot: $0.10 per million input tokens for the bounded short requests at the native default endpoint. No chat-model price is substituted. Missing usage or an unsupported rate stays unknown; it is never displayed as a free request. The ledger retains seven decimal places in cents. Pricing provenance accompanies the cost event and remains available to accounting corrections.
History follows company cost visibility and rechecks task access before returning task/run links. It shows the latest 100 requests in the selected date window. Input/output contents remain absent after refresh.
## HTTP surface
All paths are under `/api/companies/:companyId/decision-model`:
| Method/path | Access and behavior |
| --- | --- |
| `GET /` | Board; returns manager capability and settings/compatible choices to connection managers |
| `PUT /` | Connection manager; validates and audits configuration |
| `GET /availability` | Authenticated company caller; local authorization check |
| `POST /test` | Connection manager; fixed server-owned three-question sample, ignoring client prompts/identity |
| `GET /history` | Existing company cost-read rules; date bounds and limit up to 500 |
There is no general HTTP decision execution endpoint.
## Provider dependencies and verification
The experimental Vercel contract is hidden behind Paperclip-owned types. Dependencies are pinned to `ai@7.0.130`, `@ai-sdk/openai@4.0.86`, and `@openrouter/ai-sdk-provider@3.1.0`. That OpenRouter release calls its compatible model factory `evaluationModel`; the wrapper adapts it to the current Decisions interface. Upgrade these together and rerun native wire-format tests.
CI owns lockfile updates. Before a direct Docker build of a feature checkout whose dependency manifests changed, refresh the local build context with `pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile`. Do not commit that generated lockfile; the hosted Docker workflow performs the same preparation and the lockfile bot updates master.
References: [OpenAI Decisions](https://developers.openai.com/api/docs/guides/decisions), [Vercel AI SDK](https://ai-sdk.dev/docs/reference/ai-sdk-core/decide), [OpenRouter Decisions](https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-questions-and-answers-request).
Automated coverage lives in the provider contract and database-backed decision service tests. Provider tests use actual pinned SDK adapters with simulated HTTP responses; they do not establish live account/model availability. Storybook's **Decision models** group uses production components and includes settings states, metadata history, and a settings-to-history journey. A live acceptance check must run the fixed setup test with each authorized shared provider connection and inspect the corresponding persisted invocation/charge. Record live and simulated evidence separately.
+311
View File
@@ -0,0 +1,311 @@
# OpenAI Dot runner prototype
The reference prototype is now accompanied by an experimental Rust Runner
provider, dedicated `/mcp/runner` agent connection and durable broker. See
[OpenAI Dot with Paperclip Runner](openai-dot-runner.md) for setup and the
current qualification limits. The transport proof below remains specific to
this reference harness.
Built 2026-10-02 on the public MCP foundation from `codex/paperclip-mcp-experimental-setting`
(commit `a88448f77`), merged into the fresh `codex/dot-events-prototype` worktree.
This is a working **protocol prototype**, not a production-selectable Dot
agent. The default demo's Dot peer and task authority are synthetic. That demo
does not contact OpenAI, spend model credits, or modify an existing company.
The optional live lab below is intended to connect an actual Dot. The new provider
implements the runner's `HarnessDriver` contract and executes through
`HarnessDriverBackend`; it does not use the legacy HTTP adapter.
## Try it
After installing and building workspace dependencies:
```sh
pnpm prototype:dot
```
The command creates a temporary PostgreSQL database, company, user and task;
creates a real OAuth grant through PKCE and consent; starts two loopback HTTP
servers; subscribes to a signed event; and drives a runner assignment to
completion through authenticated MCP. It prints each successful stage and
removes the temporary state. Its synthetic Dot peer deliberately retries a
write concurrently to prove there is only one report. No credentials are
printed. This is a CLI demo, not a mock UI or a connection to your Dot account.
Fresh-worktree preparation (use the repository's pinned pnpm version):
```sh
pnpm install --no-frozen-lockfile
pnpm --filter '@paperclipai/server^...' --filter '!@paperclipai/paperclip-runner' --filter '!@paperclipai/ui' build
```
The local callback uses an injected transport restricted to one fixed synthetic
HTTPS URL and rewrites it to loopback. The production event transport retains
its HTTPS, public-IP DNS pinning and no-redirect checks.
## Connect a real Dot to the disposable lab
The separate live lab uses the real remote webhook transport, with no synthetic
Dot peer or callback override. It still uses synthetic task admission and one
projected `save_report` tool, so it is a transport acceptance test, not production
agent scheduling. It requires an existing Dot account with custom plugins.
Expose **only port 43127** through an HTTPS tunnel, then run:
```sh
cloudflared tunnel --protocol http2 --url http://127.0.0.1:43127 --no-autoupdate
DOT_LAB_ORIGIN=https://YOUR-TUNNEL-HOST pnpm prototype:dot:live
```
Add `https://YOUR-TUNNEL-HOST/mcp/paperclip` as an OAuth MCP plugin in the
account that owns the Dot. The consent page waits for local operator approval.
The lab prints a local `control.json` path; it contains a temporary credential,
must stay local, and is removed when the lab stops. The separate control port
(43128 by default) must never be tunneled. Inspect the pending client and return
origin before approving its exact request ID:
```sh
node server/scripts/dot-runner-live-control.mjs /path/to/control.json status
node server/scripts/dot-runner-live-control.mjs /path/to/control.json approve REQUEST_ID
```
The consent page then returns to ChatGPT. Give Dot the standing instruction
printed by the lab, including the exact company and inbox task IDs. Wait for a
verified `paperclip.dot.work_available` subscription in `status`, then publish
the assignment from Paperclip's side:
```sh
node server/scripts/dot-runner-live-control.mjs /path/to/control.json queue
node server/scripts/dot-runner-live-control.mjs /path/to/control.json status
```
The expected result is a signed event delivery, Dot reading and accepting the
assignment, a report saying `17 + 25 = 42`, and a structured runner completion.
A delivered webhook alone is not a passing test. `status` exposes the report,
runner transcript and delivery outcome only over authenticated loopback.
Keep the lab and tunnel running while ChatGPT loads the newly installed tools;
the plugin directory can list them before Dot can invoke them. A stopped quick
tunnel cannot be reused just by restarting the lab. A new tunnel address needs
a plugin configured for that address and fresh OAuth consent. Restarting the
lab also discards its OAuth client registrations and grants.
Stop the lab with the local control command (`... control.json stop`), then stop
the tunnel after the test. Use this command for reliable cleanup; development
process wrappers may terminate on a signal before asynchronous cleanup finishes.
The lab expires after two hours,
revokes its connection on graceful shutdown, and deletes its temporary database
and encryption key. It never opens the user's existing Paperclip database.
## Two directions, two explicit identities
```mermaid
sequenceDiagram
participant Host as Paperclip host
participant Runner as Dot runner bridge
participant MCP as OAuth MCP + event outbox
participant Dot as OpenAI Dot
Host->>Runner: Admitted task + explicit grant/agent binding
Runner->>MCP: Persist work_available references
MCP->>Dot: Signed webhook wakeup
Dot-->>MCP: Receipt (does not mean work started)
Dot->>MCP: Read inbox and assignment; accept
MCP->>Runner: Authenticated, bound commands
Runner-->>Host: PRP turn.started
Dot->>MCP: Call projected tools / report progress / propose result
MCP->>Runner: Check current authority; deduplicate request ID
Runner-->>Host: PRP progress and structured result
Note over Host: Paperclip decides final task disposition
Dot->>MCP: Personal Paperclip reads/writes while idle
Note over MCP: These retain the consenting person's identity
```
Dot's always-available personal tools come from the merged public MCP connection.
They act as the person who consented. Agent participation is separate: a trusted
host binds an already admitted run to one OAuth grant, company, agent, task,
session and turn. Connecting or selecting a task never grants impersonation.
The registry has no MCP registration tool and no generic API executor.
## What is implemented
- `DotHarnessDriver`: one turn per admitted run, explicit acceptance, bounded
lifetime, projected tools, progress and validated `paperclip.run_result.v1`
completion. Reports normal PRP events consumed by the existing native backend.
- `createDotRunnerMcpBridge`: six tools on the existing authenticated endpoint:
`paperclip_dot_inbox`, `paperclip_dot_read`, `paperclip_dot_accept`,
`paperclip_dot_tool`, `paperclip_dot_progress`, `paperclip_dot_finish`.
Bound tools are visible only to the selected grant with write consent.
- Optional `paperclip.dot.work_available` event on the existing durable outbox.
Its `companyId` and `taskId` identify a designated standing inbox task. Each
assignment can concern a different task; the event carries only IDs, and Dot
retrieves the current assignment through authenticated tools. Existing
authorization, encrypted callback material, signed verification, retry,
expiration, rotation and unsubscribe behavior are reused.
- Matching duplicate commands share one receipt, including simultaneous writes.
A changed retry is rejected. Ambiguous writes remain `unknown` and are not
redispatched. Per-run receipts are memory-only in this prototype.
- Revocation/expiry prevents further accepted commands and ends event waiting.
The live host authority callback runs for each new operation and before
returning tool output. The host's tool dispatcher must enforce atomic domain
permissions, pause, ownership, approvals and budget checks at the actual write.
- Usage and cost remain unknown. Delivery does not fabricate a started turn.
Result acceptance proposes a disposition; it does not directly set issue status.
## Host wiring and the production gap
The detailed implementation proposal is in
[OpenAI Dot as a production Runner provider](plans/2026-10-02-openai-dot-runner-adapter.md).
The default app does **not** construct the bridge or advertise the Dot event.
The host must explicitly pass `enableDotPrototype: true` to
`createPublicMcpEvents`, pass the bridge as the fourth argument to
`publicMcpIngressRoutes`, and register a driver for a trusted admitted run.
The lab demonstrates that composition with synthetic admission and a single
synthetic projected tool. There is no production scheduling integration yet.
Do not enable this by supplying a fake Codex profile to the native execution
factory or by creating a heartbeat row from an MCP request. Production needs:
1. An explicit governed agent binding/consent and a qualified Dot provider in
native admission, with task checkout, budgets, pause and policy checks.
2. Durable provider mailbox and command receipts, reconnect/recovery and
reconciliation of uncertain tool outcomes. The event outbox already persists;
this prototype's runner registry does not. After restart it refuses recovery.
3. Server-owned runtime tool projection/dispatch with ordinary audit receipts.
The lab tool is not the production runner tool authority.
4. A stop policy that can handle remote autonomy honestly. Revoking Paperclip
access cannot prove that Dot or its child tasks stopped. Active close reports
`provider stop is unconfirmed`; interruption, steering and resume are not
advertised. `dot-bridge:<session>` identifies this bridge, not an OpenAI task.
5. A reachable staging deployment, real Dot plugin onboarding, and a live
event-triggered assignment before claiming client compatibility or release
readiness. Hosted use also needs the companion Cloud broker deployed.
An existing in-flight external write can finish after revocation. Do not retry
with a fresh request ID to force a result. Inspect authoritative task state.
Unregister completed runs to release the bounded in-process registry.
## Intended live onboarding
1. Connect the existing Paperclip MCP plugin in developer mode and consent to
the team and requested writes.
2. Explicitly bind that connection to the chosen Dot agent after the production
admission work above. Show the identity and the standing inbox task.
3. Give Dot one standing instruction to subscribe to `paperclip.dot.work_available`
for that inbox, inspect current assignments, accept intended work, use the
projected tools, and finish with a structured result. Treat duplicate events
as wakeups, never as another task. Avoid comment-acknowledgement loops.
4. Assign work in Paperclip and verify the entire loop with the actual account.
OpenAI now documents MCP Events for dots. Private developer-mode testing does
not require a public directory listing; public distribution is a separate review.
See [MCP Events](https://developers.openai.com/plugins/build/mcp-events),
[developer mode](https://developers.openai.com/api/docs/guides/developer-mode),
and [app review](https://developers.openai.com/plugins/deploy/app-review).
## Verification
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/dot/dot-harness-driver.test.ts
pnpm --filter @paperclipai/server exec vitest run src/__tests__/public-mcp.test.ts
pnpm prototype:dot
```
Runner tests cover native PRP validation, cross-company/grant/turn rejection,
unknown outcomes, concurrent retries, expiry, live authority checks, malformed
results, late writes, revocation and unsupported recovery. The MCP suite checks
real OAuth, event signatures, the opt-in catalog, personal-tool coexistence and
revocation. The CLI demo uses real TCP for both directions.
Recorded validation: eight runner tests passed; all 37 MCP tests passed; the
changed Dot scenario passed again after the final uncertainty-handling fix;
runner and server TypeScript checks passed; the loopback CLI demo passed.
Shared/server dependency builds and the runner TypeScript build also passed.
The repository-wide test/build, Rust provider qualification and hosted Cloud
acceptance were not run. Actual Dot acceptance is recorded below. This is not
a PR-ready production provider handoff.
Live-lab setup on 2026-10-02: the isolated server started, its public OAuth
discovery endpoint returned 200, and unauthenticated MCP requests returned 401.
The live script passed an explicit TypeScript check, and the 37 MCP regression
tests passed again after changing the bridge to a structural driver interface.
During that initial setup, no actual Dot connected or subscribed: native desktop automation was unavailable,
and the inspected browser account showed Dot creation rather than an existing
Dot. The temporary tunnel subsequently expired; it and the lab were stopped.
The later attempts below used the correct existing Dot account.
### Actual Dot attempt, 2026-10-02
With the existing Dot open in the correct Chrome profile, the user approved
installing the private `Paperclip Dot Lab` plugin and its lab-only OAuth grant.
The first real callback exposed a lab bug: OAuth consent request IDs are opaque
`pcmcp_request_` tokens, not UUIDs. The live script now validates that format.
Restarting the disposable database also erased ChatGPT's cached public client
registration. The exact public client ID and redirect URI were restored only in
the verified one-company lab database; no grant or token was inserted directly.
The normal consent and PKCE exchange then completed successfully.
Chrome briefly showed `ERR_BLOCKED_BY_CLIENT`; after the user handled/retried the
page, it showed the ordinary stale-client server error instead. The browser
block's originating component was not identified. No browser protection was
disabled. It should not be confused with the separately diagnosed OAuth error.
Observed live results:
- ChatGPT called authenticated `server/discover`, `tools/list`, and `events/list`
using MCP 2026-07-28; all returned HTTP 200.
- The plugin UI showed its connected account, 10 read tools, 6 write tools, and
all four event definitions, including the six Dot runner actions.
- Dot found `paperclip.dot.work_available` and recognized its argument schema.
Initially it reported no Paperclip action tools in its runtime, including the
read-only `paperclip_connection`. An explicit @ mention did not make them
available immediately.
- Dot also reported that its event-subscription route requires a successful
read-only connection check, so the reduced event-only test could not proceed.
- The lab observed **zero `tools/call` or `events/subscribe` requests**, zero
subscriptions, and zero deliveries. No assignment was queued and no report
was saved. This is live discovery/connection evidence, **not** an end-to-end
task or event-wakeup pass.
The first lab and tunnel were stopped too early, revoking the temporary grant
and deleting its database. Dot subsequently reported that the action tools had
become available, but its connection check failed against that offline endpoint.
This does not establish a Dot runtime incompatibility. A fresh lab and tunnel
were connected as `Paperclip Dot Lab Retry`; the new server observed Dot calling
`paperclip_connection` successfully. The first plugin definition remains
installed with its expired endpoint. Production still needs the admission,
durable mailbox, recovery and tool-authority work above.
### Successful real Dot retry, 2026-10-02
The replacement private plugin completed fresh dynamic client registration,
normal OAuth consent and PKCE without manual database repairs. It used the real
Dot in the user's Chrome account and the public HTTPS tunnel. The lab retained
the ordinary callback verification, DNS/IP pinning and webhook signing checks.
Observed server evidence (UTC):
| Time | Observation |
| --- | --- |
| 22:16:30 | Dot called `paperclip_connection` successfully. |
| 22:17:00 | `events/subscribe` succeeded; the verified `paperclip.dot.work_available` subscription was present. |
| 22:17:24 | The operator queued the single assignment. Its webhook was observed as `delivered` after one attempt. |
| 22:17:48 | Dot called `paperclip_dot_inbox` after the event, without another chat prompt. |
| 22:17:56 | Dot read the assignment using `paperclip_dot_read`. |
| 22:18:10 | Dot accepted it; the native Runner emitted `turn.started`. |
| 22:18:23 | Dot called `save_report` through `paperclip_dot_tool`; the lab saved exactly one report: `Dot received the Paperclip event. 17 + 25 = 42.` |
| 22:18:38 | Dot submitted a valid `paperclip.run_result.v1`; the Runner emitted `run.result.proposed`, `turn.completed` and `run.terminal` with state `succeeded`. |
| 22:18:50 | `events/unsubscribe` succeeded; no subscription remained. |
Dot's final chat message confirmed event receipt and completion. It could not
confirm complete unsubscription from its client response, but the server
independently recorded the successful unsubscribe. Removing the subscription
also removed its delivery rows, so delivery was verified before that cleanup.
Dot separately disclosed an accidental cloud desktop app inventory call during
setup; it reported opening no app content and stopping immediately. This test
proves the transport and Runner handshake, not isolation of Dot's other tools.
This is a **real event-triggered, two-way transport acceptance pass** using the
new Runner backend. It still uses synthetic admission, one disposable task and
one projected tool. It does not establish production scheduling, recovery,
budget enforcement, arbitrary personal API operations, or reliable remote stop.
The replacement lab was left running for inspection under its two-hour expiry;
the test subscription was removed and no further assignment was queued.
+6
View File
@@ -1,5 +1,11 @@
# Paperclip evaluation guide
The [Slack connector probe catalog](../server/src/services/connectors/slack/evals/README.md)
organizes eleven manual model acceptance probes and a selector for existing
deterministic regressions (`pnpm test:slack-connector`). It is not a registered
model campaign; transport fixtures do not prove that an agent chooses a tool
or that a real Slack interaction completes.
The explicit-only [live provider connection suite](../tests/runner-e2e/PROVIDER-CONNECTIONS.md)
is a Product E2E workflow for fresh subscription/API-key/gateway connections,
with attended login and independent artifact checks against local or staging targets.
+66
View File
@@ -211,6 +211,22 @@ New comments received during an execution hold retain their individual deferred
The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted.
An unstarted legacy conversation retry waits on its existing `scheduled_retry`
row while a previous execution still owns a process or environment lease. Each
sweep moves the next check forward without creating another run or incrementing
retry accounting. The wait survives a controller restart. A queued retry that
encounters the same ownership hold returns to that scheduled state. Once
ownership is released, normal promotion and dispatch gates still apply,
including reassignment, cancellation, budget limits, and no-replay holds.
The **Retry now** action returns `waiting` with the saved retry schedule while
cleanup holds ownership. Its controls show the wait without claiming that a run
started, and another click can check the gate again.
Recovery instructions in a wake describe only an active or escalated recovery
action. Resolving or cancelling that action removes its repair instructions from
subsequent wakes, so the original worker can resume the task. This prompt change
does not clear any persisted execution reconciliation hold.
Workspace contention (`workspace_busy`) displays **Waiting for workspace** and
continues automatically when the workspace is available. Internal scheduling
attempts remain in the run log without conversation cancellation markers,
@@ -501,6 +517,24 @@ Warm sandbox reuse must match the current host Git tip and branch as well as the
file snapshot and saved stamp, including managed nested repositories. A history
or branch mismatch restages the host before the next run begins.
### Remote Codex model compatibility
Fresh remote Codex Runner runs check the selected image CLI before saving the
native execution input. If the CLI is in the supported app-server version window
but below the selected model's verified minimum, preparation chooses the newest
compatible older model of the same class (`sol`, `luna`, `astra`, or `terra`),
then the stable Runner default. Each candidate is considered once; this selection
does not replay a provider turn or consume a failure-retry attempt.
The effective model is saved in the execution input before checkpoint selection,
so launch, recovery, and usage accounting share that identity. The requested
agent and task settings stay unchanged. A task warning and `runner.model_fallback`
run-log event name both models and the image CLI version. An explicit remote
Codex artifact or npm install pin retains precedence. Persisted executions are
not rewritten, and unknown models, invalid CLI builds, and artifact failures keep
their existing verification and recovery rules. Capacity and authentication
failures do not trigger this startup substitution.
### Native provider model capacity
A committed Codex `turn.failed` event with `codexErrorInfo: serverOverloaded`,
@@ -656,6 +690,21 @@ An issue monitor is a one-shot deferred action path for agent-owned issues in `i
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
Native runners in standard execution use `set_task_monitor({ taskId?, idempotencyKey, monitor })`. Omit `taskId` for the current task. An explicit target must be accessible, in the same company, assigned to the caller with no human assignee, and `in_progress` or `in_review`. Runtime-management permission still applies. Ask, planning, and review-only runs cannot use this tool. Generic `call_api` lifecycle restrictions remain in force.
To wait for a check:
1. Set a future `monitor.nextCheckAt` and short `monitor.notes` describing the check, with optional service context and bounds.
2. Confirm the receipt's persisted task ID, monitor state, next-check time, and bounds.
3. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and `continuation: { kind: "monitor", summary, idempotencyKey }`. Report outstanding work honestly; the scheduled check may still block completion.
4. End the run. Paperclip keeps the task active, releases execution ownership, and the one-shot scheduler later wakes it with `issue_monitor_due`. This does not enqueue an immediate continuation. On resume, `get_task_context.activeTask.monitor` includes the consumed monitor’s notes and attempt count.
Only a valid persisted monitor on the current task authorizes that finish; scheduling another owned task does not. Authority is checked again under the final disposition lock. A timer that becomes due during an active native execution remains scheduled until execution releases it. Dispatch checks the current schedule, claim, status, and assignee so an older dispatch cannot clear a replacement monitor.
The service name `AI provider quota` is reserved for server-owned recovery of legacy runs. Native task monitors reject it; use ordinary service context and notes when scheduling a provider-usage check.
Use a new idempotency key to replace the schedule or clear it with `monitor: null`. Retrying the original call returns current monitor state without re-arming a consumed, replaced, or cleared timer. Monitor changes, audit activity, and mutation receipts are committed together, and unrelated execution/review policy is preserved. Legacy agents continue to use the issue API.
Monitor policy lives under `executionPolicy.monitor` and includes:
- `nextCheckAt`: when Paperclip should wake the assignee
@@ -1351,6 +1400,23 @@ controller must recover it through the authenticated ownership checks. This
preserves active work and queued messages without treating a server restart as
user cancellation.
When a native turn creates a governed approval wait, its checkpoint retains the
exact committed triggering event and yielded disposition before interrupting the
provider. Shutdown gives these turns a shared, bounded 20-second window to finish
settling their usage and terminal evidence before detaching. Recovery verifies
the trigger against the retained event ledger and settles that same turn, even
if the user has already answered the interaction. It replays retained accounting
through the ordinary control-plane observers without starting another model turn.
When a later heartbeat run reuses the provider session, it clears that prior
run's wait marker along with its terminal, result and pending-request authority.
Already-numbered cancellation acknowledgements and cancelled/expired runtime requests
are retained during shutdown so recovery can replay a contiguous journal. They
carry no permission to start work. Stream closure, stream failure or drain timeout
after complete usage leaves the saved wait unfinalized until a provider terminal
is retained; complete token counters alone cannot certify a stopped provider.
A timeout, missing accounting, or an unproven provider terminal remains a failure;
the saved wait does not certify successful provider execution.
Before either shutdown path exits, idle warm sessions close through their
normal suspend-and-checkpoint path. Remote sessions therefore leave verified
backup authority for the next controller even though their last run is already
+43
View File
@@ -0,0 +1,43 @@
# HTML artifact previews
HTML attachments and workspace `.html`/`.htm` files open rendered by default.
The eye and code icons beside Download switch between the report and its original
source. Download always returns the original file. Empty and oversized attachments
use the existing text-viewer states and limits.
The shared `HtmlArtifactPreview` uses `srcdoc` in an iframe with
`sandbox="allow-scripts"`. **Never add `allow-same-origin`.** Its opaque origin
prevents artifact JavaScript from reading Paperclip cookies, storage, or the parent
DOM. Forms, popups, downloads from inside the frame, and top navigation are not
granted. The host does not accept messages or privileged requests from artifacts.
A trusted CSP is installed before any artifact markup. It permits inline scripts
and styles for self-contained tables, charts, filtering, and other report controls.
It blocks API connections, remote scripts/styles/images/fonts/media, child frames,
objects, base URLs, and form submissions. Embedded data images/fonts/media work.
An artifact's own CSP cannot weaken the first policy. No artifact HTML is parsed
or inserted into the host DOM. The iframe sends no referrer.
This supports self-contained reports. Reports that need CDN libraries or external
resources must embed them instead. Ordinary external links cannot open new tabs
or navigate the host. The sandbox does allow navigation within the artifact frame;
its opaque-origin restrictions remain after navigation. This is not a claim of
complete network isolation or protection against resource exhaustion by scripts.
Attachment HTTP serving retains forced download, `nosniff`, and its existing
sandbox CSP. Workspace previews return bounded UTF-8 inside JSON, and workspace
downloads retain `Content-Disposition: attachment`. No database or API shape changes
are needed.
Review **HTML artifacts** in Storybook for the production viewer, security probes,
workspace panel/file sheet, and a task-to-report journey. Run the browser security
checks with:
```sh
pnpm exec playwright test --config tests/html-preview/playwright.config.ts
```
These checks use Chromium and simulated report data. They verify working report
controls, raw mode, cookie/storage/parent access denials, blocked resource and API
requests, and denied top navigation/popups. They do not replace testing the actual
attachment journey in a running Paperclip instance.
+98
View File
@@ -0,0 +1,98 @@
# Automatic idle sleep safety
`GET /api/instance/task-drain?idleSleepSafety=1&ownerId=<owner>` adds an
instance-wide `idleSleepSafety` report for instance administrators and signed
Cloud control callers. Ordinary task drains keep their existing behavior.
Company members and agents cannot read this report.
## Hosting protocol
1. Hold external admission at the hosting layer. Queue user requests for wake or
abort sleep when activity arrives. Do not send traffic into an instance that
is being stopped.
2. Send `POST /api/instance/task-drain` with
`{ "purpose": "idle", "ttlMs": 120000 }`. Idle holds require a TTL from 5 to
300 seconds. Save the returned `ownerId` and `expiresAt`. An existing drain
returns 409. An unsupported purpose must leave automatic sleep disabled.
3. Poll the report with that exact owner. Only
`{ "version": 1, "backgroundWork": "none" }` permits sleep. Require the same
`ownerId`, `draining: true`, zero run/wake counters, and enough remaining TTL
for the provider stop. `present`, `unknown`, missing fields, or errors must
keep the instance awake. Do not interpret `quiescent` alone as authorization.
4. Revalidate the same hold immediately before stopping the instance. Keep
external admission closed through the provider stop. Never use an old report
after a restart, expired hold, changed owner, or failed validation.
5. If sleep is abandoned, send `DELETE /api/instance/task-drain?ownerId=<owner>`.
A missing or stale owner cannot release another controller's idle hold. The
TTL clears an abandoned hold if the controller loses its response.
An idle hold rejects new tenant HTTP requests and WebSocket upgrades with 503
and `Retry-After: 1`. Health checks and authenticated task-drain control remain
available. Ordinary deploy drains do not enable this HTTP gate. The host must
handle retry/wake at its ingress before enabling automatic sleep.
Health and control mutations remain tracked through completion, including
signed bootstrap writes. Only the task-drain read skips request tracking.
Its authentication finishes under a separate work token before entering the
report, so it counts concurrent writes without blocking on its own scan.
## Accepted work and cleanup
HTTP admission is installed before body parsing, authentication, webhooks and
MCP ingress. The Express adapter tracks handler promises in nested routers and
error middleware. Sending a response or losing the client does not complete an
async handler. A callback-only handler that abandons its response without ending it remains a
sleep blocker. An async route that settles after a disconnect releases its
request token; normal cancellation does not permanently block sleep. New routes must be registered before this adapter is installed.
Fire-and-forget work must have durable queue state or use `trackIdleWork`; a
response is never a substitute for tracking that work.
Accepted live-event WebSocket authentication remains counted until its promise
settles, including after the raw socket closes. Detached startup reconciliation
for built-in agents, managed homes and persisted runtime services also remains
counted after readiness until its writes settle.
Scheduler work already in flight remains counted until its promise settles.
Idle holds pause new scheduler admissions. Database backup promises remain
counted through success or failure. Configured periodic backups also block
sleep until a host owns a durable wake schedule for them. A generation counter invalidates a
scan if tracked work both starts and finishes during inspection. Reports remain
unknown until startup recovery and HTTP tracking are installed.
The report inspects accounting and sandbox-cleanup spools directly. Any entry,
including malformed JSON, temporary files and failed write probes, blocks sleep.
Read errors and invalid spool paths return unknown. Recovery readers that skip
bad records cannot certify an empty spool.
An orphan cleanup token starts before its spool write and remains held through
buffering, queue splices, flush retries and buffer overflow. It ends only after
the database owns cleanup. Queued usage receipts stay counted until capture
settles. If receipt capture and the database failure fence both fail, a token
continues to block sleep until the failure fence is durably written. A query
that updates zero rows does not clear that debt. Unrecoverable cleanup requires
operator reconciliation; this API does not discard it to save compute.
## Persisted work and limits
The report checks all companies in one bounded read-only database transaction.
Queued and orphaned runs, timers, retries, accounting debt, unfinished issues,
cleanup, active routines, integrations, external API credentials, and plugin
work block sleep. Completed ordinary run history does not. Less common work
sources conservatively block on any retained records. Active issue watchdogs
block sleep even when their watched issue is complete and no review has started.
Missing migrations, malformed configuration and database errors produce unknown
without exposing SQL or tenant data.
Every enabled plugin blocks sleep. A version label does not prove that arbitrary
worker code has no background activity. No plugin approval or configuration
bypass is supported. Scheduled and externally triggered work needs a durable
host wake mechanism before this restriction can be relaxed.
This is a single-process admission protocol. A restart clears the owner. The
host must serialize sleep with deploy/wake operations and bind its final check
to the instance it stops. An unexpected restart after the final check can still
race an external provider stop; a process-local report cannot fence that remote
operation by itself. Multiple replicas require shared fencing. The protocol
does not make existing in-memory-only debt survive an unplanned process crash.
Related public PR #13413 adds runtime-service controller requirements and uses
the same `purpose: "idle"` / `ownerId` vocabulary. This report conservatively
blocks all retained runtime-service state instead of approving such services.
+100 -3
View File
@@ -269,9 +269,9 @@ environment variables: `SENTRY_DSN_FRONTEND` for the browser and
`SENTRY_DSN_BACKEND` for the server. Each variable is optional. A
specific variable always wins for its own component; a legacy variable,
`SENTRY_DSN`, supplies a component that has no specific value set. An
empty string counts as absent for all three variables. The feature uses
built-in Sentry options only. It adds no `beforeSend` hook and no custom
filter code.
empty string counts as absent for all three variables. SDK configuration uses
built-in Sentry options only. It adds no `beforeSend` hook. The run-failure
reporter selects reportable outcomes before calling the SDK, as described below.
The server is inactive when the backend DSN resolves to `null`; then it
imports no Sentry package. The browser is inactive when the front-end DSN
@@ -434,6 +434,60 @@ They do not change the ambient Sentry scope, whose isolation is unavailable
without an OpenTelemetry context manager. Later, unrelated exceptions must
not inherit a previous run's identity or fingerprint.
Known missing-secret configuration blockers are kept in the task's run log,
blocked state, and owner recovery action, without a Sentry run-failure event.
This requires a failed `configuration_incomplete` run in the preparing stage,
a setup-phase report, explicit proof that provider work did not start, and a
nonempty list of recognized missing or inactive secret bindings. Process exit
evidence, unknown binding reasons, secret-provider failures, ambiguous missing
secret-definition lookups remain reportable. This filter does not change task
recovery, credentials, or execution policy.
A workspace policy conflict also stays local when the resolver proves that an
explicit `local_path` or `non_git_path` project workspace has no repository URL,
Git confirms the selected directory is not a repository, and the task requests
a Git worktree. The run must fail during setup before provider work starts.
Its existing validation error, blocked task and board recovery action remain.
Local Git command failures, permissions, corrupt repositories, fallback paths,
and generic missing or unrestorable workspaces remain reportable. The error text
or `workspace_validation_failed` code alone never suppresses them.
Recognized external connection failures also stay in the local run result instead
of producing application-error events in Sentry. Their producer records a closed
`connectionFailure` descriptor: schema version, provider, operation, and reason.
It contains no URL, host, path, credential, or remote response. This classification
does not change the run's failed state, task recovery action, retry policy, or
whether remote work may have been accepted.
- Managed remote Git clones classify known authentication, repository access,
invalid URL, DNS (including machine-specific SSH aliases), connection, TLS,
rate-limit, and upstream failures from the actual clone subprocess. Setup must
fail before provider work starts. Every failed workspace candidate must have
proven connection evidence; a mixed or unknown failure remains reportable.
Local/file clones, credential-provider exceptions, local checkout or copy
failures, process signals, and repository corruption remain reportable. Remote
fetch failures already return workspace warnings; their classification is not
transferred to a later failure resolving a local base ref.
- Hermes gateway configuration validation and the initial create-run request use
exact config, HTTP, or structured transport evidence. Missing configuration,
rejected credentials, unavailable endpoints, rate limits, and upstream outages
remain visible to the run owner. Unknown protocol/parser errors, missing run
identifiers, untyped terminal failures, and timeouts after accepted work remain
reportable. Stream/poll errors retain their existing retry and logging behavior.
Expected pre-provider waits for a shared project workspace, a credential rotation
lock, or an exhausted AI connection pool carry a server-owned expected
cancellation receipt. They retain existing retry and lock behavior without a
Sentry cancellation event. A later failed retry still reports its actual error;
the wait code alone is not sufficient to suppress an event.
MCP tool discovery records recognized outbound HTTP/transport failures through
server-only provenance. These failures preserve the response status, stored
connection health, audit record, local context, and first-party telemetry while
skipping only Sentry. A proven missing OAuth scope returns an actionable 422
response with its reconnect guidance. Unknown responses, parsing errors, local
callbacks, database errors, and unproven network-looking messages still report.
The `run_failure` context also includes the recorded process `exitCode` and
`signal`, so a generic adapter error can still distinguish a nonzero exit from
a signal termination. Exit codes must fit the database's signed 32-bit integer;
@@ -515,6 +569,19 @@ and native runs:
- `run_exception_0` through `run_exception_3`: exception names, codes, HTTP
statuses, and request IDs for a caught exception and up to three causes.
The orphan reaper records a bounded `processLossDiagnostic` before status writes
or cleanup. For `process_lost` failures, `run_execution` includes
`processLossPidRecorded`, `processLossGroupRecorded`, and `processLossLocalCheck`
(`not_observed_alive`, `not_checked`, or `no_identifiers`). It also includes
`processLossRunPredatesObserver`, `processLossObserverUptimeMs`, and
`processLossLastOutputAgeMs` when the timestamps are available and valid. Ages
above seven days are omitted. These are observations, not proof of an OOM,
provider failure, or deployment. The last-output age can include system output.
`processLossRetryEligible` records eligibility for the existing process-loss retry
at detection; it does not claim that a retry was queued or succeeded. This data
contains no process IDs, task text, paths, or credentials and changes no recovery
or ownership decisions.
ACP turns record `acpLastEventAgeMs`, `acpObservedEventCount`,
`acpPendingToolCount`, and `acpToolInventoryComplete` at finalization, before
usage reads, error logging, and cleanup. The age measures time since the last
@@ -541,6 +608,29 @@ or `restore_failed`. Unknown values are omitted. Workspace paths and arbitrary
pre-restore result data are not included. A later successful run does not, by
itself, establish that an earlier failed restore recovered the workspace files.
For `workspace_validation_failed` with `git_worktree_not_reusable`, `run_execution`
includes `workspaceValidationReason` and an allowlisted `workspaceValidationReasonCode`:
`missing_worktree`, `not_a_git_checkout`, `not_registered`, `wrong_repository_root`,
`branch_mismatch`, or `git_inspection_failed`. The last code distinguishes an
unsuccessful or truncated Git registration inspection from confirmed absence in
a complete list. Its optional `workspaceValidationInspection*` fields identify
the fixed command `worktree_list`, failure (`spawn_failed`, `nonzero_exit`, or
`output_truncated`), an allowlisted OS error code (or `unknown`), and an exit code
from 1 through 255. No paths, repository or branch names, IDs, command output, or
arbitrary messages enter these fields. Validation still blocks reuse; it does
not repair Git metadata, change the selected repository, or retry the task.
For explicit reuse of a retained Git workspace, `persisted_workspace_source_conflict`
uses the same two fields with the fixed reason codes `source_scope_mismatch`,
`explicit_project_workspace_conflict`, `source_path_unproven`,
`source_registration_unproven`, `source_repository_mismatch`, or
`source_repository_unavailable`. These distinguish an unverified original source
from a missing Git registration. Paths, repository URLs, and workspace IDs stay
out of the diagnostic fields. The task remains blocked until its original source
is available or the owner intentionally selects a different workspace. A failed
Git registration probe retains `git_inspection_failed` and the same bounded
inspection fields, including when it occurs during original-source selection.
When available, the saved `workspaceRestoreDiagnostic` adds the bounded fields
`workspaceRestorePhase`, `workspaceRestoreStep`, `workspaceRestoreErrorCode`,
`workspaceRestoreHttpStatus`, and `workspaceRestoreExitCode` to `run_execution`.
@@ -554,6 +644,13 @@ integers from 1 through 255. These fields accompany a known restore failure code
only. They omit error messages, raw command lines, paths, process output, and arbitrary
cause data. Git error wrappers preserve only these safe codes and numbers for
diagnostics, without adding the original error as a cause.
Native sandbox `environmentSyncOut` failures carry the same allowlisted codes
and bounded HTTP/exit statuses across the plugin worker RPC boundary. The host
revalidates that optional envelope and retains it only for restore diagnostics.
The envelope excludes provider messages, response bodies, names, paths, and
credentials. The existing RPC error message and code remain unchanged, as do
restore classification, retries, and source retention. Older workers without the
envelope still report `unknown` when no structured cause is available.
For `git_integration`, optional `workspaceRestoreGitCommand` identifies the fixed
command family: `rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`,
`commit_tree`, `update_ref`, or `log`. `workspaceRestoreGitFailureKind` is
+389
View File
@@ -0,0 +1,389 @@
# OpenAI Dot with Paperclip Runner
OpenAI Dot is an experimental provider of `paperclip_runner`. A dedicated
agent OAuth connection and MCP Events wake an existing Dot. The Rust Runner
owns the assignment lifecycle and durable tool receipts. Dot uses Paperclip's
existing agent permissions and task tools, including document writes and
completion feedback.
This first version supports a self-hosted instance with a local Runner
controller and a stable public HTTPS origin. Hosted agent-broker and remote
controller deployments are not qualified. The feature is off by default.
## Enable and pair
1. Configure `PAPERCLIP_PUBLIC_URL` to the instance's stable HTTPS origin and
enable **Assistant connections (MCP)** and **OpenAI Dot**
in **Instance settings → Experimental**. The instance must use authenticated
sign-in. These settings are saved and take effect without a restart; the old
`PAPERCLIP_ENABLE_OPENAI_DOT` environment flag no longer enables the provider.
2. In **Add agent**, choose the standalone **OpenAI Dot** option.
Acknowledge that its provider billing is external and unmetered, then save it.
Approve the agent if company policy requires it. Dot uses the shared Runner
internally; its own experimental toggle does not require enabling the general
**Paperclip Runner** option and does not enable other Runner providers.
3. In the agent configuration, choose **Pair Dot**, then **Set up with Dot**.
The shared setup-prompt component copies the MCP URL, one-use pairing code,
expiry, and event instructions. Paste the prompt into your Dot so it can add
or reuse the private plugin and finish OAuth in its own browser. Dot enters
the one-use code on the Paperclip connection page and chooses
**Connect Dot with pairing code**. Entering a valid code first shows the
exact company, agent, permissions, and ongoing revocable access duration;
previewing consumes no code and creates no grant. No operator sign-in in Dot’s browser is
needed. The personal `/mcp/paperclip` connection
cannot execute as Dot.
4. The OAuth code approval pairs Dot atomically. Attach the connected plugin
to the Dot conversation if ChatGPT has not exposed its action tools. Dot calls
`paperclip_dot_inbox` to verify access, then subscribes to
`paperclip.dot.mailbox_updated` with the setup company and inbox binding IDs.
Its callback must pass the signed webhook verification. The prompt stays
only in page memory and disappears when its code expires; if you leave the
page or the code expires, revoke the pending connection and pair again.
5. Choose **Test event delivery**. Dot drains `paperclip_dot_inbox` and confirms
the readiness challenge. Readiness requires this round trip, not just an
HTTP acknowledgement from the callback.
6. Assign a task to the agent. Normal scheduling, checkout, company access,
budgets and approval rules still determine admission.
Only the operator's one-use pairing code is displayed. OAuth tokens and callback
signing secrets stay on the server and never enter the Runner descriptor,
task prompt or saved adapter config. Pairing codes expire after 15 minutes.
The dedicated connection uses the merged MCP gateway's PKCE browser and device
flows, including verified client metadata documents and organization hints.
Its issuer is `<origin>/mcp/runner/oauth`; the personal issuer remains `<origin>`.
The shared consent page identifies Dot agent access. A code issued by an
operator preapproves only its selected company and agent; redemption rechecks
current operator membership, agent availability, all experimental prerequisites,
expiry, revocation, and the authorization request’s organization hint. Redemption
consumes the code and attaches the grant atomically; it creates no board session.
Manual browser and device consent still require an operator role. Device codes and browser requests stay bound to their original resource
and organization. The gateway's Connections invitations remain personal
assistant invitations; use the agent's Dot connection panel for Runner pairing.
Database migration `0317_messy_famine.sql` adds only Dot tables and extensions
after the merged gateway migrations. It is safe to reapply. The earlier
prototype migration number is retired; published master migrations are intact.
Company package imports use the same independent Dot option. An unpaired imported
Dot can be saved after the external billing acknowledgement; pair it before
assigning work. The dev launcher also prepares the shared Runner binary when
Dot is enabled, even when the general Runner option is off.
## Troubleshoot plugin OAuth discovery
Use the exact server URL `<origin>/mcp/runner` with OAuth authentication. The
resource metadata lives at `/.well-known/oauth-protected-resource/mcp/runner`;
its authorization server is `<origin>/mcp/runner/oauth`, with discovery at
`/.well-known/oauth-authorization-server/mcp/runner/oauth`. Both metadata URLs
must return JSON without a login or redirect. An unauthenticated MCP request
returns `401` with a `WWW-Authenticate` header pointing to the resource metadata.
The server advertises both CIMD and Dynamic Client Registration; use CIMD when
ChatGPT discovers it. A manually entered client secret is not required.
ChatGPT’s CIMD prefers `private_key_jwt` but explicitly lists `none` among its
supported methods. Paperclip selects the public PKCE method it advertises and
rejects clients whose published capabilities do not include that method.
The browser authorization page must also be reachable from Dot’s computer.
Discovery and browser reachability are separate checks: a temporary tunnel may
work for ChatGPT’s MCP service while Dot’s browser blocks its hostname. If the
same instance already has another trusted public HTTPS browser ingress, set
`PAPERCLIP_MCP_AUTHORIZATION_ORIGIN` to that origin and allow its hostname in the
instance configuration. The advertised authorization endpoint, consent pages,
and device verification pages use that origin. The MCP resource, issuer, token
endpoint and PKCE validation remain on `PAPERCLIP_PUBLIC_URL`. Consent mutations
accept only these explicitly configured origins; this does not allow arbitrary
return destinations or expose another instance.
If ChatGPT times out, compare its retry with the server access log. No incoming
MCP or discovery request means that attempt did not reach Paperclip; entering
OAuth URLs manually does not repair that connection. Verify public reachability
from outside the tailnet: MagicDNS on the host can route a Tailscale URL directly
to the private tailnet address even when the same URL uses Funnel externally.
Check the public ingress separately before concluding that public access works.
The plugin builder's timeout alone does not establish a blocked port or a
missing discovery document. See [OpenAI's authentication guidance](https://developers.openai.com/plugins/build/auth).
## Assignment protocol
Events contain mailbox references. Dot drains the inbox after its saved cursor,
reads the assignment, and explicitly accepts it before executing tools. A
webhook `2xx` does not mean Dot accepted or started the task. Duplicate or
out-of-order events must not create another assignment.
Dot invokes catalogued tools through `paperclip_dot_tool`. Every operation uses
a UUID request ID. A pending response is reconciled with
`paperclip_dot_operation_status` or retried with **the same ID and arguments**.
Changing arguments under the same ID is rejected. An unknown write must never
be retried under a new ID. Idle intake returns its assignment when available.
If admission is still pending, retry `paperclip_dot_request_turn` with the same
ID and exact prompt to inspect the same intake; no second task or human nudge
is required. Events also notify work queued behind another active assignment.
Dot calls `paperclip_finish` or `paperclip_block` through the tool bridge, then
ends the external turn with `paperclip_dot_finish` using exactly the accepted
structured report. Paperclip's ordinary result and status finalizers decide
the task disposition. Dot can also discover its assigned tasks and request
normal admission with `paperclip_dot_tasks` and `paperclip_dot_request_work`.
The completion tool returns its canonical accepted report. The final turn can
repeat either that canonical report or the exact original accepted arguments;
the Runner persists and emits the canonical report. Changed reports are rejected.
Work requests use a durable admission receipt keyed by binding, generation and
request ID. Retries read that receipt, even after a task finishes, and cannot
enqueue another run or move the same request to another task. A Dot runs one
assignment at a time; competing tasks stay queued regardless of the agent's
configured concurrency. During setup the connection panel continues checking
for a verified event subscription without requiring a manual refresh.
Mailbox writers and cursor reads serialize through the binding row so a cursor
cannot skip a tool result whose transaction commits later. A paused Dot can
receive fence notices and acknowledge them, but cannot read tasks or execute
tools; those narrow inbox reads leave its task cursor unchanged.
## Limits and recovery
- One active assignment per binding. Acceptance expires after 10 minutes;
execution authority starts with a two-hour lease. Accepted, still-authorized assignments can renew with `paperclip_dot_renew` for up to two hours ahead, within a 24-hour total bound. Fifteen minutes without useful
activity is shown in the connection panel as requiring attention.
- No mounted workspace, selectable model, native Dot thread identifier,
provider usage or provider cost. Text deliverables use Paperclip documents.
Assigned skills are read from verified pinned bundles; app tools use the assigned MCP gateway. The optional workspace bridge provides files, sandboxed commands, and verified downloadable artifacts without mounting files into OpenAI. Workspace calls serialize within the local controller, including hash checks and writes, so overlapping edits cannot both commit against the same observed hash. Definite file/skill read failures return bounded error receipts rather than leaving tool calls pending. Commands require Linux bubblewrap with a private PID namespace; there is no unrestricted fallback. macOS file tools remain available, but commands are disabled because sandbox-exec cannot contain detached descendants. Networking and injected credentials are excluded from commands; use assigned app tools for services.
- Cancel, pause, reassignment and revocation fence Paperclip authority. They do
not confirm that Dot stopped all external activity. A fence acknowledgement
records receipt only.
- Controller recovery restores the same bridge and assignment. A missing or
invalid advertised checkpoint requires reconciliation. Automatic bounded
retries do not create replacement Dot assignments. A tool effect still in
flight when its controller detaches stays pending if its exact outcome was
not durably recorded; recovery does not execute that write again.
- Keep the public origin stable. Subscriptions expire and must be renewed;
reconnection drains current mailbox references rather than claiming event
replay. Disabling new Dot work does not grant old assignments new authority.
Resubscribing verifies the Dot callback again and publishes a fresh mailbox
reference for its existing outstanding assignment. It does not create another
assignment or replay tool effects. OAuth revocation and refresh-token replay
also revoke the matching binding, fence its assignments, and cancel waiting
Paperclip runs. Revoking an old grant cannot revoke a replacement binding.
Reopening an agent form restores the binding reference from the server so a
previously completed pairing remains visible. Completing pairing also saves the exact binding reference and a configuration revision atomically; revocation clears only that matching reference. A separate form save is unnecessary.
## Verification evidence
`server/src/__tests__/dot-runner.test.ts` uses an isolated PostgreSQL database,
real Rust Runner, dedicated PKCE OAuth, a signed synthetic callback, normal
semantic authority, a document write and the ordinary status finalizer. It
checks duplicate writes, changed-argument rejection, membership loss and
revocation. Runner tests cover bridge reattachment, missing checkpoints,
closed launch fields, expiry and late effect receipts after fencing.
The earlier real-Dot account experiment proved OAuth and signed wake/report
transport through the reference harness; see `dot-runner-prototype.md`. That
proof does not qualify this new dedicated endpoint against a real account.
The dedicated adapter was subsequently qualified against a real Dot account
on 2026-10-07, as recorded below.
On 2026-10-06, a hands-on browser test used a fresh authenticated instance with
synthetic account, company, and agent data through its public HTTPS origin.
It verified the experimental prerequisites, saved toggle behavior, Dot harness,
dedicated MCP URL, one-use pairing instructions, and revocation. The instance
was created with `worktree init --empty`, so it copied no private instance data
or signing keys. At that point, real Dot pairing, event delivery, and task
completion had not yet been qualified.
On 2026-10-07, the real Dot account completed the dedicated endpoint acceptance
in that empty synthetic test-drive. Dot created the private ChatGPT plugin and
reviewed the exact agent grant. The human approved the connection in Codex;
Codex submitted that actual consent through the existing plugin Connect flow.
Dot could not verify an agent relay of human approval, so this did not rely on
impersonating the human or overriding Dot's policy. Codex attached the connected
app to the Dot conversation when its tools were not initially exposed. No
human operated Dot's computer. Dot verified its inbox and enabled its signed
MCP Events subscription. A Paperclip readiness challenge woke Dot without a
manual chat message and was confirmed successfully.
The first task exposed a missing Dot descriptor in ordinary heartbeat admission.
Admission now inspects the dedicated bridge's actual capabilities without
starting a broker or provider process; it does not route Dot through the
Codex-compatible JSON-RPC facade. A normal UI retry then completed **DOT-1**:
mailbox item 2 woke Dot, which accepted the assignment, used the projected tool
catalog to write and read back `dot-runner-acceptance`, and submitted accepted
structured completion. The document contains the exact synthetic nonce and
has one revision authored by the Dot agent. The ordinary native finalizer
committed **Done**, with a succeeded run, exit code 0, no remaining work, and
no verification caveats. Provider usage and cost remained null.
The expanded catalog was also exercised by the real Dot. It created a task
assigned to the verified human owner, read a pinned skill, wrote an attributed
cross-task comment and task document, ran a sandboxed command in the earlier macOS prototype, incorporated a
follow-up comment, renewed its lease, and registered a downloadable report.
The report's downloaded bytes and SHA-256 matched its receipt. An initially
missing fixture exposed a read exception that left the operation pending and
prevented turn closure. The test operator cancelled that run; Dot acknowledged
its fence without repeating its work.
After the fix, Dot initiated a new intake with `paperclip_dot_request_turn`,
created **hello from idle** for the human owner, observed a terminal
`runner_bridge_file_not_found` error, and continued successfully. It wrote,
read, executed, and registered the 20-byte `idle-runner-verified` proof. Its
ordinary run succeeded with exit code 0 and the finalizer committed **Done**.
The offer needed a direct inbox check because no automation wake was observed
in that conversation. Intake now returns an available assignment and instructs
same-request polling while admission is pending. The earlier event-only
readiness and assignment proof remains valid; webhook delivery alone never
establishes that the external Dot has started work.
Assigned MCP gateway relay is exercised through real Rust and authenticated
Runner authority with a synthetic gateway. No live third-party app account
call is claimed. Inbound task file contents are available through an explicit attachment-reading grant; the server does not automatically stage or send files.
Inspect these persisted synthetic acceptance records in the test-drive:
- Task: `a3ecdff5-41b0-400a-8146-51317c0b864e` (DOT-1).
- Run: `9a44feeb-2779-4217-8210-092852019511`.
- Assignment: `fbee1530-5a20-41d0-8f8f-226ef45f8a58`.
- Document: `3f094689-6346-4a9c-8138-7c08df5da6b1`, revision
`033be018-f6f6-4b6b-a10f-e892627e12e3`.
After the admission fix, 34 Runner/driver tests and 13 real Rust/broker integration
tests passed. Full workspace typecheck and build passed. The earlier full
repository test attempt remains incomplete after unrelated failures and a
workspace snapshot stress timeout. This live qualification covers the current
local-controller endpoint; it does not establish a green full suite, hosted
broker qualification, or a permanent public ingress. The temporary tunnel must
be replaced with a stable origin for continued use.
The current integration includes master through `a6306ba60`, including the
assistant configuration and tool expansion in #15380. Dot still uses its own
agent resource and cannot receive personal configuration permission. Full
workspace typecheck and build, UI token gates, and 198 focused tests passed
after this merge. The empty-worktree tests and clone-migration regression also
passed. These checks do not replace the full repository CI and review gates.
Local verification on 2026-10-03:
| Check | Result |
| --- | --- |
| `pnpm -r typecheck`, `pnpm build`, UI token gates | Passed |
| Rust workspace library tests | 312 passed |
| PRP schema tests and CI shard selection tests | 13 and 24 passed |
| Control-plane and Dot driver regression tests | 97 passed, including late callback retirement |
| Real Rust / PostgreSQL Dot integration | 2 passed |
| Agent configuration route tests | 36 passed, including unpaired create and conversion |
| Stable shared package lane | 837 passed |
| Adapter utilities and Codex adapter source tests | 1,883 passed, 12 skipped |
| Root `pnpm test:run` attempt | Server group: 744 files passed, 2 failed, 4 skipped; 14,997 tests passed. Wrapper stopped at that failed group. |
The root run's failures were a Calendar socket reset and a Git scan load
assertion (497 of 498 expected joined requests). Both failing cases passed in
isolation. A subsequent stable database lane passed 126 tests but failed one
embedded PostgreSQL startup; that test also passed in isolation. These results
do not establish a completely green repository suite or release readiness.
Gateway integration on 2026-10-06 merged master through `c365a16e3`, including
the released browser/device consent and assistant invitations (#14846 and
#14933). Initial integration verification passed full workspace typecheck and
build, token gates, 125 gateway/consent/admission tests, five Dot integration
tests, 20 consent/Connections UI tests, 318 Rust library tests, 123 Runner/Dot
recovery tests and 13 PRP schema tests.
The local root `pnpm test:run` was stopped after 2 hours 47 minutes when review
fixes made it stale; it did not complete and is not a passing result. Fresh
PR CI verifies the final branch. The review fixes cover reconnect wakeups,
OAuth disconnect and refresh replay, binding-reference restoration, board-only
OpenAPI coverage and prior protocol-version assumptions. Current results and
remaining qualification are tracked in [PR #15402](https://github.com/paperclipai/paperclip/pull/15402).
After those fixes, full workspace typecheck and token gates passed again.
Focused verification passed 89 gateway, Dot, OpenAPI, connection-instruction
and pairing UI tests, 40 protocol/runtime compatibility tests, and the real
Runner protocol-upgrade/replacement test.
PR CI exposed a clean-shutdown race: Rust could exit after the durable shutdown
receipt was acknowledged but before the Dot SDK's next poll. The adapter now
recognizes that confirmed clean exit and still requires reconciliation after
an unexpected exit. A real Rust regression reproduces the failing order and
passes with the fix. Four driver tests and 11 integration/pairing tests passed,
including a failed connection refresh after successful revocation. Revocation
clears the cached binding before refetch so that failure cannot restore it.
Visual review uses the production `DotRunnerConnection` component in the
`Assistant connections/Dot Runner` Storybook stories. Pairing, event-test
waiting and revocation were exercised in the browser with synthetic API
responses. These screenshots show preview data, not a qualified Dot account:
![Synthetic pairing preview](screenshots/openai-dot-runner/pairing.jpg)
![Synthetic connected preview](screenshots/openai-dot-runner/connected.jpg)
## Starting work from a Dot conversation
Call `paperclip_dot_capabilities` to discover the bound agent, responsible person, permissions, prerequisites and active assignment. An idle Dot can call `paperclip_dot_request_turn` with a prompt and stable UUID. Paperclip creates one visible, agent-authored intake task and admits it through the ordinary heartbeat/Runner path. Retries reuse the task and wake receipt; the same UUID with a different prompt is rejected. Company and agent pause, grants, budgets, task assignment permissions and single-assignment ownership apply. The intake is work attributed to the Dot, not a fabricated operator message or an OAuth permission expansion.
Read and accept the resulting assignment, then call its catalog through `paperclip_dot_tool`. Use `get_identity` and paginated `list_people` to identify the owner. `create_task` accepts either `assigneeActorId` for an agent or `assigneeUserId` for an active company person. `reassign_task` also accepts a person and checks the previous agent/person and status version before the ownership change. Human tasks do not wake agents.
The shared catalog adds `get_task`, `comment_on_task`, `list_task_documents`, `read_task_document`, and `write_task_document` through the existing authenticated REST authority. Company boundaries, task visibility, mode restrictions, document revisions, locks and mutation receipts still apply. `call_api` remains the discovery-based route for other authorized control-plane actions.
`list_assigned_skills` and `read_assigned_skill` read the turn's exact pinned skill versions, with manifest and file digest checks. Assigned app tools are projected by the existing MCP gateway; its permission checks, approvals, receipts and revocations remain authoritative. Credentials are kept on Paperclip. Newly granted app tools arrive in a continuation with a new pinned catalog.
Enable **Workspace files and commands** on the agent to expose `workspace_list`, `workspace_read`, `workspace_write`, and `workspace_run`. The server binds these to the admitted execution workspace. File paths reject absolute paths, traversal and symlinks. Paperclip instance state under `.paperclip` is excluded from file tools, uploads, and sandbox commands. Linux commands mask existing instance directories and the workspace root instance directory with read-only empty mounts. The command protection scan fails closed above 4,096 directories. macOS supports file tools and publishing; `workspace_run` is neither advertised nor executable there because its file sandbox cannot contain detached descendants. New writes require an absent file; overwrites require its observed SHA-256. Commands have bounded output/time and an OS sandbox, use a workspace-local home, and do not inherit credentials. Linux commands run in a private PID namespace. Command completion, timeout, or live authority loss terminates its supervisor and all descendants, including children that start a new session. Register requested files with `register_deliverable` before finishing. Workspace mutation attempts are reserved durably before effects; an interrupted attempt returns an unknown outcome instead of replaying a possible effect. Inspect state before deciding another mutation.
Mailbox `follow_up` entries reference new comments on an accepted assignment. Read `get_task_history` and incorporate them at a safe boundary. This supplies new input without claiming OpenAI steering support. `paperclip_dot_tasks` pages by the last task ID. Each assignment allows up to 4,000 broker operations; lower domain-specific limits still apply. A fenced assignment's control acknowledgement remains available at that limit. `hire_agent` creates a distinct unpaired Paperclip Dot teammate; the operator still pairs a separate OpenAI Dot. No existing binding or workspace permission is inherited.
## Tool inventory
The top-level MCP catalog contains these 15 transport and lifecycle tools:
`paperclip_dot_capabilities`, `paperclip_dot_request_turn`, `paperclip_dot_tasks`,
`paperclip_dot_request_work`, `paperclip_dot_pair`, `paperclip_dot_inbox`,
`paperclip_dot_read`, `paperclip_dot_accept`, `paperclip_dot_tool`,
`paperclip_dot_progress`, `paperclip_dot_finish`, `paperclip_dot_operation_status`,
`paperclip_dot_confirm_event`, `paperclip_dot_renew`, `paperclip_dot_control_ack`.
After accepting work, use `paperclip_dot_tool` with a name from the assignment's
actual catalog. Availability depends on work mode, permissions, assigned apps,
and workspace/attachment bindings. Advertising a tool does not bypass its
server-side authorization.
| Area | Runner tools |
| --- | --- |
| Identity and people | `get_identity`, `list_people`, `list_agents`, `get_agent` |
| Task work | `get_task_context`, `get_task_history`, `search_tasks`, `get_task`, `report_progress`, `set_task_title`, `set_task_monitor`, `create_task`, `reassign_task`, `set_dependencies`, `comment_on_task` |
| Human input and approvals | `request_human_input`, `list_approvals`, `get_approval`, `get_approval_context` |
| Documents | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `list_task_documents`, `read_task_document`, `write_task_document` |
| Agent instructions | `read_agent_instructions`, `update_agent_instructions`, `get_agent_instruction_history`, `restore_agent_instructions` |
| Skills | `create_skill`, `update_skill`, `list_assigned_skills`, `read_assigned_skill` |
| Projects | `create_project`, `list_projects`, `list_project_repositories` |
| Apps and control-plane APIs | `connections_search`, `connection_request`, `search_api`, `call_api`, `hire_agent`; assigned gateway tools are discovered from the actual catalog, or via `paperclip_search_assigned_tools` / `paperclip_call_assigned_tool` for large catalogs |
| Workspace and output | `workspace_list`, `workspace_read`, `workspace_write`, `workspace_run`, `register_deliverable` |
| Task attachments | `list_task_attachments`, `read_task_attachment` when **Read task attachments** is enabled |
| Bound chat inputs | `list_chat_attachments`, `reuse_chat_attachment`; `read_chat_attachment` and `read_current_wake_comments` require verified server bindings |
| Feedback | `submit_complaint`, `submit_suggestion` |
| Completion | `paperclip_finish`, `paperclip_block`; a separate review run offers its limited read catalog and `resolve_review` |
Enable **Read task attachments** on the Dot agent to expose `list_task_attachments`
and `read_task_attachment` on new assignments. This setting is off by default,
requires operator consent, and sends file contents to OpenAI. It is independent
of **Workspace files and commands** and is never inherited by a hired teammate.
Reads are restricted to the current run's assigned task and company, recheck
live run/binding/setting authority before returning, verify byte size and
SHA-256, and return at most 12,000 bytes per page from files up to 16 MiB.
Each run keeps a verified in-memory copy bounded to 16 MiB and 20 files;
subsequent pages reuse those bytes and recheck file metadata and live access.
Closing the run clears the cache. Agent configuration routes reject attempts
to change the operator-owned attachment, workspace, and pairing flags.
Text pages preserve UTF-8 boundaries; binary files use base64. Pass the returned
SHA-256 on subsequent pages to detect changes. Missing, oversized or corrupt
files return terminal errors. Read audit entries contain metadata, never file
contents. Revocation blocks further reads, but cannot withdraw bytes already
sent to OpenAI. Files and filenames remain untrusted input. There is no public
attachment URL or Paperclip credential exposed to Dot. Generic `call_api`
attachment and workspace downloads cannot bypass these settings; use the scoped
read tools. Raw asset reads and uploads are limited to API captures and output
artifacts from the current run. API uploads from workspace paths require the
workspace grant and the same confined root as workspace tools.
A plugin upgraded during a running Dot conversation can retain an old top-level
tool catalog. Refresh its tools in ChatGPT plugin settings and reattach it.
Inspect the real exposed actions before claiming new idle or lease actions
are available. The assignment catalog is read on each new assignment.
@@ -0,0 +1,988 @@
# OpenAI Dot as a production Paperclip Runner provider
Date: 2026-10-02
Status: Experimental self-hosted adapter and full Runner extension implemented; real-account qualification and remaining limits are recorded in the dated checkpoints below
Branch: `codex/dot-events-prototype`
Scope: Original production design with the implementation status recorded below. The adapter remains off by default.
## Implementation status
Implemented the dedicated agent OAuth resource, one-use pairing and readiness
challenge, scoped durable mailbox and operation receipts, PRP v3 external
operations, native input v6 without a workspace or credentials, Rust Dot
executor, normal task tool/completion authority, provider configuration and
connection UI. Tests exercise the real Rust Runner and ordinary task status
finalizer, plus bridge reattachment and missing-checkpoint refusal.
The current supported deployment is self-hosted with a local controller and
stable public HTTPS. The October 7 real-account checkpoint qualifies the dedicated `/mcp/runner`
path. The earlier account proof covered only the reference harness transport. The complete fault matrix and hosted agent-broker
qualification below remain future qualification work.
The original adapter rejected assigned skills and third-party MCP bindings.
The user subsequently authorized their governed Runner integration. The full
extension now reads pinned skill files and relays assigned gateway tools.
Workspace files and sandboxed commands require an explicit per-agent opt-in.
Automatic inbound attachment staging remains disabled pending separate scope
approval. See [the adapter runbook](../openai-dot-runner.md).
## 1. Recommendation
Add **OpenAI Dot** as a provider of the existing `paperclip_runner` adapter:
```json
{
"adapterType": "paperclip_runner",
"adapterConfig": {
"provider": "openai_dot",
"dotBindingId": "<server-owned binding>",
"lifecycleMode": "per_turn"
}
}
```
The provider uses MCP Events to notify an existing Dot and authenticated MCP
tools to receive its actions. Rust `runnerd` owns the provider state machine and
PRP lifecycle. Paperclip owns connection authorization, durable mailboxes,
admission, tool effects, budgets, approvals, and final task disposition.
The implementation is a new Runner provider, not a new legacy `execute()`
adapter. Keep `paperclip_runner` in the server/UI/CLI registries. The older
`create-agent-adapter` skill remains useful for configuration, diagnostics and
cross-surface consistency; its process-spawning package recipe does not define
this provider's execution architecture.
The branch now integrates the merged public MCP foundation (`e34abee67`,
#14846) and scoped browser/device assistant invitations (`22a3ea341`, #14933)
from master. The preliminary foundation at `a88448f77` is superseded.
This does not imply that the companion Cloud broker is deployed or qualified.
### Gateway reconciliation — 2026-10-06
The canonical worktree remains `dot-events-prototype/paperclip` on
`codex/dot-events-prototype`. Master was merged through `c365a16e3`.
The shared consent implementation now creates distinct agent-purpose grants
for both browser and device flows, validates request resource and organization
ownership, and rejects viewer approval. Dot has a separate issuer and scope.
Verified client metadata, durable verification quotas, event secret rotation,
subscription refresh races and warm-standby gates use the merged implementation.
Personal and Dot event workers consume their own subscription resources.
The generated Dot-only migration is now `0317_messy_famine.sql`, after the
published master migrations. Its table, column, index and constraint additions
are safe to reapply. Both consent UIs retain the merged organization branding
and request layout while explaining agent access. Runner provider selection
uses the current shared harness selector.
Targeted gateway, Dot, onboarding and UI verification passed 125 tests before
the added migration replay check. Full workspace typecheck/build and token
gates passed. Runner recovery verification passed 123 tests and the Rust
workspace passed 318 library tests. The local full repository attempt was
stopped after 2 hours 47 minutes when review fixes made it stale. Final branch
CI and review are tracked in [PR #15402](https://github.com/paperclipai/paperclip/pull/15402);
the adapter runbook records verification limits.
The other gateway chat's subsequent assistant-tool expansion is still separate
work and is not part of this integration. Real-account qualification of the
dedicated Runner endpoint remains outstanding. Assigned skill files and
third-party MCP bindings remain unsupported.
### Evidence and limits
The real-Dot test on October 2 demonstrated:
1. Private custom-plugin installation and ordinary OAuth connection.
2. Dot successfully calling `paperclip_connection`.
3. A verified MCP Events subscription and signed webhook delivery.
4. Dot fetching and accepting an assignment without a second chat prompt.
5. Dot invoking a projected tool and saving exactly one test report.
6. A valid structured result, native `turn.completed`, and `run.terminal` with
state `succeeded`.
7. Server-confirmed unsubscription.
The native test used TypeScript `HarnessDriverBackend`, synthetic admission,
an in-memory driver registry and one synthetic tool. It did not exercise Rust
runnerd, normal task checkout/finalization, restart recovery, usage accounting,
or workspace execution. Those are implementation requirements below.
The test also exposed two onboarding details: action tools may become available
after their directory listing appears, and restarting a disposable lab destroys
OAuth state. A stable deployment and a real readiness check are essential.
## 2. Product contract
### Paperclip initiates work
A user selects **Paperclip Runner → OpenAI Dot**, connects their Dot, and assigns
it an ordinary task. Existing scheduling admits the task. Paperclip sends a
small event; Dot retrieves the current assignment, explicitly accepts it, uses
the projected tools, and submits its result. The task thread shows delivery,
acceptance, progress, outputs, and completion separately.
Paperclip can enqueue work whenever its ordinary rules allow. Delivery and
execution are asynchronous. A webhook acknowledgement is never presented as
proof that Dot started work.
### Dot initiates interaction
Preserve both directions and make the acting identity explicit:
| Context | Authority | Supported behavior |
| --- | --- | --- |
| User asks their Dot to use Paperclip | Existing personal MCP connection | Read company work, create/delegate tasks, add feedback, retrieve results; actor is the consenting person. |
| Dot executes an assigned Runner turn | Dedicated agent connection plus admitted assignment | Invoke the same authorized semantic tools as another Runner provider; actor is the assigned agent and run. |
| Dot wants to start work while idle as the agent | Dedicated connection's `request_work` operation | Submit a bounded wake request referencing an authorized task and intent. Normal scheduling decides whether to admit a run before exposing its mutation tools. |
Idle `request_work` must support a company-scoped planning/intake task for
agent-initiated ideas, so proactive work does not depend on an already assigned
engineering task. The intake task is an ordinary governed work object, created
through existing task rules; it is not the event mailbox or an execution bypass.
Actual task creation, delegation and writes then use the admitted tool catalog.
Do not silently give an agent the consenting person's authority. A personal
connection remains optional and independent. If the same Dot also has personal
Paperclip access or other plugins, Paperclip cannot prevent it from acting
through those separately authorized channels. Users needing strict separation
must use a Dot/account without those broader connections.
### First release boundaries
- One active assignment per binding. Additional tasks remain in normal queues;
do not admit a large backlog into provider sessions waiting for acceptance.
- One active agent binding per dedicated OAuth grant. Multi-agent multiplexing
through one Dot connection is deferred.
- Standard, planning and ask tasks use normal Paperclip tool policy. Only
capabilities actually exposed to this provider are offered.
- Start with control-plane work: research coordination, task decomposition,
documents, comments, approvals and assigned connector tools. A Dot is not
presumed to share the Paperclip repository or local filesystem.
- Follow-up messages are durable task messages and subsequent assignments.
Active steering is unavailable in v1. The UI must say when a message is queued.
- Model selection, token usage, provider-native session reset, private reasoning,
global interruption, and control of Dot's other tools are unavailable.
- Public reachability is required. No Chrome automation, ChatGPT cookies,
undocumented task endpoints, or expiring development tunnels in production.
## 3. Architecture and ownership
```mermaid
sequenceDiagram
participant App as Paperclip scheduler/controller
participant Runner as Rust runnerd: Dot provider
participant Broker as Durable Dot mailbox + public MCP
participant Dot as OpenAI Dot
App->>Runner: Authenticated PRP prepare/open/start
Runner->>App: Durable external dispatch request
App->>Broker: Commit assignment + wakeup item
Broker->>Dot: Signed MCP Event with references
Dot->>Broker: Read and accept assignment
Broker->>App: Durable bound operation
App->>Runner: PRP external operation
Runner-->>App: turn.started
Dot->>Broker: Call projected tool
Broker->>App: Durable bound operation
App->>Runner: PRP external operation
Runner->>App: Normal semantic tool request
App->>App: Existing authorization + domain effect + receipt
App-->>Runner: Normal semantic_tool.result
Runner-->>App: External operation result
App-->>Broker: Persist receipt
Broker-->>Dot: Tool result
Dot->>Broker: Propose structured completion
Broker->>App: Durable bound operation
App->>Runner: PRP external operation
Runner-->>App: Validated result + terminal events
App->>App: Existing completion feedback and finalizer
```
The apparent round trip through the controller is intentional: MCP is the
provider's transport, while Runner remains the execution lifecycle. Avoid a
second tool dispatcher or finalizer hidden in the MCP route.
### Component responsibilities
| Component | Owns | Must not own |
| --- | --- | --- |
| Public MCP/OAuth | Authenticate each call, resource/scope checks, bounded input, callback verification | Creating an admitted run from supplied IDs; arbitrary agent impersonation |
| Dot broker in server | Pairing, bindings, mailbox, delivery state, request deduplication, routing to the current controller | Domain effects or provider terminal decisions |
| Rust Dot provider | Turn acceptance, provider command ordering, projected-call mapping, durable provider receipts, PRP events and checkpoints | DB access, company policy, OAuth secrets, issue status writes |
| Existing native controller | Admission, PRP ownership/leases, semantic dispatch, governed waits and finalization | Pretending a webhook receipt proves execution |
| Dot | Retrieve work, perform reasoning, invoke exposed tools, propose result | Selecting its company/agent/run authority or bypassing finalization |
Runner receives no ChatGPT credential, MCP OAuth token, webhook URL/signing key,
or unrestricted Paperclip API key. Reuse the authenticated PRP connection for
the app-owned broker port. Do not add a second generic HTTP control API solely
to get operations into runnerd.
The Runner package remains standalone: define the broker port and envelopes in
the package; implement them in Paperclip server without importing server or DB
code into `packages/paperclip-runner`.
## 4. Production Runner integration
### Provider identity and configuration
Proposed identifiers:
- Provider: `openai_dot`.
- Driver: `openai_dot_mcp`.
- Execution kind: `remote_service`; the local runnerd process is only the
controller-side transport, not the Dot's execution process.
- Service: `openai_dot`.
- Provider session ID: `null` unless a documented provider identity becomes
available. A user-entered Dot URL is a display link, not authentication.
- Model: `null`, displayed as **Managed by Dot**.
- Provider version: our qualified bridge/protocol revision, clearly labelled;
do not claim it pins OpenAI's hosted implementation.
Agent config contains only binding references and validated operational limits.
The server resolves the binding into an immutable per-run snapshot containing
company, agent, connection, binding generation, catalog revision, deadlines and
protocol revision. Tasks cannot override that identity or supply callback URLs.
### Contract changes
The current native input is a closed `paperclip.native-execution-input.v5`
contract. Introduce the next input revision with a Dot provider variant; retain
v1–v5 parsing and recovery for existing providers. Update JSON Schema,
TypeScript and Rust together, plus export/compatibility manifests and fixtures.
Do not repurpose a Codex or ACPX profile.
The new input must also distinguish provider workspace access from the Runner's
own state directory. Dot's workspace-access variant is `none`; it receives no
provider cwd. Adapt the execution-input builder and filesystem preparation
guards accordingly instead of satisfying existing mandatory cwd fields with a
made-up project path. A project/task may still exist in Paperclip without its
checkout being mounted into this provider.
Add a small, versioned external-provider transport extension over PRP. Proposed
logical messages, with final names fixed in the contract PR:
| Direction | Message | Meaning |
| --- | --- | --- |
| Runner → controller | `external_provider.dispatch_requested` | Persist a particular assignment/mailbox revision and notify its bound connection. |
| Controller → Runner | `external_provider.operation` | Deliver a validated accept/tool/progress/finish/control-ack operation with its stable request identity. |
| Runner → controller | `external_provider.operation_settled` | Persist the exact accepted/rejected/pending/unknown/result receipt for that operation. |
These are closed provider-neutral transport envelopes, not raw OpenAI messages.
Bind every envelope to company, agent, run, session, turn, binding generation,
assignment revision and request digest. Negotiate the new command/event schema
revision and capability explicitly; older binaries reject it before execution.
The existing v2 enums cannot be expanded invisibly in persisted old contracts.
Reads of assignment/receipt projections may come directly from the authorized
broker database. Mutating lifecycle operations enter the live Runner. Normal
`semantic_tool` dispatch, `paperclip_finish`/`paperclip_block` validation,
completion feedback and PRP finalization remain authoritative.
Command acknowledgement means the external operation was durably admitted to
the Runner, not that its tool effect finished. A tool operation must release
the command-processing lane while waiting for `semantic_tool.result`; otherwise
the result command would deadlock behind the operation waiting for that result.
Settlement is a separate correlated event, preserved across reconnects.
### Rust and TypeScript surfaces
- Add `dot_provider_backend.rs` and bounded Dot provider checkpoint/state code
in `runner/crates/runner-core/src/`.
- Extend `NativeProviderCommandExecutor` selection and recovery. Detect a Dot
checkpoint alongside the existing Codex/ACPX/managed state; conflicting
provider checkpoint authorities must fail closed.
- Reuse `ProviderToolBridge` for catalog validation, semantic call identities,
bounded pending/results and retained receipts.
- Add the production Dot branch to the native backend factory and the runnerd
transport facade. Existing names such as `createRunnerdCodexTransport` carry
several providers today; extend the real provider switch rather than routing
Dot through a standalone TypeScript fallback.
- Evolve `DotHarnessDriver` into the TypeScript reference/conformance
implementation of the same durable contract. The current memory-only lab
remains explicitly a lab until replaced by that implementation.
- Teach native execution preparation and projection about **waiting for Dot**.
Audit the Codex defaults in `prepare-native-run.ts` so persisted profile,
driver identity and initial phase match the admitted Dot provider.
- Run the lightweight Dot provider controller on the trusted execution host in
v1. Do not provision a code sandbox just to wait for a remote Dot. Remote
runner placement is a separate qualified deployment profile.
### Capabilities reported to Paperclip
| Capability | v1 behavior |
| --- | --- |
| Typed events / structured result | Supported for bridge-observed actions and validated results. |
| Dynamic tools | Supported through current run-owned projection and normal dispatch. |
| Read / reconciliation | Supported for durable mailbox, operation and Runner state. |
| Native provider session resume | Unsupported; no documented Dot thread/session API. |
| Bridge recovery after restart | Required; recover the same assignment and receipts, not a new Dot conversation. |
| Steering / immediate interruption | Unsupported. |
| Queued follow-up | App queue supported; do not advertise provider-native steering/follow-up guarantees. |
| Usage / cost / model controls | Unknown or unavailable; never zero-filled or inferred from elapsed time. |
| Native skills/MCP injection | Unsupported; expose assigned knowledge/tools through authenticated reads and projection. |
| Native runtime permission/input control | Unsupported; use Paperclip's durable task interactions and response wakes. |
| Goals / child-thread lineage | Unsupported. |
The capability model must distinguish recovering our bridge from resuming an
OpenAI session. Add an explicit recovery/stop description if the current booleans
cannot express this, rather than setting `resume` or `interruption` to true.
## 5. Authorization and pairing
### Dedicated agent OAuth resource
Add a dedicated agent resource, proposed `/mcp/runner`, sharing the existing
OAuth/DCR/PKCE implementation and event transport. Its grant has a distinct
purpose and `paperclip:agent` scope, optionally with `offline_access`. It never
implicitly grants personal `paperclip:write` authority.
The existing `/mcp/paperclip` resource and its personal consent semantics stay
intact. Make the principal a discriminated personal/delegated-agent type; do not
manufacture a board actor in an agent call. Recheck the grant's consenting user,
membership, company availability and explicit agent delegation on every call.
Update discovery and token audience checks for each exact resource.
Pairing sequence:
1. An authorized operator selects an existing/new Dot agent and starts setup.
Apply the normal hiring/configuration approval rules.
2. Paperclip creates an expiring one-use pairing intent bound to company,
agent, initiating operator, requested delegation and protocol revision.
3. The user installs/connects the dedicated Dot runner plugin using OAuth and
explicit agent consent. No agent API key is pasted into a prompt.
4. A generated setup instruction asks Dot to complete pairing, perform a
read-only connection check, and subscribe to the exact binding mailbox.
5. Pairing consumes the one-use intent and binds the grant. Callback verification
associates the subscription with that binding generation.
6. A harmless challenge travels through the same event → MCP → Runner path.
Ready means the challenge completed, not merely that tools were listed.
Store only a hash of the pairing code, bound to the initiating user and company.
Reject expired/replayed codes, cross-company grants, duplicate active bindings,
and any attempt by a personal grant to claim agent tools automatically.
OAuth identifies an authorized client connection, not cryptographically a
particular Dot chat. The callback identifies the subscription destination; it
does not prove exclusive possession by one model or thread. Treat the binding
as delegated connection authority and state that limitation in the product.
Enforce one active assignment even if two chats use the same connection.
### Per-operation authority
Before accepting or replaying an operation, check:
- resource, grant purpose/scopes, active membership and agent binding;
- company, agent, assignment, run, session and turn;
- binding generation, current assignment revision and authority deadline;
- active controller lease/fence and task ownership;
- agent/company pause, budget admission and applicable work-mode restrictions;
- current projected tool permission, grants and governed approvals.
Recheck at the domain mutation boundary and before returning sensitive results.
Revoked authority must not obtain cached results simply because its request ID
is valid. A current authorized operator can reconcile retained outcomes.
Revoking/replacing a binding increments its generation and fences all old
callbacks. Fresh consent creates a new binding generation; it never silently
adopts active work from an older connection.
## 6. Durable state and event delivery
Use additive, company-scoped tables. Final naming can follow existing schema
conventions, but these ownership boundaries are required:
| Record | Key data and constraints |
| --- | --- |
| `dot_agent_bindings` | Company, agent, consenting user, dedicated grant, generation, readiness, protocol revision, created/revoked timestamps. Unique active agent binding and active grant binding. |
| `dot_runner_assignments` | Immutable run/session/turn and completion-contract binding, binding generation, catalog digest, prompt/runtime-context references, status, deadlines and accepted/completed timestamps. Unique run/turn; at most one live assignment per binding. |
| `dot_runner_operations` | Assignment, request ID, operation, canonical input digest, PRP command/call IDs, status and bounded result/reference. Unique request ID within assignment; no silent argument changes. |
| `dot_mailbox_items` | Durable actionable item for assignment, continuation, pending tool result or control request; immutable ID, assignment revision, visibility/consumption state and stable notification identity. |
Do not duplicate canonical task comments, documents, transcripts, result rows or
finalization decisions in these tables. Store references and bounded transport
snapshots only. Callback secrets remain in the existing encrypted event store;
OAuth tokens remain hashed at rest. Restrict sensitive assignment/result
retention to the same authorized data and storage policy as normal native runs.
Reuse `mcp_event_subscriptions` and `mcp_event_deliveries`. Their current
task-only subscription and activity-log scan are insufficient for a real agent
mailbox. Add a tagged resource binding (`task` or `dot_binding`) and a typed
mailbox notification source, with constraints so a row cannot represent both.
Preserve current task-event authorization and behavior. Delivery uniqueness must
cover subscription + source notification, not just an incidental task comment.
No synthetic inbox issue is needed for the production transport. Activity
logging records mutations, but an arbitrary `activityLog` row must not be able
to manufacture a Runner assignment.
### Event definition
Use one production event, proposed `paperclip.dot.mailbox_updated`, filtered by
`companyId` and `bindingId`. The prototype's task-filtered
`paperclip.dot.work_available` stays a lab contract; migrate setup instructions
explicitly rather than changing its schema in place.
The event carries only bounded references: binding generation, mailbox item ID,
assignment/run/turn references where applicable, item kind and mailbox revision.
No prompts, company secrets, document bodies or authority-bearing bearer tokens.
Dot reads current state through authenticated tools; event text grants no rights.
Commit the assignment/mailbox item and notification intent atomically after
admission. Publish only committed, currently authorized work. Reuse signed
verification, public-IP checks/DNS pinning, no redirects, encrypted callback
material, secret rotation, bounded retries and `410`/`413` handling.
Delivery success, Dot acceptance and Runner completion are separate durable
facts. Keep an audit-safe delivery summary attached to the assignment even if
unsubscription removes transport rows; the lab demonstrated why that matters.
### Subscription gaps and duplicates
- Follow the protocol's `refreshBefore`; surface expiration/disconnection.
- Do not promise replay while the server returns `cursor: null`.
- On initial subscription or verified resubscription, atomically arrange a new
wakeup for each still-actionable mailbox item. The item/assignment ID remains
the same. Reconnection must neither lose old work nor create a second run.
- Webhook retries preserve event ID. A deliberate reconnect/reminder notification
has its own event ID but refers to the same mailbox item.
- Dot drains a bounded page of actionable items after a wakeup, acknowledging
items individually. Batched, duplicate and out-of-order events are expected.
- Never generate another wakeup merely because Dot reported progress or posted
a comment. Prevent self-wake and completion-comment loops.
## 7. Operation protocol and idempotency
Retain the prototype's conceptual operations and add pairing/status support.
Schemas are versioned, bounded and validated before persistence.
| Operation | Behavior |
| --- | --- |
| Connection/pairing | Read readiness or consume a one-use pairing intent; cannot create an admitted task run. |
| Inbox/read | Return only current bound work, prompt context, tool catalog references and completion contract. |
| Accept | Atomically claim the current assignment once; only Runner acceptance emits `turn.started`. |
| Tool | Map a stable external operation into a normal semantic tool call. |
| Progress | Append bounded progress through PRP; apply ordinary useful-progress/watchdog rules. |
| Finish | Propose the canonical structured result; run normal completion feedback and finalization. |
| Operation status | Read a durable pending/completed/rejected/unknown receipt without redispatch. |
| Request work | Create an idempotent ordinary wake request while idle; never self-assign by accepting arbitrary IDs. |
| Control acknowledgement | Record cooperative acknowledgement without claiming global provider shutdown. |
Use `requestId` plus assignment binding as the external operation identity. The
server reserves it before routing to the current controller. Canonical input
digest mismatch is an explicit conflict. Derive stable PRP command and semantic
call IDs from that identity and preserve them across recovery.
The broker receipt and existing semantic effect receipt have different jobs:
the former tracks delivery of a provider operation; the latter proves the
domain effect. Reuse `PaperclipRunnerToolAuthority` and its mutation receipts.
Do not implement writes by calling the public personal MCP executor.
### Lost replies and slow operations
Wait only a bounded interval for an MCP response. If work is still durably
pending, return an operation reference. A later mailbox result item wakes Dot
when the receipt settles. A status read or retry with the same ID can also
retrieve it. This is operation reconciliation, not polling the inbox for work.
Distinguish:
- **Pending:** a durable operation is still being processed; do not dispatch it
again merely because the HTTP request ended.
- **Completed/rejected:** exact recorded result can be returned to a currently
authorized retry.
- **Unknown:** an effect may have happened without a confirmed receipt. Block
redispatch, including restart recovery, until the existing domain receipt or
authoritative resource resolves it.
No exactly-once claim for arbitrary remote writes. Use at-least-once transport
with durable identity, existing domain idempotency and explicit uncertainty.
Receipt exhaustion stops admission rather than evicting IDs still replayable.
Finish shares the same ledger. A duplicate result cannot create another
terminal or finalize twice. Changed completion content under the same ID is a
conflict. A stale contract revision returns actionable feedback instead of
marking the task done. The production path requires real criterion bindings;
the lab's empty completion criteria are not its production contract.
## 8. Lifecycle, recovery and cancellation
### Assignment state
```mermaid
stateDiagram-v2
[*] --> Queued: normal scheduling
Queued --> Offered: admission + durable dispatch
Offered --> Accepted: explicit current-generation accept
Accepted --> Running: first execution activity
Running --> AwaitingInput: governed wait
Running --> ResultProposed: finish
ResultProposed --> Settled: normal finalizer
Offered --> Fenced: cancel / expire / revoke
Accepted --> Fenced: cancel / expire / revoke
Running --> Fenced: cancel / expire / revoke
Fenced --> Reconciliation: drain in-flight effects
AwaitingInput --> Settled: yielded turn
Settled --> [*]
```
An answered interaction produces a new, ordinarily admitted continuation with
current context and a new authority epoch. Do not hold a network request open
through a human approval. Dot's memory may persist, but it must read the new
Paperclip assignment and contract before taking further actions.
Suggested initial operational defaults, configurable by operators and subject
to qualification: one active assignment, ten-minute acceptance deadline,
two-hour maximum authority lifetime, and attention after fifteen minutes
without useful activity. A warning is not automatic reassignment. Silence can
mean Dot is doing external work; it does not prove termination.
Successful webhook receipt without acceptance gets no unbounded reminder loop.
At the acceptance deadline, show a recoverable connection/acceptance failure.
An operator retry republishes a wakeup for the same still-valid assignment or
creates a fenced successor only after reconciliation. Transport failures use
the existing bounded delivery retry policy.
### Crash matrix
| Failure point | Required recovery |
| --- | --- |
| Before assignment commit | No event exists; ordinary admission can retry its stable intent. |
| After commit, before send | Event worker finds the durable notification. |
| After webhook receipt, before accept | Same assignment remains offered; do not synthesize a started turn. |
| During accept / response lost | Same request returns the accepted receipt; one `turn.started`. |
| Tool committed, response lost | Reconcile existing semantic receipt; never repeat the effect with a fresh ID. |
| Paperclip controller restarts | Restore current lease/generation, route persisted operations, replay PRP acknowledgements. |
| runnerd restarts | Restore exact Dot provider checkpoint; verify binding/catalog/assignment identities before replay. |
| Both restart | Reconcile server records and Runner checkpoint by identity/digest; unresolved divergence becomes operator-visible, not a new assignment. |
| Subscription expires | Preserve pending work; mark delivery unavailable; wake actionable items after verified reconnection. |
| OAuth/binding revoked | Reject callbacks and cached-result access; retain authorized audit/reconciliation state. |
| Old Dot continues after replacement | Reject all old-generation mutations and results. |
| Storage/replay limit exceeded | Stop accepting new operations, retain uncertainty, surface actionable recovery. |
The existing controller generation and provider-attempt counter stay distinct
from the Dot binding generation. A server failover does not automatically
create a new provider attempt or grant new authority to a remote model.
Enforce generation/lease comparisons atomically when routing and mutating state;
an in-memory check followed by an unfenced write is insufficient across server
replicas. The broker stores a pending accept reservation, and projects it as
accepted only from the Runner's durable acceptance evidence.
### Stop semantics
Pause, cancel, budget stop, reassignment and revocation first fence Paperclip
authority in the same governed path used by other providers. Prevent new tool
dispatch, hide obsolete inbox work, and reconcile already dispatched effects.
Optionally send a cooperative stop mailbox item while the connection remains
authorized for that narrowly scoped acknowledgement.
Represent these facts separately:
1. Paperclip authority revoked.
2. Paperclip-controlled effects settled or unresolved.
3. Dot acknowledged the stop request, if observed.
4. OpenAI/global external activity stopped: **unconfirmed**.
Killing runnerd does not kill Dot. A model acknowledgement does not prove that
its child tasks or other plugins stopped. Do not let existing PID/process-death
recovery logic treat either as a safe global replacement proof.
After fencing and settling Paperclip effects, the run can record cancellation
of Paperclip participation. Automatic replacement after an accepted assignment
remains disabled where external activity might duplicate work. Surface the
uncertainty and require the existing operator recovery path for such cases.
## 9. Tools, context, files and accounting
### Runtime context
Build assignments through the existing native execution-input constructor,
including task/wake context, agent instructions, completion contract, current
questions and policy. Add read-on-demand projected operations for assigned skill
content and large context; pin revisions and recheck authorization on reads.
Use existing deferred tool discovery when catalogs exceed transport limits.
Expose the core task tools and assigned connector tools through the same
`PaperclipRunnerToolAuthority` and assignment snapshot used by other providers.
An old catalog does not preserve revoked grants.
Do not export local environment variables, credential bindings' secret values,
absolute host paths or a general-purpose server shell to Dot. Instructions to
respect a work mode do not replace server-side tool policy.
### Workspaces and deliverables
In v1, Dot has no shared filesystem. Suppress filesystem-dependent tools unless
their execution location and authority are explicitly supported. A local path
reported by Dot is never a path on the Paperclip server. Do not synthesize a
fake cwd or pass it to existing file-upload helpers.
Support durable text/Markdown outputs through existing document tools. For
binary outputs, design a separate bounded, authenticated artifact-ingress
contract or a qualified remote-workspace file reader. Bind any upload to the
company, task, agent and originating run; verify bytes/hash and create normal
attachment/work-product records. Do not fetch arbitrary model-supplied URLs.
Binary transfer is a later capability gate, not a reason to delay the initial
task/document provider.
### Accounting
Record provider usage and provider cost as unknown. Preserve separately measured
costs of Paperclip-mediated paid tools. No guessed token counts or zero-cost run
records. Display **Dot usage is managed outside Paperclip**.
Continue normal budget checks before admission and every paid/mutating action.
Budget exhaustion prevents new Paperclip work and fences the binding's current
run. Since token spend and external execution are unobservable, Paperclip cannot
promise a hard dollar cap on Dot's own activity. A policy requiring fully metered
provider spend must make this provider ineligible. Otherwise require the
operator's explicit selection of an externally billed/unmetered provider
profile, plus concurrency, operation-count and authority-time limits.
This distinction is a product contract, not permission to weaken existing
company budget hard stops for costs Paperclip actually controls.
## 10. Onboarding and ongoing UX
1. Choose **Paperclip Runner → OpenAI Dot**.
2. Show stable MCP endpoint and connection/pairing flow. Cloud deployments use
the stable broker once its agent-resource routing is implemented; self-hosted
deployments use authenticated public HTTPS.
3. Connect the dedicated plugin, review the named company/agent delegation, and
copy/open the setup instruction in the user's existing Dot.
4. Display separate setup stages: authenticated, tools callable, subscription
verified, test event received, test completed.
5. Enable assignment only after the challenge completes. A slow tool refresh
shows **Waiting for Dot's tools** with retry guidance.
6. Offer a real first task and display its result in the normal task surface.
Keep normal environment diagnostics read-only. **Send test event** is a separate
explicit action because it wakes an external model. Persist the setup test and
its receipts so refreshing the page or retrying does not create multiple tests.
Configuration shows binding owner, named agent, connection health, last successful
test, last acceptance, subscription expiration and reconnect/disconnect actions.
Do not show unused cwd, executable, model/effort or token-budget controls.
Run UI distinguishes **Queued**, **Waiting for Dot**, **Accepted**, **Working**,
**Waiting for input**, **Finalizing**, and **Needs reconnection/reconciliation**.
These are projections over normal execution and bridge records, not a second
issue-status system. Stop messaging describes the scope of what Paperclip can
stop. Missing provider transcript detail is labelled as unavailable.
The stable plugin description/instructions teach one standing subscription, inbox
draining, exact identity/request-ID reuse, structured completion, current-context
reads and no comment echo loops. Plugin directory listing is separate from
private testing; workspace policy and public distribution review still apply.
Any Apps catalog entry must follow `doc/connections/CONNECTOR-PLAYBOOK.md`.
Do not model a user-entered Dot URL as a normal outbound MCP server connection:
here, Dot is the MCP client.
## 11. Implementation sequence and file map
Deliver each slice behind a default-off Dot provider flag as well as the
existing Runner/public-MCP requirements. Disabled means no new admission;
preserve authorized cleanup/reconciliation of previously admitted work.
### Slice 1 — Contracts and deterministic production topology
**Change:** Define the Dot provider, binding snapshot, external-provider port,
operation receipts, capability limitations and next native/PRP schema revisions.
Add TypeScript/Rust parity fixtures and a scripted external peer.
**Files:**
- `packages/adapter-utils/src/paperclip-runner-permissions.ts`
- `packages/paperclip-runner/protocol/schemas/` and protocol fixtures
- `packages/paperclip-runner/src/contracts/{native-execution,harness-driver,types}.ts`
- `packages/paperclip-runner/src/backends/native-backend-factory.ts`
- Runner compatibility/export declarations and corresponding Rust protocol code
**Exit:** A qualified test artifact recognizes Dot and rejects incompatible
profiles/envelopes before dispatch; old-provider fixtures still pass. No
production picker entry yet.
### Slice 2 — Scoped connection, pairing and mailbox persistence
**Change:** Dedicated agent OAuth resource/purpose, scoped binding, migrations,
mailbox/operation records and event-source support. Retain personal MCP behavior.
**Files:**
- `packages/db/src/schema/public_mcp.ts`, new Dot schema module and exports
- Generated migration via `pnpm db:generate`
- `packages/shared/src/types/` and `validators/` for public configuration/status
- `server/src/routes/public-mcp.ts`, agent-binding management routes
- `server/src/services/public-mcp/{oauth,events,event-webhooks,dot-runner}.ts`
- New `server/src/services/dot-runner/` binding, mailbox and operation services
**Exit:** Restart-persistent pairing/subscriptions/receipts; cross-company,
personal-to-agent escalation, callback verification and revocation tests pass.
Operations can be stored safely before a controller is available.
### Slice 3 — Rust provider and authenticated broker port
**Change:** Implement the Dot executor, durable checkpoint, acceptance boundary,
PRP external operation delivery and normal `ProviderToolBridge` integration.
The app port implements only transport persistence/routing.
**Files:**
- New `runner/crates/runner-core/src/dot_provider_backend.rs` and state module
- `runner/crates/runner-core/src/native_provider_backend.rs`
- `runner/crates/runner-core/src/provider_bridge.rs` where shared extension is needed
- Runner durable command/event validation and provider descriptor projection
- `packages/paperclip-runner/src/drivers/dot/` reference/conformance implementation
- Runnerd transport facade and `server/src/services/native-runtime/runner-prp-coordinator.ts`
**Exit:** A real Rust runnerd and scripted MCP peer complete an assignment through
the app-owned tool authority. Killing either process at a receipt boundary does
not repeat a committed effect. The memory-only prototype is not used by this test.
### Slice 4 — Normal task admission, tools and finalization
**Change:** Select `openai_dot` from normal scheduling, acquire checkout and
controller authority, construct the immutable input, publish admitted work and
route results through existing completion feedback/status policy.
**Files:**
- `server/src/services/native-runtime/{provider-profile,runtime-mode,prepare-native-run,native-execution-input}.ts`
- `server/src/services/native-runtime/native-session-executor.ts`
- `server/src/services/native-runtime/paperclip-runner-tool-authority.ts`
- `server/src/services/native-runtime/{native-run-coordinator-store,native-run-finalizer,status-arbiter}.ts`
- Narrow integration points in `server/src/services/heartbeat.ts`
**Exit:** Assign a real test issue to the Dot agent; it writes a normal document
and proposes completion; the ordinary finalizer decides issue status. Reads,
mutations, work modes, approvals and budget denials use existing authorities.
### Slice 5 — Continuations, stop and recovery
**Change:** Restore exact assignments across controller/runner restarts, deliver
slow-operation results, implement question/approval response wakes, and fence
cancelled/paused/reassigned work without claiming external shutdown.
**Files:**
- `server/src/services/native-runtime/{native-restart-recovery,native-session-resume,native-runner-ownership}.ts`
- `server/src/services/{acknowledged-native-stop,remote-execution-termination,execution-recovery-attempt}.ts`
- Existing interaction/continuation services and Dot mailbox reconciliation
- Rust Dot recovery/checkpoint tests and protocol fixtures
**Exit:** Fault matrix in section 8 passes, with no duplicate writes or stale
callback authority. Unknown effects produce visible reconciliation, not an
automatic retry storm. A long human wait survives process restarts.
### Slice 6 — Product onboarding and read models
**Change:** Provider configuration, pairing/challenge UI, normal diagnostics,
connection health, run phase/stop semantics, and CLI watch output.
**Files:**
- `ui/src/adapters/paperclip-runner/index.ts` and its configuration components
- Agent creation/configuration and assistant-connection views/API clients
- Shared provider/connection status types and experimental-setting contracts
- CLI Runner event formatting/config diagnostics
- `integrations/assistant-plugins/` setup instructions/package as appropriate
**Exit:** A user can connect an existing Dot and complete the first task without
terminal commands, UUID transcription, database edits or browser automation.
UI follows `DESIGN.md`/design skill, token gates and standard form footers.
### Slice 7 — Hosted routing and deployment qualification
**Change:** Stable public endpoints, agent-resource routing and grant purpose in
the Cloud broker, membership/revocation checks, key persistence, readiness and
sleep/wake behavior. Keep the account-to-tenant routing authority server-owned.
**Ownership:** Instance-side changes in this repo; Cloud broker/deployment
changes in the companion Cloud repository. Track that dependency explicitly.
**Exit:** Hosted and directly reachable self-hosted tests separately pass.
Private-network-only installs report the missing reachability prerequisite.
No temporary tunnel URL is shipped as configuration.
### Slice 8 — Real-account acceptance and release gate
**Change:** Full-stack evaluation fixtures, real Dot acceptance evidence,
operational docs and qualified provider catalog entry.
**Exit:** Real user onboarding → real assigned issue → signed wakeup → accepted
turn → normal tool effect/document → structured result → ordinary finalization,
plus restart, duplicate-delivery, revoke and cancellation scenarios. Record exact
artifact/contract revisions and distinguish simulator from real-account results.
Critical path: 1 → 2 → 3 → 4 → 5 → 8. Slice 6 consumes the status contracts;
slice 7 is required before advertising hosted support. A hidden vertical slice
after 4 is useful for real testing, but qualification requires 5 and 8.
## 12. Verification and release criteria
### Deterministic checks
- Shared TS/Rust fixtures: invalid version, wrong binding, stale generation,
changed request input, duplicate accept/finish and terminal ordering.
- Persistent DB tests: atomic assignment/outbox, uniqueness under concurrent
acceptance, one active assignment, subscription gap repair, lease takeover,
retention limits and unsubscribe audit preservation.
- Actual semantic authority tests: repeated task/document/comment effects,
approval gates, planning/ask restrictions, membership loss, budget stop,
grant revocation before/after dispatch and response replay.
- Recovery tests: crash at every commit/ack boundary; preserve uncertain writes;
conflicting snapshots fail closed; no fresh request IDs on automatic retry.
- Boundary tests: no personal privilege through the agent resource, forged Dot
URLs have no authority, no callback SSRF/redirects, stale catalog cannot call
removed tools, large input/output stays bounded, and no credentials in logs.
- Control tests: accepted-but-silent Dot, stop/finish race, in-flight write after
cancellation, late result after reassignment, duplicate/batched/out-of-order
events and slow operation completion.
- Context tests: current wake comments, assigned instructions/skills, question
answers and completion revisions survive continuation without leaking another
run's context or claiming native provider session resume.
### Live qualification
Use a disposable company with real scheduling and the built Rust artifact.
Record dispatch-to-webhook, webhook-to-accept and accept-to-result durations as
observations, not an OpenAI SLA. Repeat after a server restart and after OAuth
refresh. Verify that a task assigned while disconnected resumes once after
reconnection. Test two queued tasks and a human-question continuation.
Prove subscription removal/revocation from server state, not just Dot's prose.
Test pending external activity separately from Paperclip authority revocation.
Record unknown usage honestly. Keep the endpoint up through the entire test.
### Commands and evidence
Start with targeted Runner contract/driver, MCP, DB and native-runtime tests.
Use the repository's Runner and Product E2E eval skills when implementing their
fixtures. Run Rust/TypeScript parity, build/package compatibility and standalone
consumer checks for changed public Runner contracts.
Before a PR-ready handoff, run:
```sh
pnpm -r typecheck
pnpm test:run
pnpm build
pnpm check:token-gates # when UI changes are present
pnpm --filter @paperclipai/paperclip-runner verify
```
Browser/product suites are opt-in and required for the onboarding/live acceptance
slice, not for each small contract edit. Update the PR template in full and
attach inspectable evidence to the implementation issue/work product.
Keep operational counters in the run log or configured OpenTelemetry path.
First-party Telemetry is a separate opt-out data path: any new Telemetry events
require its generated contract, README update and privacy review. Do not add
prompts, callback URLs, document bodies or credential material to any diagnostic.
Release requires all of the following:
1. Normal task admission and finalization through Rust runnerd.
2. Dedicated agent delegation and current-authority enforcement.
3. Durable transport/operation recovery without duplicated known effects.
4. Accurate stop, usage, model and workspace limitations in UI and API.
5. Real-account onboarding and multi-turn acceptance at a stable URL.
6. No regressions in existing Runner providers or personal MCP connections.
7. Rollback can disable new Dot work while retaining safe cleanup and receipts.
## 13. Deliberate deferrals and open validation
Defer provider-native steering, remote kill guarantees, model selection,
multi-agent sharing of one connection, verified provider thread identity,
token accounting, arbitrary repository access and binary file transfer until a
documented capability and corresponding qualification exist.
Before public availability, validate long-duration event subscriptions and
refresh in the real Dot client; tool-catalog propagation after reconnect; large
catalog/result behavior; and hosted app approval/distribution requirements for
the selected release channel. The successful short lab test proves none of
those by itself.
The largest implementation risk is accidentally treating Dot as a controllable
process. The design must continue to work when it is an independent remote
assistant that can be delayed, retain unrelated context, or keep doing things
outside Paperclip after its Paperclip authority ends.
## 14. Sources and implementation anchors
- [Prototype and actual live acceptance](../dot-runner-prototype.md)
- [Runner architecture](../architecture/paperclip-runner.md)
- [Runner package ownership and production boundary](../../packages/paperclip-runner/README.md)
- [Public MCP identity, tools and hosted broker](../public-mcp.md)
- [Current Dot reference driver](../../packages/paperclip-runner/src/drivers/dot/dot-harness-driver.ts)
- [Current MCP bridge](../../server/src/services/public-mcp/dot-runner.ts)
- [Native provider selection](../../server/src/services/native-runtime/provider-profile.ts)
- [Production native executor](../../server/src/services/native-runtime/native-session-executor.ts)
- [Rust provider dispatch](../../packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs)
- [Production tool authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts)
- [OpenAI MCP Events](https://developers.openai.com/plugins/build/mcp-events),
checked 2026-10-02: Dot support, asynchronous webhook acknowledgement,
callback verification, refresh, ordering and delivery limits.
- [OpenAI plugin quickstart](https://developers.openai.com/plugins/quickstart)
- [OpenAI Dot documentation](https://learn.chatgpt.com/docs/dots)
This plan is saved in the existing isolated worktree. There is no assigned
Paperclip issue or authenticated issue-artifact context in this Codex chat, so
it is a repository plan rather than an uploaded issue artifact. When execution
is assigned in Paperclip, link this plan as its plan document/work product.
## 15. First-time onboarding checkpoint — 2026-10-07
The live onboarding attempt found and fixed two authorization problems. ChatGPT’s client metadata prefers `private_key_jwt` while also publishing `none` as a supported method; Paperclip now negotiates the supported public-client method while retaining S256 PKCE. Dot’s browser blocks the temporary Cloudflare hostname even though ChatGPT’s MCP discovery service reaches it. An optional `PAPERCLIP_MCP_AUTHORIZATION_ORIGIN` separates the browser authorization ingress from the MCP resource, issuer, and token ingress. Both routes use existing operator-approved tunnels; no new hosted infrastructure was created.
An operator-issued, expiring, one-use pairing capability now lets Dot approve its exact agent grant without receiving an operator password or board session. The preview validates the pending request, binding, company, current operator membership, and agent status, and displays company, agent, permitted task operations, and access duration before submission. Approval and binding consumption are atomic, and the resulting token remains restricted to the Runner resource and exact agent. The copied setup prompt uses this path and retains the user-requested plugin-creation authorization paragraph. The live attempt also exposed a plugin-name collision and uncertainty about entering the code; the prompt now handles an existing name for another URL, and both the prompt and consent page explain that code entry previews access while submitting Connect grants it.
The synthetic test-drive remains separate from production. Dot created the fresh custom plugin and reached the Tailscale consent page. It entered the code, confirmed the exact company, agent, permissions, and duration in its own scope preview, and stopped before submitting Connect. The human approved the exact connection in Codex. Dot could not verify an agent relay of that approval, so Codex completed the actual consent through the existing private plugin Connect action using the direct human authorization. The registered ChatGPT callback exchanged the real OAuth token successfully. Attaching the already connected app to the Dot conversation exposed its tools; Dot verified its inbox, created and verified the mailbox event subscription, and confirmed a readiness challenge triggered from Paperclip with no manual chat wakeup. The connection is ready. The first ordinary task exposed a heartbeat descriptor omission before dispatch; admission now uses the real dedicated Dot bridge descriptor, rather than the JSON-RPC facade. Its regression test verifies the same capabilities as execution without starting a process or broker. The normal UI retry completed DOT-1 through the real Rust Runner without a manual chat wakeup. Dot accepted mailbox item 2, wrote and read back the exact-nonce document, and completed both reporting steps. The ordinary finalizer committed Done; run `9a44feeb-2779-4217-8210-092852019511` succeeded with exit code 0, no remaining work or verification caveats, and unknown provider usage/cost retained as null. The live connection and local-controller task path are qualified; stable production ingress and hosted/remote modes remain outside this acceptance.
Verification: the focused OAuth/onboarding API suite passed 11 cases, the affected UI suites passed 27 cases, the relevant server/UI typechecks and token gates passed, and the workspace build passed. The earlier focused Runner/MCP suites also passed. A full `test:run` was stopped after unrelated failures and a 300-second workspace snapshot stress timeout; isolated tool-access and event-sequencing rechecks passed, but the full suite is incomplete. After the admission fix, 34 Runner/driver tests and 13 real Rust/broker integration tests passed, and full workspace typecheck and build passed again. The full repository suite is still incomplete, so this checkpoint is not a PR-ready verification claim.
During deployment, macOS exhausted its PostgreSQL shared-memory IDs. One confirmed unused PostgreSQL interlock with zero attachments and an exited creator was reclaimed; no running database was stopped. Another development instance had occupied the old API port during the outage, so this test-drive and its existing public tunnels were moved to port 3109. The temporary MCP tunnel hostname consequently changed; fresh plugin onboarding must use the current runtime prompt rather than an old copied URL or expired pairing code.
## 2026-10-07 full Runner capability extension
User authorized implementation and live qualification of all six expansion areas. Delivery scope: idle request admission with normal run ownership; human assignees and person discovery; authorized cross-task tools; pinned skill reads and assigned MCP gateway relay; explicitly enabled host workspace tools and verified artifacts; truthful capability discovery; rolling lease renewal, pagination and follow-up input. Preserve provider limits for model choice, usage, cost, and unconfirmed global external stopping.
Acceptance: an idle Dot creates a hello task assigned to the responsible person; an assigned Dot reads a pinned skill, uses an authorized assigned gateway tool, produces a verified downloadable file, handles follow-up input, renews its lease, and cannot continue mutations after fencing. Verify replay, company isolation, budget stops and permission denials. Do not reseed the synthetic test-drive from production.
### Full Runner acceptance checkpoint
The real Dot created **hello** assigned to the verified responsible human, read
its pinned synthetic skill, used attributed cross-task comments and documents,
ran a sandboxed command, incorporated a follow-up comment, renewed its lease,
and registered a report whose downloaded bytes and SHA-256 matched the receipt.
A missing initial fixture exposed a definite read exception without a terminal
receipt; the operator cancelled that run and Dot acknowledged its fence. Read
failures now settle as bounded errors. A real Rust regression proves that later
tools and finalization still work after a missing-file error.
Dot then initiated its own **DOT-4** intake through `paperclip_dot_request_turn`,
created **hello from idle** for the human owner, verified the missing-file error,
and wrote/read/executed/registered `idle-proof.txt`. The downloaded 20-byte file
contains `idle-runner-verified` and matches its registered SHA-256. Both
completion steps succeeded; the normal run exited 0 and the ordinary finalizer
committed Done. The offer required a direct inbox check; no automation wake was
observed for that offer. Intake responses now include an available assignment
and instruct stable same-request polling while admission is pending.
Review also found overlapping hash-based writes and missing lock entries.
Workspace calls now serialize across the local controller's canonical workspace
lane; a concurrent-write regression permits one commit and rejects the stale
edit. The lockfile includes compression and the existing manifest dependencies.
OpenAPI includes the capability-authenticated pairing preview and redemption
routes. The expanded API catalog's comparison fixture has been synchronized.
Real Rust/PostgreSQL qualification covers the configured assigned gateway with
a synthetic service, not a live external app account. Linux command sandboxing,
hosted/remote controllers, provider-native control and the complete future fault
matrix remain outside this supported local-controller acceptance. Automatic
inbound attachment staging remains disabled. Full local suite and fresh PR CI
are tracked in PR #15414 and are not assumed green from targeted checks.
@@ -20,6 +20,8 @@ accepts the caller's explicit native model; no allowlist, hardcoded default or
fallback is added. Pi profile 19 has command digest
`sha256:b7647ebf97f802ca053ec3384c912bf0e8d18eba308d27397bb1d95a37220825`.
The merge onto newer master uses Pi profile 20 (`sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e`). Its declaration differs from profile 19 only in the revision and the shared sandbox source hash: master adds configured task environment keys in the Codex-only branch. Pi runtime, wrapper, extension, closures, model configuration, credential environment and recovery identity hashes are unchanged. The seven live results above remain evidence for their original profile 19 source; profile 20 integration is verified by current-head tests and CI.
| Core path | Canonical campaign | Retained evidence |
| --- | --- | --- |
| Four typed questions, browser reconnects and exact saved answers | `pi-core19-0-1791477277` | 42 files; SHA256 `5c0c5fdfeca97b689a0f299935fd5cb62d65c38a056b8ff4f2dfc888cd795b24` |
@@ -109,6 +111,8 @@ harness `ee3b094d35719e4dd7cbc791d1924205c4fa474f` and immutable image
Their canonical results and independent cleanup pass; their host stops normally.
They do not substitute for the seven fresh profile-19 results above.
The merge onto newer master uses Pi profile 20 (`sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e`). Its declaration differs from profile 19 only in the revision and the shared sandbox source hash: master adds configured task environment keys in the Codex-only branch. Pi runtime, wrapper, extension, closures, model configuration, credential environment and recovery identity hashes are unchanged. The seven live results above remain evidence for their original profile 19 source; profile 20 integration is verified by current-head tests and CI.
| Core path | Canonical campaign | Retained evidence |
| --- | --- | --- |
| File edit, one exact successful command, authenticated public download, registered deliverable, finish, Done and finalized copy-back | `pi-core-0-1791470879` | 23 files; archive SHA256 `9a1c41e2ccad75f89a069ff571a3aa9f8cb94ae80c1bd397f7c886338b9a09bd`; definition 5 |
@@ -0,0 +1,47 @@
# Native connection evidence and continuation repair
## Finish line
Fix the observed decline-grading races and qualify an explicit per-turn native
completion reminder. Keep the prompt reduction in #15489 draft. Do not merge.
## Changes and evidence
The evaluator waits for the final successful lead run's attributed saved reply
before grading a decline, with the original deadline unchanged. Wrong wording
still fails. Both explanation checks recognize the observed unavailable/rejected
and "wasn't able to pull" responses. The exact grading input is retained before
later refreshes. A recorded tool-action rejection may await its bound continuation
within the same deadline, just like an executed approval.
Compact resumed inputs remind the provider to obtain an accepted completion or
block report for the current turn. Earlier reports and final prose are not a
replacement. The strict native result gate, original connection prompt, tool
catalog, permission enforcement and automatic retry policy remain in place.
This is an instruction repair hypothesis; local tests alone do not establish
that a model will follow it.
## Bounded comparison
Freeze a common evaluator baseline on master `fc6304dfe5f2e446e09bd052a7b45f51e930f250`.
Use identical evaluator, fixture, prompt, model, profile, permission and budget
inputs in both variants. The candidate alone adds the compact per-turn reminder.
Select the fifteen explicit `native-connection-guidance` local cells: five
connection stories across native Codex `gpt-5.6-sol`, ACPX Claude `claude-sonnet-5`,
and OpenCode `openrouter/deepseek/deepseek-v4-flash-0731`. One attempt per variant
and cell, no automatic retries, 720000 ms cell deadlines, 1000-cent company and
agent hard stops, and three concurrent cells per campaign. Count every actual
run, including failures; preserve source and grader hashes and incomplete billing.
The old campaigns 37688433682 and 37688449963 remain original 11/15 and 12/15
results with two new failures. This follow-up does not regrade or qualify those
trees. A passing workflow command is not a behavioral grade. Inspect saved output,
accepted reports, exact pairs, decisions, tool use and cleanup before readiness.
## Validation so far
- 25 focused evaluator tests pass, including delayed/missing/wrong-run replies,
incorrect output, and accepted/rejected continuation deadlines.
- 194 native contract, instruction-delivery and session tests pass. A correct
visible provider answer without a semantic completion still fails closed.
- Full validation and the fresh paired live comparison are pending.
@@ -0,0 +1,180 @@
# Personal primary agent
## Current boundary
The user approved the revised Storybook design on 2026-10-07 and requested full
implementation plus acceptance testing in a real isolated test drive. This branch
now includes persistence, API wiring, and production defaults.
- Branch: `codex/personal-primary-agent-storybook`.
- Preview: http://localhost:6017/?path=/story/primary-agent-00-review-guide--start-here
- Source: `ui/storybook/stories/primary-agent/` (one feature folder).
- Shared presentation: `ui/src/components/primary-agent/PrimaryAgentPresentation.tsx`.
- Stories keep simulated state as the approved reference. The application now
mounts a separate provider backed by a user/company preference endpoint.
- Delivery includes real test-drive acceptance on desktop/mobile and required
repository checks. After reviewing the working feature, the user requested a
pull request with passing checks and merge into master.
## Agreed product contract
Each person has one primary agent per company, marked with an accessible
"My primary agent" crown on the profile and agent list. Neither the Agents
sidebar nor the Chat sidebar shows a crown. Different people can choose different agents; the
preference will persist across devices. Crowning does not change roles,
reporting lines, permissions, tasks, routines, or shared Board Operations.
- The first human-attributed creation silently initializes the primary, including
onboarding. Later creations, system provisioning, and agent-authored hires do
not replace or establish a person's preference.
- Tasks preserve explicit assignments and restored drafts, then prefer a recent
eligible assignee, then the primary, then the existing fallback.
- Chat reopens a valid recent conversation, otherwise the primary's conversation,
otherwise its existing chooser. Opening Chat does not start execution.
- The individual agent page offers Set as my primary beneath the agent details.
Kebab menus have no primary action, and there is no explicit removal option.
Standalone component examples do not offer this action. The current primary
shows its crown with no redundant set action. When a primary already exists,
switching requires a modal with the two agents' avatars and an arrow above.
The incoming primary has an outlined avatar and a crown beside its name:
"This will make [new name] your primary agent, do you want to do this?"
Cancel leaves the current primary unchanged. When none exists,
choosing one applies immediately without the modal. Stars remain independent.
Failures roll back and offer an actionable retry. These review decisions
supersede the original menu, removal, and immediate-switching design.
- The primary stays first in the Agents sidebar, even when idle; other ordering
is preserved and the primary appears only once in that section.
- Pausing and errors retain the crown and existing execution restrictions.
Leaving or terminating clears it without resetting initialization. Crowning a
previously left agent rejoins it under existing membership rules.
## Review coverage and implementation notes
Stories use the actual streamlined and classic Layout components, roster/profile
pages, composer, Chat pages/navigation, existing menus, and shared crown components.
Fixtures include idle/active, first-created result, absent/cleared/unavailable,
paused/error, independent stars, long names, loading, failed mutations, desktop,
mobile, and light theme. Interactive stories cover setting from the profile,
absence of primary menu actions, leaving, starring, rollback/retry, and opening
a task or Chat after switching, cancellation, and first choice without confirmation.
The streamlined Sidebar now includes the reviewed Agents section by default.
Stories can still supply it through Layout's existing sidebar slot for review.
The icon rail is retired in SidebarContext; collapsed coverage uses the supported
collapsible Agents section plus the mobile drawer. No rail is reintroduced.
Reloading resets the fixture preference. Task fallback is simulated through the
existing composer's defaults/recent-assignee input. Chat entry routing and the
first-created result are also simulations. These stories are design references,
not evidence of authorization, persistence, concurrency, or real execution.
## Verification at review handoff
- UI TypeScript build (`tsc -b ui`).
- 61 existing tests across SidebarAgents, AgentActionButtons, EntityRow, Agents,
and AgentOverview.
- Token gates and palette/character synchronization checks.
- Full static Storybook build.
- Browser walkthroughs: switch crown and sidebar order, independent stars,
rollback and Retry, leave/rejoin, direct profile action, task assignment/draft
precedence, recent Chat history, Chat fallback/chooser, and mobile drawer,
composer, and profile at 390 by 844.
## Implementation delivered
1. Added a company/user preference with nullable primaryAgentId and initialization
state; enforce uniqueness. Synchronize db/shared/server/ui contracts.
2. Added authenticated GET/PUT `/api/companies/:companyId/primary-agent/me`, deriving
the user from authentication, enforcing company access and agent visibility,
rejecting agent actors, and recording mutation activity.
3. Initialized transactionally during human-attributed creation. Concurrent
creations establish one primary and cannot overwrite an explicit choice or
lifecycle-cleared preference. Backfill from the earliest attributable human creation;
if that original agent is gone or terminated, leave the crown empty. Unknown
ownership remains unset.
4. Added lifecycle cleanup and existing membership rejoin behavior. Connected a
user/company-scoped query hook to the reviewed presentation and defaults.
5. Verified concurrency, backfill, lifecycle cleanup, user/company isolation
(including local trusted mode), drafts, recent selections, unavailable agents,
and real desktop/mobile task and Chat journeys.
## Real test-drive acceptance (2026-10-07)
- Isolated CLI test drive: `/tmp/paperclip-primary-agent-test-drive`, company
**Primary Agent Test Drive**, prefix `PRI`, at http://localhost:3104.
- Maia was crowned automatically through the real creation API. Creating Alex
preserved Maia. The simplified creation wizard's model-connection step rejected
the disposable placeholder credential; Alex was created through the real API
with the existing local Codex login instead. No creation-wizard success is claimed.
- Desktop: profile-only setting, cancel/confirm, persistence after reload, roster
crown, sidebar ordering, absent sidebar crowns and menu actions, leave/clear,
first choice without a modal, rejoin, and paused crown retention passed.
- Fresh browser-origin task composer selected Alex from the primary. After switching
to Maia, the composer preserved recent Alex and Chat reopened Alex's recent
conversation. An existing draft on the other origin remained intact.
- Opening Chat used the primary with no new execution. Sending a message produced
**“Alex received this primary-agent chat.”** from a successful real Codex run.
- Real task **PRI-3, Acknowledge Alex’s task receipt**, completed with
**“Task received by Alex”** and status `done`. The initial probe used an unsupported
`gpt-5.4` subscription model; both agents were changed to `gpt-6.1-sol`, the successful
checks were repeated, and the obsolete probe task was cancelled.
- Mobile at 390 × 844: the reviewed modal fits, confirmation switches the crown,
and the page has no horizontal overflow. Viewport override reset afterward.
- Focused tests cover initialization, simultaneous creation/selection, audit order,
leave races, backfill, visibility boundaries, viewer self-service, auth-derived
user/company isolation, optimistic rollback/retry, session retry, task defaults,
and recent Chat precedence.
## Verification results
- Repository typecheck passed with `pnpm -r --workspace-concurrency=1 typecheck`.
Final server/UI typechecks also passed after the last feature refinements.
- `pnpm build`, `pnpm check:token-gates`, and `git diff --check` passed.
- Entire UI suite: **698 files, 7,760 tests passed**.
- Entire DB suite, run serially: **47 files, 166 tests passed**. Parallel attempts
failed to initialize embedded PostgreSQL before migrations; the serial run
resolves that migration-verification concern.
- Focused primary/default/provider checks passed: **106 tests** across final
runs (9 database/API tests, plus provider, composer, and Chat tests).
- Existing onboarding, hire idempotency, resource membership, and company import
suites passed: **125 tests**. Legacy approval plus terminalization checks:
**15 tests passed**. Import/approval expectations now assert original creator
attribution rather than dropping the new creation option.
- **Repository-wide tests are not green.** `pnpm test:run` was stopped after
roughly an hour with failures, rather than being represented as a complete pass.
`adapter-cost-failure-paths.test.ts` still has OpenCode timeout failures in a
fresh rerun. `workspace-git-snapshot-streaming.test.ts` timed out after 300 seconds.
Heartbeat database-setup timeouts passed isolated reruns. The broad run began
before the final edits and also reported primary/approval failures; those suites
passed in fresh processes against final source. Later broad-run groups did not
complete. These local failures remain disclosed; PR checks must pass on the
current head before merge.
The final-code test drive remains running at http://localhost:3104 with **Alex**
primary and **Maia's profile** open. Choose **Set as my primary**, confirm, and
reload to try persistence. New Task and Chat retain recent Alex choices by design;
the primary is their fallback when no eligible recent choice or draft exists.
## Pull request verification
- PR: https://github.com/paperclipai/paperclip/pull/15470. The user authorized
passing PR checks and merging into master.
- The rebased branch passes repository typecheck and build, token gates, and
114 focused tests. Migration replay now runs twice without replacing choices.
- The earlier OpenCode timeouts pass all 31 tests with an isolated XDG config
directory; the local config copy caused the delay. The large Git streaming test
also passes when run alone (296 seconds).
- Initial CI identified stale creation-option assertions and app remounts on
company changes. Preserve mounted app children and bind each pending mutation
to its original user/company. Refresh membership caches for primary changes
received from other devices.
- Security review identified agent mappings in company activity. Preference
events now record only the affected user and action, with no selected agent IDs.
An authenticated cross-user activity endpoint test covers this boundary.
- Review fixes pass 96 database/permission-route tests and 127 provider,
live-update, and skill-route tests. Final merge requires fresh-head CI and review.
- The affected browser suites pass all 10 tests, including onboarding, archived
company navigation, Chat fallback without execution, and slow-CPU reloads.
- The second PR run passed 52 checks and Greptile 5/5, with one OpenAPI inventory
failure. Both personal endpoints now have board-only request/response contracts
in OpenAPI. Its 14 tests and the remaining 28 tests in that shard pass locally.
@@ -0,0 +1,81 @@
# Waiting and resuming: runtime ownership and evidence
## Finish line
Map the section 5 waiting/resume boundary, strengthen the existing continuation
oracle where it can accept broken state, and measure a small current-source
sample before proposing instruction removal. This change adds eval checks and
an ownership map. It does not remove agent instructions or change scheduling.
## Ownership map
| Operation | Authority and code | Instruction decision |
| --- | --- | --- |
| Ask a durable question | Native `request_human_input` persists an interaction with a mutation receipt in `server/src/services/native-runtime/paperclip-runner-tool-authority.ts`. `packages/paperclip-runner/src/native-session-runtime.ts` records a governed wait; `server/src/services/native-runtime/status-arbiter.ts` selects `in_review` for the yielded response wake. The finalizer and status committer apply the decision. | Keep the short native routing instruction and tool contract. No manual status PATCH is needed on this path. |
| Ask through legacy API/skill | `server/src/services/issue-thread-interactions.ts` creates the interaction; the legacy caller separately moves the task to `in_review`. `server/src/routes/issues.ts` checks the review path. `skills/paperclip/SKILL.md` describes those two operations. | Keep the legacy save-question, then set-waiting-state instructions. Interaction creation alone does not own that transition. |
| Deliver a human answer | `issue-thread-interactions.ts` validates the resolver and commits the answer plus `issueQuestionResponseDeliveries` in one transaction. `question-response-delivery.ts` owns delivery claims, replay fences and continuation wake receipts. Completed-task historical answers do not enqueue new work. | Agents must use the actual answer and respect the requested scope; they need not manufacture a wake or poll for it. |
| Resume a provider's built-in question | `server/src/services/native-runtime/native-question-bridge.ts` binds the persisted question to its source run and provider request. It tries the original paused request before a fresh-wake fallback. | Keep this distinct from a semantic question that yields a terminal run. A running run is valid only with the pending native request's identity. |
| Deliver approval/review outcomes | `queueResolvedInteractionContinuationWakeup` in `server/src/routes/issues.ts`, `native-interaction-bridge.ts`, and native review dispatch/participant services preserve authoritative targets and outcomes. | Clarification is not approval. Reviewer authority, exact plan revision and rejected-action boundaries remain necessary. |
| Keep dependencies blocked and waits healthy | Native status arbitration and `server/src/services/recovery/legacy-continuation.ts` retain governance, dependency, budget and ownership gates. `pre-dispatch-review-wait.ts` distinguishes a verified no-provider review wait from an executed cancellation. | Keep first-class blockers distinct from human questions/review. Prose alone cannot create an actionable wait or authorize recovery. |
| Finish after a response | Native completion goes through assessment/status commit; legacy completion uses the status/comment API (optionally the installed helper). Task documents and activity remain durable server records. | Runtime completion and legacy API bookkeeping are different contracts. This audit does not justify removing the latter. |
## Existing coverage and gaps
- `continuation`: real answer changes, clarification without approval, revised
plans retaining approval, untrusted handoff text, child reuse after restart,
native question documentation, and the provider-question bridge. These use
existing browser/API/provider journeys, not a new harness.
- `everyday-workflows`: agent review handoff and service approval/decline cover
additional reviewer and tool authorization paths.
- `context-integrity`: ordered comment continuation retains identical repeated
user wording. It does not make every repeated agent comment a defect.
- `continuation-accounting` and the server question-response delivery tests:
durable receipts, duplicate delivery, restart, late gates and bounded repair.
These remain separate from task quality and instruction qualification.
The old lifecycle oracle could accept a pending question with a stale execution
lock, wrong waiting status, retry/recovery/monitor, a changed assignee, a lost
intermediate run, or a missing/replaced second answer. New mutation calibrations
reject those records even when the final task is done. Positive calibrations
retain both terminal semantic waits and a correctly bound paused provider run.
The existing content, approval and artifact checks still apply.
All waiting checkpoints now carry task activity from the public API. This is
successful persisted mutation evidence, not a failed-attempt counter. Final
output count already rejects duplicate deliverables; there is no blanket rule
that repeated comments or document revisions are invalid. A future instruction
comparison must inspect attributed writes and revisions, preserving legitimate
progress comments and actual failures. This slice does not prove that every
possible duplicate status write or premature effect is absent.
## Bounded live measurement
Use the existing trusted Product E2E workflow with the branch under test frozen
to its resolved source revision. Select exactly these four local cells, one
attempt each, with no automatic retries and verified 1,000-cent company/agent
hard stops before task creation:
- `continuation.legacy-codex.local.answer-updates-scope`
- `continuation.legacy-claude.local.answer-updates-scope`
- `continuation.runner-codex.local.question-tool-documentation`
- `continuation.runner-acpx-claude.local.question-tool-documentation`
This is a current-source canary of question waiting/resume and the stronger
oracle. It is not a candidate/baseline instruction comparison. The two native
cells ask two sequential questions; the legacy cells ask one scope question.
They cannot be pooled into a model ranking or a cost comparison. Record actual
runs, source/workflow/grader identities, original grades, cleanup and incomplete
billing coverage. Keep prior grades unchanged. Failed attempts need diagnosis,
not a reroll to produce a green total.
Approval/review, dependency unblock, provider-native paused questions, crash
races and remote execution are outside this four-cell sample. Their existing
deterministic/live suites are catalogued above, not newly qualified here.
## Next instruction decision
Retain the legacy waiting transition until the API owns it atomically (a separate
product change) or matched evidence supports another valid contract. Native
waiting is already runtime-owned. Any proposed native text reduction should
first measure the remaining prompt/tool bytes and run matched approval/question
cases; do not move legacy API instructions into native tool descriptions.
+20
View File
@@ -7,6 +7,12 @@ event needs no operator endpoint.
## Native PRP Run-Log Events
Fresh remote Codex model substitution emits `runner.model_fallback` on the
system stream with `warn` severity. Its payload contains `requestedModel`,
`effectiveModel`, and `codexCliVersion`. It records a preparation choice before
provider launch, not a failed turn or a retry. The same substitution appears as
a system warning in the task conversation. It remains local run-log data.
The hidden native coordinator writes each validated PRP event to the bound
run's existing event stream before it acknowledges the runner. The row keeps
the PRP `eventType`, source instance, source event ID, source sequence, protocol
@@ -280,6 +286,14 @@ warnings and errors remain visible.
Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry.
An unstarted legacy conversation retry waiting for execution cleanup retains
`resultJson.executionWait` with the issue, blocking run, and
`execution_owner_active` cause. A local lifecycle event records the wait and the
unchanged scheduled retry attempt once per blocking run. Repeated scheduler
checks update the same retry row without repeating that event. Promotion removes
this ownership-wait marker after cleanup; other execution holds retain their
existing dispatch gates.
If execution-continuation setup finds that a task no longer exists, is closed,
or its owner changed, the existing cancellation settlement records
`continuation_task_ownership_changed`. The run and wake request become cancelled
@@ -335,6 +349,12 @@ the enclosing workspace task. The original error and restore safety policy are
unchanged. These lines stay in the instance run log and its configured durable
storage, and are not new first-party telemetry events.
Native sandbox `environmentSyncOut` errors preserve allowlisted error codes and
bounded HTTP/exit statuses across worker RPC for this diagnostic line. The host
revalidates the envelope and keeps the original failure and recovery policy.
Provider messages, paths, response bodies, credentials, and arbitrary error data
are not copied into the new envelope. Older workers can still report `unknown`.
The optional `step` identifies the failed restore operation. For
`phase=workspace` and `step=git_integration`, `gitCommand` identifies one fixed
command family (`rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`,
+4 -1
View File
@@ -1,7 +1,10 @@
# Runner API escape hatch
`search_api` and `call_api` extend the native runner when an available dedicated
operation cannot express the requested work. Existing tools remain preferred;
operation cannot express the requested work. Use `set_task_monitor` to schedule or clear a one-shot task check; after confirming
the receipt, finish with `yielded` and `continuation.kind: "monitor"` to await that
task’s timer. `call_api` still rejects monitor/execution-policy lifecycle writes.
Existing tools remain preferred;
agents do not have to search before using them. Only two tool definitions are
advertised. The API catalog is returned on demand, never injected into the
initial prompt.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+43
View File
@@ -54,3 +54,46 @@ is safe to remove from its age, an absent PID, or a successful task response.
This change prevents new orphaned ownership. It cannot recover file changes
that an earlier failed collection discarded.
## Required remote restore failures
When a legacy sandbox run cannot copy required workspace files back, the server
keeps the original allocation for repair. It writes the failed run, the exact
lease retention intent, and a board-owned repair hold in one transaction. The
hold survives reassignment and conversation resets and coexists with newer
recovery incidents; it never supersedes them.
Cleanup stops that allocation and verifies its provider receipt. It does not
use ordinary ephemeral destruction or return the source to warm reuse. Stop
failures use the existing cleanup retry and backoff policy.
The retained source uses `retain_on_failure`. A stopped source can still incur
provider storage charges. It stays available until an operator recovers or
explicitly discards it; elapsed time never proves that its files were copied.
The task's `GET /api/issues/:id/recovery-actions` response lists these repair holds
alongside any newer active incident, including the original lease IDs. Inspect
those exact leases through `GET /api/environment-leases/:leaseId`,
confirm that cleanup stopped them, recover the missing files, and record
`workspaceRepairEvidence` through the existing recovery-action resolution API.
Send `POST /api/issues/:id/recovery-actions/resolve` with the exact recovery action
ID, `outcome: "restored"`, the task's **unchanged**
`sourceIssueStatus`, and `executionReconciliation` for the recorded source run.
Only a board operator can record this repair. It preserves the current task
owner, conversation generation, status, and execution locks. It does not queue a
continuation or replay the stopped run; after repair, any new work still follows
the normal admission rules.
The board currently shows repair guidance and an Inspect run link, not a repair
form. Recording the decision is an operator API/runbook workflow; the recovery
API response contains the exact action and lease IDs. Provider access may require
an instance administrator or Cloud support. This change does not add a repair UI.
Then use the provider's operator console to remove that exact retained
allocation when its files are no longer needed. Verify its immutable provider
allocation ID against the recorded lease before removal. The ordinary reusable
sandbox cleanup does not select `retain_on_failure` sources; do not use
environment deletion as proof that this allocation was removed. Keep the
environment and its provider credentials until recovery is complete. Recording a
repair decision does not automatically destroy it.
A new message, Retry, or `/new` cannot clear this workspace repair hold. Ordinary
local lock-timeout retries and runs without a required remote copy-back failure
keep their existing policy. This change preserves future failures; it cannot
recover files from an allocation that was already destroyed.
+3
View File
@@ -14,6 +14,8 @@
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"dev:mobile": "pnpm --filter @paperclipai/ui build && pnpm --filter @paperclipai/ui preview",
"dev:both": "node scripts/dev-both.mjs",
"prototype:dot": "pnpm --filter @paperclipai/server exec tsx scripts/dot-runner-demo.ts",
"prototype:dot:live": "pnpm --filter @paperclipai/server exec tsx scripts/dot-runner-live.ts",
"storybook": "pnpm --filter @paperclipai/ui storybook",
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
@@ -71,6 +73,7 @@
"test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts",
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
"test:runner-recovery": "vitest run server/src/services/native-runtime/native-replacement-evidence.test.ts server/src/services/native-runtime/stopped-codex-turn.test.ts server/src/services/native-runtime/native-safe-replacement.test.ts",
"test:slack-connector": "node server/src/services/connectors/slack/evals/run-regressions.mjs",
"test:e2e:browser-context": "playwright test --config tests/e2e/playwright-company-context.config.ts",
"test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts && node --test tests/runner-e2e/native-completion-checks.test.mjs tests/runner-e2e/native-completion-git-source.test.mjs tests/runner-e2e/native-completion-source-contract.test.mjs",
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { readConnectionFailure } from "./connection-failure.js";
describe("readConnectionFailure", () => {
const git = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" };
const gateway = { schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason: "connection_refused" };
const mcp = { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason: "connection_refused" };
it.each([
"authentication_failed", "repository_unavailable", "invalid_remote", "dns_failure", "connection_refused",
"network_unreachable", "connection_reset", "connection_timeout", "tls_failure", "rate_limited", "remote_unavailable",
])("accepts the Git clone reason %s", (reason) => {
const input = { ...git, reason };
expect(readConnectionFailure(input)).toEqual(input);
});
it.each(["endpoint_missing", "endpoint_invalid", "insecure_transport", "credentials_missing"])(
"accepts the gateway configuration reason %s", (reason) => {
const input = { ...gateway, operation: "configuration", reason };
expect(readConnectionFailure(input)).toEqual(input);
},
);
it.each([
"authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable", "connection_refused",
"dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure",
])("accepts the gateway request reason %s", (reason) => {
const input = { ...gateway, reason };
expect(readConnectionFailure(input)).toEqual(input);
});
it.each([
"authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable", "connection_refused",
"dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure",
])("accepts the MCP tool discovery request reason %s", (reason) => {
const input = { ...mcp, reason };
expect(readConnectionFailure(input)).toEqual(input);
});
it.each([
{ ...git, operation: "fetch" },
{ ...git, operation: "create_run" },
{ ...git, reason: "endpoint_not_found" },
{ ...gateway, operation: "clone" },
{ ...gateway, operation: "poll_status" },
{ ...gateway, operation: "stop_run" },
{ ...gateway, operation: "configuration" },
{ ...gateway, reason: "credentials_missing" },
{ ...gateway, reason: "repository_unavailable" },
{ ...gateway, reason: "timeout" },
{ ...gateway, reason: "unknown" },
{ ...gateway, provider: "other_gateway" },
{ ...gateway, provider: "hermes_local" },
{ ...mcp, operation: "create_run" },
{ ...mcp, operation: "clone" },
{ ...mcp, operation: "configuration" },
{ ...mcp, operation: "call_tool" },
{ ...mcp, reason: "credentials_missing" },
{ ...mcp, reason: "repository_unavailable" },
{ ...gateway, operation: "discover_tools" },
{ ...git, operation: "discover_tools" },
])("rejects an unsupported provider/operation/reason combination: %j", (input) => {
expect(readConnectionFailure(input)).toBeNull();
});
it.each([
undefined, null, false, 1, "connection_refused", [], [gateway], {},
{ ...gateway, schemaVersion: 0 }, { ...gateway, schemaVersion: 2 }, { ...gateway, schemaVersion: "1" },
{ ...gateway, schemaVersion: null }, { ...gateway, schemaVersion: undefined },
{ ...gateway, provider: null }, { ...gateway, provider: ["hermes_gateway"] },
{ ...gateway, operation: null }, { ...gateway, operation: 1 },
{ ...gateway, reason: null }, { ...gateway, reason: 1 }, { ...gateway, reason: ["connection_refused"] },
{ ...gateway, reason: { code: "ECONNREFUSED" } },
{ provider: "hermes_gateway", operation: "create_run", reason: "connection_refused" },
{ ...gateway, reason: "connection_refused " }, { ...gateway, reason: "CONNECTION_REFUSED" },
])("rejects unknown versions or malformed values: %j", (input) => {
expect(readConnectionFailure(input)).toBeNull();
});
it.each([
{ url: "https://private.example.test" },
{ account: "private-account" },
{ token: "synthetic-secret" },
{ remoteRunAccepted: false },
{ providerWorkStarted: false },
{ details: { message: "private diagnostics" } },
{ extra: undefined },
])("rejects extra metadata instead of broadening the bounded marker: %j", (extra) => {
expect(readConnectionFailure({ ...gateway, ...extra })).toBeNull();
});
it("rejects unknown own fields even when a prototype supplies a valid descriptor", () => {
const input = Object.assign(Object.create(gateway), { a: 1, b: 2, c: 3, d: 4 });
expect(readConnectionFailure(input)).toBeNull();
});
it("returns an independent bounded value without mutating its producer's input", () => {
const input = Object.freeze({ ...gateway });
const output = readConnectionFailure(input);
expect(output).toEqual(gateway);
expect(output).not.toBe(input);
expect(input).toEqual(gateway);
expect(Object.keys(output ?? {}).sort()).toEqual(["operation", "provider", "reason", "schemaVersion"]);
});
});
@@ -0,0 +1,58 @@
/**
* A bounded producer-owned explanation of an external connection failure.
* This changes error reporting only: it never proves remote acceptance,
* authorizes a retry, or turns a failed run into a success.
*/
const gitReasons = [
"authentication_failed", "repository_unavailable", "invalid_remote",
"dns_failure", "connection_refused", "network_unreachable", "connection_reset",
"connection_timeout", "tls_failure", "rate_limited", "remote_unavailable",
] as const;
const hermesConfigurationReasons = [
"endpoint_missing", "endpoint_invalid", "insecure_transport", "credentials_missing",
] as const;
const hermesRequestReasons = [
"authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable",
"connection_refused", "dns_failure", "network_unreachable", "connection_reset",
"connection_timeout", "tls_failure",
] as const;
export type GitConnectionFailure = {
schemaVersion: 1;
provider: "git";
operation: "clone";
reason: typeof gitReasons[number];
};
export type ConnectionFailure = GitConnectionFailure | {
schemaVersion: 1;
provider: "hermes_gateway";
} & ({
operation: "configuration";
reason: typeof hermesConfigurationReasons[number];
} | {
operation: "create_run";
reason: typeof hermesRequestReasons[number];
}) | {
schemaVersion: 1;
provider: "mcp_http";
operation: "discover_tools";
reason: typeof hermesRequestReasons[number];
};
/** Unknown versions, combinations and extra payload fields remain reportable. */
export function readConnectionFailure(value: unknown): ConnectionFailure | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length !== 4 || !keys.every((key) => ["schemaVersion", "provider", "operation", "reason"].includes(key)) ||
record.schemaVersion !== 1 || typeof record.reason !== "string") return null;
let reasons: readonly string[];
if (record.provider === "git" && record.operation === "clone") reasons = gitReasons;
else if (record.provider === "hermes_gateway" && record.operation === "configuration") reasons = hermesConfigurationReasons;
else if (record.provider === "hermes_gateway" && record.operation === "create_run") reasons = hermesRequestReasons;
else if (record.provider === "mcp_http" && record.operation === "discover_tools") reasons = hermesRequestReasons;
else return null;
if (!reasons.includes(record.reason)) return null;
return { schemaVersion: 1, provider: record.provider, operation: record.operation, reason: record.reason } as ConnectionFailure;
}
@@ -1107,6 +1107,7 @@ describe("sandbox adapter execution targets", () => {
// ACP keeps stdin open while it waits for a handshake. A remote child can
// exit before replying; that must close the proxy and fail the handshake.
const output = "final output\n".repeat(16_384);
const logs: string[] = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-open-stdin",
target: {
@@ -1124,11 +1125,16 @@ describe("sandbox adapter execution targets", () => {
env: {},
timeoutSec: 10,
streamOutputViaSession,
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
expect(bridge).not.toBeNull();
try {
const result = await runProxyWithInput(bridge!.agentCommand, "initialize\n", true);
expect(result.code).toBe(exitCode ?? 1);
const terminal = logs.filter((line) => line.includes("ACP process session terminal:"));
expect(terminal).toEqual([
`[paperclip] ACP process session terminal: source=remote_event outcome=${exitCode === null ? "error" : "exit"} exitCode=${exitCode ?? "unknown"} signal=unknown.\n`,
]);
if (exitCode === null) {
expect(result.stdout).toBe("");
expect(result.stderr).toContain("ENOENT");
@@ -1141,6 +1147,194 @@ describe("sandbox adapter execution targets", () => {
}
}, 15_000);
it("flushes remote errors under backpressure and retains only bounded terminal evidence", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-flush-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let emit!: (frame: string) => Promise<void>;
let complete!: () => void;
let failInput!: (error: Error) => void;
let inputStarted = false;
const inputFailure = new Promise<never>((_resolve, reject) => { failInput = reject; });
void inputFailure.catch(() => {});
const commandComplete = new Promise<void>((resolve) => { complete = resolve; });
const frame = {
type: "error", message: "synthetic-private-detail".repeat(200_000),
code: 1e100, signal: "untrusted-private-signal",
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-flush", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
if (!input.useSession) {
const script = input.args?.[1] ?? "";
if (script.startsWith("mkdir -p") && script.includes("/stdin/000000000001.json")) {
inputStarted = true;
return inputFailure;
}
return delegate.execute(input);
}
emit = async (text) => { await input.onLog?.("stdout", text); };
await commandComplete;
return { exitCode: 1, stdout: "", stderr: "", timedOut: false, signal: null, pid: null, startedAt: null };
} },
},
command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true,
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
let peer: net.Socket | undefined;
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
peer = net.createConnection({ host: "127.0.0.1", port });
peer.on("error", () => {});
peer.setEncoding("utf8");
let output = "";
peer.on("data", (chunk) => { output += chunk; });
const closed = new Promise<void>((resolve) => peer!.once("close", resolve));
await new Promise<void>((resolve) => peer!.once("connect", resolve));
peer.write(JSON.stringify({ token, type: "hello" }) + "\n");
// Confirm authentication by receiving a marker before applying backpressure.
await emit(JSON.stringify({ type: "data", stream: "stdout", data: "" }) + "\n");
await waitForCondition(() => output.length > 0, "Missing authenticated marker.");
output = "";
peer.write(JSON.stringify({ token, type: "stdin", data: "aW5wdXQ=" }) + "\n");
await waitForCondition(() => inputStarted, "Input write did not start.");
peer.pause();
// The real wrapper emits error then exit when a child fails to spawn.
// A later frame must not write after end() and abort the pending error.
await emit([
frame,
{ type: "exit", code: 1 },
{ type: "data", stream: "stdout", data: "bGF0ZQ==" },
{ type: "shutdownAck" },
].map((event) => JSON.stringify(event) + "\n").join(""));
// The reverse race matters too: an already-accepted input write can
// reject after the remote error started draining. Its end(data) must
// not abort or replace the first terminal frame.
failInput(new Error("synthetic-private-input-detail"));
await waitForCondition(
() => logs.some((line) => line.includes("ACP process session input delivery failed.")),
"Input failure was not handled.",
);
peer.resume();
await closed;
expect(JSON.parse(output)).toEqual(frame);
expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([
"[paperclip] ACP process session terminal: source=remote_event outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
failInput(new Error("test cleanup"));
complete();
peer?.destroy();
await bridge?.stop();
}
}, 15_000);
it("reports a streamed transport failure even when diagnostic persistence stalls", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-stream-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let releaseLog!: () => void;
const stalledLog = new Promise<void>((resolve) => { releaseLog = resolve; });
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-stream", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
if (input.useSession) throw new Error("synthetic-private-provider-detail");
return delegate.execute(input);
} },
},
command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true,
onLog: async (stream, chunk) => {
if (stream === "stderr") { logs.push(chunk); await stalledLog; }
},
});
try {
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
expect(result.code).toBe(1);
expect(logs).toEqual([
"[paperclip] ACP process session terminal: source=output_stream outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
releaseLog();
await bridge?.stop();
}
}, 10_000);
it("closes a failed output poll without waiting for the run log", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-poll-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let releaseLog!: () => void;
const stalledLog = new Promise<void>((resolve) => { releaseLog = resolve; });
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-poll", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
const script = input.args?.[1] ?? "";
if (script.includes("/events'/*.json")) throw new Error("synthetic-private-provider-detail");
return delegate.execute(input);
} },
},
command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream, chunk) => {
if (stream === "stderr") { logs.push(chunk); await stalledLog; }
},
});
try {
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
expect(result.code).toBe(1);
expect(logs).toEqual([
"[paperclip] ACP process session terminal: source=output_poll outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
releaseLog();
await bridge?.stop();
}
}, 10_000);
it("records an unexpected authenticated proxy close once and ignores unauthenticated peers", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-proxy-"));
cleanupDirs.push(rootDir);
const logs: string[] = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-proxy", adapterKey: "acpx", runtimeRootDir: rootDir,
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner: createLocalSandboxRunner() },
command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
for (const authenticated of [false, true]) {
const peer = net.createConnection({ host: "127.0.0.1", port });
peer.on("error", () => {});
const closed = new Promise<void>((resolve) => peer.once("close", resolve));
await new Promise<void>((resolve) => peer.once("connect", resolve));
if (authenticated) peer.end(JSON.stringify({ token, type: "hello" }) + "\n");
else peer.end();
await closed;
if (!authenticated) expect(logs).toEqual([]);
}
await waitForCondition(() => logs.length === 1, "Missing proxy-close diagnostic.");
await bridge!.stop();
expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([
"[paperclip] ACP process session terminal: source=proxy_close outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
await bridge?.stop();
}
}, 10_000);
it("buffers sandbox process session output until the local proxy connects", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-"));
cleanupDirs.push(rootDir);
+46 -10
View File
@@ -2170,6 +2170,26 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
}
};
let pollTimer: NodeJS.Timeout | null = null;
let terminalEvidenceRecorded = false;
const recordTerminalEvidence = (
source: "remote_event" | "input_delivery" | "output_poll" | "output_stream" | "proxy_close",
event: { type?: string; code?: number | null; signal?: string | null },
) => {
if (terminalEvidenceRecorded) return;
terminalEvidenceRecorded = true;
// Remote frames are untrusted. Keep the retained diagnostic bounded and
// independent of messages, commands, paths, payloads, and provider errors.
const outcome = event.type === "exit" ? "exit" : "error";
const code = typeof event.code === "number" && Number.isInteger(event.code)
&& event.code >= 0 && event.code <= 255 ? event.code : "unknown";
const signal = typeof event.signal === "string" && [
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGABRT", "SIGFPE", "SIGKILL",
"SIGSEGV", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGBUS", "SIGXCPU", "SIGXFSZ",
].includes(event.signal) ? event.signal : "unknown";
logFailureWithoutWaiting(
`[paperclip] ACP process session terminal: source=${source} outcome=${outcome} exitCode=${code} signal=${signal}.\n`,
);
};
const pendingRemoteEvents: Array<{
type?: string;
stream?: "stdout" | "stderr";
@@ -2196,22 +2216,30 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => {
if (!socket) return false;
// `stopping` can already be set by a terminal frame buffered before auth.
// Use the socket's outbound state instead: once end() has been called,
// another write would destroy it and truncate the frame still draining.
// This also covers input-delivery failure, which ends the socket directly.
if (socket.writableEnded || socket.destroyed) return true;
socket.write(jsonLine(event));
if (event.type === "exit") {
if (event.type === "exit" || event.type === "error") {
stopping = true;
// Flush the terminal frame and all earlier output before closing. An
// immediate destroy can discard the cause and leave only connection_close.
socket.end();
} else if (event.type === "error") {
stopping = true;
socket.destroy();
}
return true;
};
const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => {
const deliverRemoteEvent = (
event: (typeof pendingRemoteEvents)[number],
source: "remote_event" | "output_poll" | "output_stream" = "remote_event",
) => {
if (event.type === "shutdownAck") {
signalShutdownAcknowledged();
return;
}
if (event.type === "exit" || event.type === "error") recordTerminalEvidence(source, event);
if (socket) {
writeRemoteEventToSocket(event);
return;
@@ -2253,6 +2281,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
nextSocket.on("close", () => {
clearTimeout(authTimer);
liveSockets.delete(nextSocket);
if (authenticated && !stopping) recordTerminalEvidence("proxy_close", { type: "error" });
});
nextSocket.on("data", (chunk) => {
connectionBuffer += chunk;
@@ -2309,11 +2338,17 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
} catch {
stdinDeliveryFailed = true;
stopping = true;
recordTerminalEvidence("input_delivery", { type: "error" });
const message = "ACP process session input delivery failed.";
// Flush the diagnostic before closing; destroy() can discard it
// and leave only ACP's generic connection_close error. Do not
// expose provider error text, which may contain a command payload.
nextSocket.end(jsonLine({ type: "error", message }));
// A remote terminal frame may already be draining while this
// accepted stdin write fails. end(data) would write after end
// and discard that first failure just like socket.write(data).
if (!nextSocket.writableEnded && !nextSocket.destroyed) {
nextSocket.end(jsonLine({ type: "error", message }));
}
// stop() awaits this input chain before sending shutdown. Run-log
// persistence must not hold teardown open when it stalls or fails.
logFailureWithoutWaiting(`[paperclip] ${message}\n`);
@@ -2345,8 +2380,8 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`);
deliverRemoteEvent({ type: "error", message });
// A stuck log sink must not strand the adapter waiting for this failure.
deliverRemoteEvent({ type: "error", message }, "output_poll");
return;
} finally {
if (!stopping) {
@@ -2472,14 +2507,15 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
deliverRemoteEvent({
type: "exit",
code: typeof result.exitCode === "number" ? result.exitCode : null,
});
signal: result.signal,
}, "output_stream");
}
} catch (error) {
if (!stopping) {
deliverRemoteEvent({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}, "output_stream");
}
}
})();
@@ -1,5 +1,5 @@
export type PaperclipRunnerProvider =
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot";
export type CodexPermissionMode = "never" | "on-request" | "untrusted";
export type OpenCodePermissionMode = "allow" | "ask" | "deny";
@@ -103,6 +103,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
description:
"AWS AgentCore runs non-interactively under its qualified harness profile and Paperclip policy.",
},
openai_dot: { configurable: false, defaultMode: "provider-managed", options: [], description: "Dot uses the admitted Paperclip tool catalog. OpenAI manages its model and other tools; provider usage and global interruption are unavailable." },
acpx: {
configurable: true,
configKey: "acpxPermissionMode",
@@ -147,7 +148,7 @@ export function isPaperclipRunnerProvider(
value === "opencode" ||
value === "claude_managed" ||
value === "aws_agentcore" ||
value === "acpx"
value === "acpx" || value === "openai_dot"
);
}
@@ -935,6 +935,20 @@ describe("runChildProcess", () => {
});
describe("renderPaperclipWakePrompt", () => {
it.each([false, true])("renders the runtime checkout flag for ordinary task wakes (resume=%s)", resumedSession => {
const payload = { reason: "issue_assigned", issue: { id: "issue-1", status: "in_progress" },
comments: [], commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
fallbackFetchNeeded: false };
const claimed = renderPaperclipWakePrompt({ ...payload, checkedOutByHarness: true }, { resumedSession });
expect(claimed).toContain(resumedSession ? "checkout: already claimed by the harness for this run" :
"The harness already checked out this issue for the current run.");
for (const checkedOutByHarness of [false, undefined]) {
const unclaimed = renderPaperclipWakePrompt({ ...payload, checkedOutByHarness }, { resumedSession });
expect(unclaimed).not.toContain("checkout: already claimed");
expect(unclaimed).not.toContain("The harness already checked out this issue for the current run.");
}
});
it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => {
const payload = {
reason: "issue_commented",
@@ -1131,6 +1145,7 @@ describe("renderPaperclipWakePrompt", () => {
);
expect(prompt).toContain("server-authenticated github chat turn");
expect(prompt).toContain("Make zero Paperclip API calls");
expect(prompt).toContain("text answer that does not require structured human input");
expect(prompt).toContain("answer directly");
expect(prompt).toContain("exactly one semantic completion");
expect(prompt).toContain("summary is the user-visible final answer");
+5 -2
View File
@@ -2384,10 +2384,10 @@ function renderPaperclipWakePromptBody(
...(externalChatReaderTurn
? [
"The inline comment batch is incomplete. Before answering, call `read_current_wake_comments` without a cursor, then pass each returned `nextCursor` until `complete` is true. That closed reader exposes only the exact comments accepted for this run. Attachment entries marked `metadata_only` are not readable bytes; state that limitation instead of inferring their contents.",
"After the complete read, answer every accepted comment in order. Make zero other Paperclip API calls: do not fetch broader task history, inbox, status, artifacts, workspace, or provider connections; do not post progress or completion comments; do not write task status; and do not check out the issue again.",
"After the complete read, answer every accepted comment in order. For a text answer that does not require structured human input, use the supplied context. Make zero other Paperclip API calls: do not fetch broader task history, inbox, status, artifacts, workspace, or provider connections; do not post progress or completion comments; do not write task status; and do not check out the issue again.",
]
: [
"For a self-contained text request, answer directly from the supplied task and wake context. Make zero Paperclip API calls: do not refetch the issue, inbox, status, artifacts, workspace, or provider connections; do not post progress or completion comments; do not write task status; and do not check out the issue again.",
"For a self-contained text answer that does not require structured human input, answer directly from the supplied task and wake context. Make zero Paperclip API calls: do not refetch the issue, inbox, status, artifacts, workspace, or provider connections; do not post progress or completion comments; do not write task status; and do not check out the issue again.",
]),
"The harness owns task state and persists your final assistant response. If the runtime offers a semantic completion operation, emit exactly one semantic completion and do not duplicate that response in a Paperclip comment or status update.",
"The semantic completion summary is the user-visible final answer. Include every requested answer, exact value, description, and any actionable file-access or delivery limitation there; a statement that you read, checked, or prepared something is not a substitute. Private progress commentary is not delivered as the final answer.",
@@ -4263,6 +4263,9 @@ export function normalizePaperclipRunnerAdapterConfig(
): Record<string, unknown> {
if (adapterType !== "paperclip_runner") return config;
config = normalizeLegacyRunnerProvider(config);
if (config.provider === "openai_dot") {
return normalizePaperclipOperationalSkillPreference(adapterType, { lifecycleMode: "per_turn", ...config });
}
const next: Record<string, unknown> = {
provider: "codex",
codexPermissionMode:
@@ -1,12 +1,58 @@
import { describe, expect, it } from "vitest";
import {
CODEX_CHATGPT_MODEL_REJECTION_RE,
codexCliVersionAtLeast,
codexLocalReasoningEffortsForModel,
DEFAULT_CODEX_LOCAL_MODEL,
isCodexLocalFastModeSupported,
minimumCodexCliVersionForModel,
models,
normalizeCodexModel,
parseCodexCliVersionOutput,
} from "./index.js";
describe("codex model CLI floors", () => {
it("records the oldest Codex CLI verified to run each gated model with ChatGPT sign-in", () => {
expect(minimumCodexCliVersionForModel("gpt-6.1-sol")).toBe("0.159.0");
expect(minimumCodexCliVersionForModel(" gpt-6.1-sol ")).toBe("0.159.0");
expect(minimumCodexCliVersionForModel("gpt-6-sol")).toBe("0.157.0");
expect(minimumCodexCliVersionForModel("gpt-6-luna")).toBe("0.157.0");
});
it.each([DEFAULT_CODEX_LOCAL_MODEL, "gpt-5.6", "gpt-6-astra", "gpt-5.5", "custom-model", "", null, undefined])(
"has no floor for %s so callers skip the version probe", (model) => {
expect(minimumCodexCliVersionForModel(model)).toBeNull();
},
);
it("compares stable versions numerically and rejects prerelease or ambiguous input", () => {
expect(codexCliVersionAtLeast("0.159.0", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("0.160.1", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("1.0.0", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("0.156.1", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("0.16.0", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("0.160.0-alpha.1", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("", "0.159.0")).toBe(false);
});
it("parses only a single stable `codex-cli x.y.z` line from --version output", () => {
expect(parseCodexCliVersionOutput("codex-cli 0.160.0\n")).toBe("0.160.0");
expect(parseCodexCliVersionOutput("WARNING: PATH unchanged\r\ncodex-cli 0.156.0\r\n")).toBe("0.156.0");
expect(parseCodexCliVersionOutput("codex-cli 0.162.0-alpha.17")).toBeNull();
expect(parseCodexCliVersionOutput("codex 0.160.0")).toBeNull();
expect(parseCodexCliVersionOutput("codex-cli 0.149.0\ncodex-cli 0.160.0")).toBeNull();
expect(parseCodexCliVersionOutput("")).toBeNull();
});
it("matches the backend's ChatGPT model rejection and captures the model", () => {
const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec(
"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}",
);
expect(match?.[1]).toBe("gpt-6.1-sol");
expect(CODEX_CHATGPT_MODEL_REJECTION_RE.test("Model metadata for `gpt-6.1-sol` not found")).toBe(false);
});
});
describe("codex local adapter metadata", () => {
it("advertises current Codex-capable OpenAI models without changing the default", () => {
const modelIds = models.map((model) => model.id);
@@ -85,6 +85,60 @@ export function codexLocalReasoningEffortsForModel(
}
}
// The Codex backend accepts a model for ChatGPT sign-in only from a client that identifies as a
// recent enough CLI (the request carries the CLI version). An older `codex` fails every turn with
// "The '<model>' model is not supported when using Codex with a ChatGPT account.", which reads
// like an account problem when it is really a stale install, typically a sandbox image baked
// before the catalog gained the model. Record the oldest stable release verified to work so the
// Test and the remote runner can name the actual gap. Omit models with no verified floor; the
// install pin in the runner and images is always at or above every floor listed here.
const CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS: Readonly<Record<string, string>> = {
// 0.156.1 and older are rejected (openai/codex#49396); 0.159.0 and 0.159.2 are accepted
// (openai/codex#49464). No stable release between those was verified either way.
"gpt-6.1-sol": "0.159.0",
// Codex 0.157.0 release notes: "Add GPT-6 Sol and Luna to the model catalog".
"gpt-6-sol": "0.157.0",
"gpt-6-luna": "0.157.0",
};
export function minimumCodexCliVersionForModel(model: string | null | undefined): string | null {
return CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS[normalizeCodexModel(model)] ?? null;
}
const STABLE_CODEX_CLI_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function stableCodexCliVersionParts(version: string): number[] | null {
const match = STABLE_CODEX_CLI_VERSION.exec(version.trim());
if (!match) return null;
const parts = match.slice(1).map(Number);
return parts.every(Number.isSafeInteger) ? parts : null;
}
/** Read the version from `codex --version` output; prerelease or ambiguous output yields null. */
export function parseCodexCliVersionOutput(output: string): string | null {
const versions = output
.split(/\r?\n/)
.map((line) => /^codex-cli (\S+)$/.exec(line.trim())?.[1])
.filter((version): version is string => version !== undefined);
if (versions.length !== 1 || !stableCodexCliVersionParts(versions[0]!)) return null;
return versions[0]!;
}
export function codexCliVersionAtLeast(version: string, minimum: string): boolean {
const parsedVersion = stableCodexCliVersionParts(version);
const parsedMinimum = stableCodexCliVersionParts(minimum);
if (!parsedVersion || !parsedMinimum) return false;
for (let index = 0; index < parsedMinimum.length; index += 1) {
if (parsedVersion[index] !== parsedMinimum[index]) {
return parsedVersion[index]! > parsedMinimum[index]!;
}
}
return true;
}
export const CODEX_CHATGPT_MODEL_REJECTION_RE =
/The '([^']+)' model is not supported when using Codex with a ChatGPT account\./;
export function isCodexLocalKnownModel(model: string | null | undefined): boolean {
const normalizedModel = normalizeModelId(model);
if (!normalizedModel) return false;
@@ -175,6 +229,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).
- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '<model>' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins.
- Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, 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.
@@ -0,0 +1,182 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const { ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess } = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.156.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
})),
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return { ...actual, ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess };
});
import { testCodexAcpEnvironment } from "./acp.js";
describe("codex ACP lane model CLI floor", () => {
let cwd: string;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-version-"));
vi.stubEnv("CODEX_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-home-")));
vi.stubEnv("PAPERCLIP_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-instance-")));
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
});
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
await fs.rm(cwd, { recursive: true, force: true }).catch(() => undefined);
});
function sandboxTarget(): AdapterExecutionTarget {
return {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd: "/remote/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
};
}
it("fails the sandbox Test when the shared codex is older than the model's floor", async () => {
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
expect(result.checks.find((check) => check.code === "codex_cli_version_incompatible")).toMatchObject({
level: "error",
message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.",
detail: "Detected Codex CLI 0.156.0.",
});
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
string,
AdapterExecutionTarget,
string,
string[],
{ env: Record<string, string> },
];
expect(call[1]).toMatchObject({ kind: "remote", transport: "sandbox" });
expect(call[2]).toBe("codex");
expect(call[3]).toEqual(["--version"]);
expect(call[4].env).toEqual({ OPENAI_API_KEY: "sk-test" });
});
it("probes the codex the agent's configured env selects, not the image default", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.160.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
});
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
cwd,
model: "gpt-6.1-sol",
env: {
OPENAI_API_KEY: "sk-test",
PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin",
CODEX_HOME: "/remote/codex-home",
PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: false,
},
},
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
string,
AdapterExecutionTarget,
string,
string[],
{ env: Record<string, string> },
];
expect(call[3]).toEqual(["--version"]);
// String-valued entries are forwarded exactly as the ACP run receives them;
// non-string values are dropped the same way buildCodexAcpConfig consumers do.
expect(call[4].env).toEqual({
OPENAI_API_KEY: "sk-test",
PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin",
CODEX_HOME: "/remote/codex-home",
});
});
it("passes the sandbox Test when the shared codex satisfies the floor", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.160.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
});
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
});
it("does not probe the sandbox for models without a verified floor", async () => {
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false);
});
});
@@ -49,6 +49,7 @@ import {
stageCodexHomeForSync,
} from "./codex-home.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { checkCodexCliVersionForModel } from "./cli-version.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@@ -648,6 +649,32 @@ export async function testCodexAcpEnvironment(
}
}
if (targetIsRemote && commandResolvable) {
// The ACP server spawns the shared `codex` installed in the environment.
// Models with a verified CLI floor are checked against that executable so
// a stale sandbox image is named here, before a run fails on every turn
// with the backend's "not supported when using Codex with a ChatGPT
// account" rejection. Models without a floor skip the probe.
//
// Remote ACP runs receive the adapter's string-valued env entries (see
// buildCodexAcpConfig), so the probe gets the same ones. An agent that
// selects its Codex install through PATH is then measured against that
// install rather than the image default.
const probeEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") probeEnv[key] = value;
}
const versionCheck = await checkCodexCliVersionForModel({
runId: `codex-acp-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
model: asString(config.model, ""),
command: "codex",
target,
cwd: resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()),
env: probeEnv,
});
if (versionCheck) checks.push(versionCheck.check);
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const warmHandleIdleMs = asNumber(
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
@@ -0,0 +1,125 @@
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import {
CODEX_CHATGPT_MODEL_REJECTION_RE,
codexCliVersionAtLeast,
minimumCodexCliVersionForModel,
normalizeCodexModel,
parseCodexCliVersionOutput,
} from "../index.js";
export const CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE = "codex_cli_version_incompatible";
export const CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE = "codex_cli_version_compatible";
export const CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE = "codex_hello_probe_model_rejected";
/**
* Run `codex --version` where the run would execute it. Never cached: an
* operator may upgrade Codex, or promote a new sandbox image, while the
* Paperclip server keeps running, and the next Test should see the change.
*/
export async function readCodexCommandVersion(input: {
runId: string;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
}): Promise<string | null> {
try {
const result = await runAdapterExecutionTargetProcess(
input.runId,
input.target,
input.command,
["--version"],
{
cwd: input.cwd,
env: input.env,
timeoutSec: 20,
graceSec: 5,
onLog: async () => {},
},
);
if (result.timedOut || result.exitCode !== 0) return null;
return parseCodexCliVersionOutput(`${result.stdout}\n${result.stderr}`);
} catch {
return null;
}
}
function sandboxUpgradeHint(targetIsSandbox: boolean): string {
return targetIsSandbox
? "Promote a sandbox image that ships the Codex CLI version Paperclip pins, then retry the Test. Adding a model to the catalog does not update the Codex CLI baked into existing sandbox images."
: "Upgrade Codex (for example `npm install -g @openai/codex@latest`) where this agent runs, then retry the Test.";
}
/**
* Compare the installed Codex CLI with the floor the configured model needs
* for ChatGPT sign-in. Returns null when the model has no verified floor, so
* callers skip the version probe entirely for such models.
*/
export async function checkCodexCliVersionForModel(input: {
runId: string;
model: string | null | undefined;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
}): Promise<{ check: AdapterEnvironmentCheck; compatible: boolean; detectedVersion: string | null } | null> {
const minimum = minimumCodexCliVersionForModel(input.model);
if (!minimum) return null;
const model = normalizeCodexModel(input.model);
const targetIsSandbox = input.target?.kind === "remote" && input.target.transport === "sandbox";
const detected = await readCodexCommandVersion(input);
if (detected && codexCliVersionAtLeast(detected, minimum)) {
return {
compatible: true,
detectedVersion: detected,
check: {
code: CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE,
level: "info",
message: `Codex CLI ${detected} satisfies the ${minimum} minimum for ${model}.`,
},
};
}
return {
compatible: false,
detectedVersion: detected,
check: {
code: CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE,
level: "error",
message: `${model} requires Codex CLI ${minimum} or newer with ChatGPT sign-in.`,
detail: detected
? `Detected Codex CLI ${detected}.`
: "Could not determine the installed Codex CLI version from `codex --version`.",
hint: sandboxUpgradeHint(targetIsSandbox),
},
};
}
/**
* The backend rejects a model for ChatGPT sign-in with one fixed sentence.
* Map it to a dedicated check so the cause (stale CLI, or a plan that does
* not include the model) is named instead of a generic probe failure.
*/
export function codexHelloProbeModelRejectionCheck(input: {
evidence: string;
detectedVersion: string | null;
targetIsSandbox: boolean;
}): AdapterEnvironmentCheck | null {
const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec(input.evidence);
if (!match) return null;
const model = match[1]!;
const minimum = minimumCodexCliVersionForModel(model);
const versionNote = input.detectedVersion
? ` Detected Codex CLI ${input.detectedVersion}.`
: "";
return {
code: CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE,
level: "error",
message: `The Codex backend rejected ${model} for this ChatGPT account.`,
detail: `${match[0]}${versionNote}`,
hint: minimum
? `${model} needs Codex CLI ${minimum} or newer with ChatGPT sign-in. ${sandboxUpgradeHint(input.targetIsSandbox)} If the CLI is already new enough, the plan does not include this model (Enterprise and Edu administrators must enable it) or the rollout has not reached the account; choose another model or use an OpenAI API key.`
: `The ChatGPT plan does not include ${model}, the rollout has not reached the account, or the installed Codex CLI is older than the model requires. ${sandboxUpgradeHint(input.targetIsSandbox)} Otherwise choose another model or use an OpenAI API key.`,
};
}
@@ -253,6 +253,125 @@ describe("codex remote environment diagnostics", () => {
expect(probeCall?.[3]).toContain("--skip-git-repo-check");
});
// `sandboxTarget()` is declared later in this describe block; function
// declarations hoist, so the version-floor cases below reuse it.
function versionProbeResult(stdout: string) {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout,
stderr: "",
pid: 111,
startedAt: new Date().toISOString(),
};
}
it("fails before the hello probe when the sandbox Codex CLI is older than the model's floor", async () => {
// A sandbox image baked before gpt-6.1-sol reached the catalog still runs
// Codex 0.156.0. The backend would reject every turn with "not supported
// when using Codex with a ChatGPT account"; the Test must name the real
// gap (the CLI in the image) instead of running that doomed probe.
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.156.0\n"));
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
const incompatible = result.checks.find((check) => check.code === "codex_cli_version_incompatible");
expect(incompatible).toMatchObject({
level: "error",
message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.",
detail: "Detected Codex CLI 0.156.0.",
});
expect(incompatible?.hint).toContain("sandbox image");
expect(result.checks.some((check) => check.code === "codex_hello_probe_skipped_cli_version")).toBe(true);
expect(result.checks.some((check) => check.code.startsWith("codex_hello_probe_") && !check.code.includes("skipped"))).toBe(false);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]];
expect(versionCall[2]).toBe("codex");
expect(versionCall[3]).toEqual(["--version"]);
expect(prepareAdapterExecutionTargetRuntime).not.toHaveBeenCalled();
});
it("runs the hello probe when the sandbox Codex CLI satisfies the model's floor", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n"));
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
level: "info",
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(2);
});
it("skips the version probe for models without a verified floor", async () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-5.6-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const helloCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]];
expect(helloCall[3]).not.toEqual(["--version"]);
});
it("names the backend's ChatGPT model rejection instead of a generic probe failure", async () => {
// The CLI is new enough, but the account's plan or rollout does not include
// the model. The backend answers with one fixed sentence; surface it with
// the detected CLI version so the operator can tell the two causes apart.
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n"));
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "ERROR: {\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}",
pid: 222,
startedAt: new Date().toISOString(),
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
const rejected = result.checks.find((check) => check.code === "codex_hello_probe_model_rejected");
expect(rejected).toMatchObject({
level: "error",
message: "The Codex backend rejected gpt-6.1-sol for this ChatGPT account.",
detail: "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account. Detected Codex CLI 0.160.0.",
});
expect(rejected?.hint).toContain("0.159.0");
expect(rejected?.hint).toContain("plan");
expect(result.checks.some((check) => check.code === "codex_hello_probe_failed")).toBe(false);
// Not an auth failure: the managed-connection flow must not invalidate the credential.
expect(result.checks.some((check) => check.code === "codex_hello_probe_auth_required")).toBe(false);
});
it("emits the canonical adapter_auth_missing check when a sandbox hello probe reports missing auth", async () => {
// The sandbox has no seedable credentials, so the hello probe returns an
// authentication-required error. The Test must emit the neutral canonical
@@ -37,6 +37,7 @@ import {
} from "./codex-auth-cache.js";
import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { checkCodexCliVersionForModel, codexHelloProbeModelRejectionCheck } from "./cli-version.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -383,7 +384,34 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable");
if (canRunProbe) {
// Models with a verified CLI floor are compared against the installed Codex
// before the hello probe. A probe on an older CLI only produces the backend's
// "not supported when using Codex with a ChatGPT account" rejection, which
// hides the real gap: the CLI (often a stale sandbox image), not the account.
let detectedCliVersion: string | null = null;
let configuredModelIsCompatible = true;
if (canRunProbe && commandLooksLike(command, "codex")) {
const versionCheck = await checkCodexCliVersionForModel({
runId,
model: asString(config.model, ""),
command,
target,
cwd,
env,
});
if (versionCheck) {
checks.push(versionCheck.check);
configuredModelIsCompatible = versionCheck.compatible;
detectedCliVersion = versionCheck.detectedVersion;
}
}
if (canRunProbe && !configuredModelIsCompatible) {
checks.push({
code: "codex_hello_probe_skipped_cli_version",
level: "info",
message: "Skipped hello probe because the installed Codex CLI cannot use the configured model.",
});
} else if (canRunProbe) {
if (!commandLooksLike(command, "codex")) {
checks.push({
code: "codex_hello_probe_skipped_custom_command",
@@ -525,7 +553,12 @@ export async function testEnvironment(
});
}
} else {
checks.push({
const modelRejection = codexHelloProbeModelRejectionCheck({
evidence: `${parsed.errorMessage ?? ""}\n${providerStderr}\n${probe.stdout}`,
detectedVersion: detectedCliVersion,
targetIsSandbox,
});
checks.push(modelRejection ?? {
code: "codex_hello_probe_failed",
level: "error",
message: "Codex hello probe failed.",
@@ -118,6 +118,11 @@ describe("buildCodexLocalConfig", () => {
});
describe("buildPaperclipRunnerConfig", () => {
it.each([undefined, false, true])("preserves Dot attachment consent in create/import forms: %s", value => {
const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "openai_dot", dotAttachmentAccess: value } }));
expect(config.dotAttachmentAccess).toBe(value === true);
expect(config.dotWorkspaceAccess).toBe(false);
});
it("keeps only settings implemented by the Codex runner profile", () => {
const config = buildPaperclipRunnerConfig(makeValues({
codexEngine: "acp",
@@ -291,6 +291,15 @@ exit 7
const remoteWorkspace = path.join(rootDir, "remote-workspace");
const systemHomeDir = path.join(rootDir, "system-home");
const managedCaptureDir = path.join(rootDir, "managed-capture");
const fixtureBinDir = path.join(rootDir, "fixture-bin");
const networkAttemptPath = path.join(rootDir, "network-attempted");
await fs.mkdir(fixtureBinDir, { recursive: true });
// A regression in installer interception must fail locally, not download
// and execute the real CLI or depend on an external server's latency.
await fs.writeFile(path.join(fixtureBinDir, "curl"), `#!/bin/sh
: > "$FIXTURE_CURL_ATTEMPT_PATH"
exit 97
`, { mode: 0o755 });
await fs.mkdir(managedCaptureDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
@@ -337,6 +346,7 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
const runnerState = {
commands: [] as string[],
installCommands: [] as string[],
};
// The managed-runtime restore path probes the generated archive with
// `wc -c` before reading bounded `dd | base64` chunks. Keep this fixture's
@@ -345,11 +355,23 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
const runner = {
execute: async (input: { command: string; args?: string[]; env?: Record<string, string>; stdin?: string }) => {
runnerState.commands.push(input.command);
const args = [...(input.args ?? [])];
if (args[1] === SANDBOX_INSTALL_COMMAND) {
runnerState.installCommands.push(args[1]);
args[1] = buildInstallSimulationCommand(
path.join(systemHomeDir, ".local", "bin", "agent"),
managedCaptureDir,
);
}
// Exercise actual bounded file reads during managed-home restoration;
// reporting empty success for every shell command hides missing bytes.
return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, input.args ?? [], {
return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, args, {
cwd: remoteWorkspace,
env: { ...input.env, PATH: `${input.env?.PATH ?? ""}:/usr/bin:/bin` },
env: {
...input.env,
PATH: `${fixtureBinDir}:${input.env?.PATH ?? ""}:/usr/bin:/bin`,
FIXTURE_CURL_ATTEMPT_PATH: networkAttemptPath,
},
stdin: input.stdin,
timeoutSec: 30,
graceSec: 5,
@@ -402,7 +424,11 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
});
expect(result.exitCode).toBe(0);
await expect(fs.stat(networkAttemptPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(runnerState.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]);
expect(prepareInputs).toHaveLength(2);
expect(prepareInputs[1].env.HOME).toBeTruthy();
expect(prepareInputs[1].env.HOME).not.toBe(systemHomeDir);
expect(finalPreparedCommand).not.toBeNull();
expect(finalPreparedCommand).toMatch(/\.local\/(bin|sbin)\/agent$/);
const resolvedCommand = runMeta.find(Boolean)?.command as string | undefined;
+37
View File
@@ -312,6 +312,43 @@ up where the last one left off, maintaining conversation context,
memories, and tool state across heartbeats. The `sessionCodec` validates
and migrates session state between runs.
### Managed instructions and resumed turns
The local adapter reads `instructionsFilePath` on every run. It sends that
bundle, its relative-reference base directory, and the standard Paperclip API
guidance through Hermes's native `HERMES_EPHEMERAL_SYSTEM_PROMPT` overlay.
Hermes applies this overlay at each model request, including after context
compression. It does not append the overlay to saved user turns. This requires
a Hermes CLI that supports the [documented environment hook](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md).
Fresh sessions, resumed sessions, and session resets all receive the current
bundle. Edits take effect on the next run; removing the bundle removes it from
the next overlay. The server's session compatibility and conversation generation
checks still control session reuse. A missing or failed resume remains a failed
run and requires an explicit session reset; it does not automatically restart
work. The next fresh run receives the complete current instruction overlay.
The `-q` user turn contains current runtime identity, task/wake context, handoff
content, and the rendered custom `promptTemplate`, if configured. Custom templates
run on every normal wake. `quiet` changes CLI display only. Disabling persistence
uses full fresh-session context even if old session parameters are supplied.
An explicit `HERMES_EPHEMERAL_SYSTEM_PROMPT` in `env` is preserved before the
Paperclip overlay. Hermes gives this environment hook precedence over its profile's
configured personality/system prompt. Put required additional profile guidance in
that explicit environment value or in the managed bundle. Paperclip does not write
Hermes profile files or modify the parent process environment. Existing copies in
old user turns remain in history until Hermes compresses them or the session resets.
Run the credential-free capture-process regression tests with:
```sh
pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/server/execute.instructions.test.ts
```
These tests verify actual child-process arguments and environment, not provider
billing, model behavior, or measured token savings.
### Skills Integration
The adapter scans two skill sources and merges them:
@@ -607,6 +607,74 @@ describe("execute", () => {
expect(result.errorMessage).toContain("host.docker.internal");
});
it.each([
["http://127.0.0.1:8642", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642 }],
["http://[::1]:8642", { code: "ECONNREFUSED", syscall: "connect", address: "::1", port: 8642 }],
["http://localhost:8642", { code: "ECONNREFUSED", errors: [
{ code: "ECONNREFUSED", syscall: "connect", address: "::1", port: 8642 },
{ code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642 },
] }],
["https://localhost", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 443 }],
["http://localhost", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 80 }],
])("explains the server-side loopback gateway at %s without claiming non-dispatch", async (apiBaseUrl, cause) => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
throw Object.assign(new Error("fetch failed"), { cause });
});
vi.stubGlobal("fetch", fetchMock);
const ctx = makeCtx({ apiBaseUrl, apiKey: "gateway-private-key" });
ctx.onDispatch = vi.fn();
const result = await execute(ctx);
expect(result.errorMessage).toContain("refers to the Paperclip server, not an agent sandbox");
expect(result.errorMessage).toContain("adapterConfig.apiBaseUrl");
expect(result.errorMessage).toContain("hermes_local");
expect(result.errorMessage).not.toContain("gateway-private-key");
expect(result.errorMeta).toEqual({ category: "gateway_loopback_connection_refused", phase: "create_run" });
expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false,
errorCode: "hermes_gateway_connect_failed", errorFamily: "transient_upstream", retryNotBefore: null });
expect(result.executionRecovery).toBeUndefined();
expect(result.resultJson).toEqual({ connectionFailure: {
schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason: "connection_refused",
} });
expect(ctx.onDispatch).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBeUndefined();
});
it.each([
["https://gateway.example.test:8642", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642 }],
["http://localhost:8642", { code: "ECONNREFUSED", syscall: "connect", address: "192.0.2.1", port: 8642 }],
["http://localhost:8642", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 9000 }],
["http://127.0.0.2:8642", { code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642 }],
["http://localhost:8642", { code: "ETIMEDOUT", syscall: "connect", address: "127.0.0.1", port: 8642 }],
["http://localhost:8642", { code: "EHOSTUNREACH", syscall: "connect", address: "127.0.0.1", port: 8642 }],
["http://localhost:8642", { code: "ECONNREFUSED", address: "127.0.0.1", port: 8642 }],
["http://localhost:8642", { message: "ECONNREFUSED connect 127.0.0.1:8642" }],
["http://localhost:8642", { code: "ECONNREFUSED", errors: [] }],
["http://localhost:8642", { code: "ECONNREFUSED", errors: [
{ code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642 },
{ code: "ETIMEDOUT", syscall: "connect", address: "::1", port: 8642 },
] }],
])("keeps ambiguous or nonmatching transport errors generic at %s", async (apiBaseUrl, cause) => {
vi.stubGlobal("fetch", vi.fn(async () => { throw Object.assign(new Error("fetch failed"), { cause }); }));
const result = await execute(makeCtx({ apiBaseUrl, apiKey: "secret-key" }));
expect(result.errorCode).toBe("hermes_gateway_connect_failed");
expect(result.errorMessage).not.toContain("refers to the Paperclip server");
expect(result.errorMeta).toEqual({});
expect(result.executionRecovery).toBeUndefined();
});
it("does not infer loopback refusal from an HTTP provider response", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
code: "ECONNREFUSED", syscall: "connect", address: "127.0.0.1", port: 8642,
}), { status: 503 })));
const result = await execute(makeCtx({ apiBaseUrl: "http://127.0.0.1:8642", apiKey: "secret-key" }));
expect(result.errorCode).toBe("hermes_gateway_upstream_error");
expect(result.errorMeta?.category).toBeUndefined();
expect(result.errorMessage).not.toContain("refers to the Paperclip server");
expect(result.executionRecovery).toBeUndefined();
});
it("redacts echoed auth material from HTTP error payloads", async () => {
vi.stubGlobal(
"fetch",
@@ -672,6 +740,189 @@ describe("execute", () => {
});
});
describe("gateway connection failure reporting", () => {
const config = { apiBaseUrl: "https://gateway.example.test", apiKey: "gateway-private-key" };
const marker = (operation: string, reason: string) => ({
schemaVersion: 1, provider: "hermes_gateway", operation, reason,
});
it.each([
[{}, "api_base_url_missing", "endpoint_missing"],
[{ apiBaseUrl: "not a URL" }, "api_base_url_invalid", "endpoint_invalid"],
[{ apiBaseUrl: "ftp://gateway.example.test" }, "api_base_url_invalid", "endpoint_invalid"],
[{ apiBaseUrl: "http://gateway.example.test" }, "plain_http_remote_denied", "insecure_transport"],
[{ apiBaseUrl: config.apiBaseUrl }, "api_key_missing", "credentials_missing"],
])("classifies configuration failure %s without dispatch", async (settings, code, reason) => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const ctx = makeCtx(settings);
ctx.onDispatch = vi.fn();
const result = await execute(ctx);
expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false,
errorCode: `hermes_gateway_${code}`, resultJson: { connectionFailure: marker("configuration", reason) } });
expect(result.errorMessage).toBeTruthy();
expect(ctx.onDispatch).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
[401, "auth_failed", "authentication_failed", null],
[403, "auth_failed", "authentication_failed", null],
[404, "runs_unsupported", "endpoint_not_found", null],
[429, "rate_limited", "rate_limited", "transient_upstream"],
[500, "upstream_error", "remote_unavailable", "transient_upstream"],
[503, "upstream_error", "remote_unavailable", "transient_upstream"],
[599, "upstream_error", "remote_unavailable", "transient_upstream"],
])("classifies HTTP %i without changing retry or dispatch behavior", async (status, code, reason, errorFamily) => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ detail: "remote failure" }), {
status, headers: { "retry-after": "7" },
}));
vi.stubGlobal("fetch", fetchMock);
const ctx = makeCtx(config);
ctx.onDispatch = vi.fn();
const result = await execute(ctx);
expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false,
errorCode: `hermes_gateway_${code}`, errorFamily, retryNotBefore: "7",
errorMeta: { status, body: { detail: "remote failure" } },
resultJson: { connectionFailure: marker("create_run", reason) } });
expect(result.errorMessage).toContain(`Hermes gateway HTTP ${status}`);
expect(result.executionRecovery).toBeUndefined();
expect(ctx.onDispatch).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledOnce();
expect(Object.keys(result.resultJson ?? {})).toEqual(["connectionFailure"]);
});
it.each([
["ECONNREFUSED", "connection_refused"], ["ENOTFOUND", "dns_failure"], ["EAI_AGAIN", "dns_failure"],
["EHOSTUNREACH", "network_unreachable"], ["ENETUNREACH", "network_unreachable"],
["ECONNRESET", "connection_reset"], ["EPIPE", "connection_reset"], ["UND_ERR_SOCKET", "connection_reset"],
["ETIMEDOUT", "connection_timeout"], ["UND_ERR_CONNECT_TIMEOUT", "connection_timeout"],
["UND_ERR_HEADERS_TIMEOUT", "connection_timeout"], ["UND_ERR_BODY_TIMEOUT", "connection_timeout"],
["CERT_HAS_EXPIRED", "tls_failure"], ["CERT_NOT_YET_VALID", "tls_failure"],
["DEPTH_ZERO_SELF_SIGNED_CERT", "tls_failure"], ["SELF_SIGNED_CERT_IN_CHAIN", "tls_failure"],
["UNABLE_TO_VERIFY_LEAF_SIGNATURE", "tls_failure"], ["UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "tls_failure"],
["ERR_TLS_CERT_ALTNAME_INVALID", "tls_failure"],
])("classifies structured fetch cause %s", async (code, reason) => {
vi.stubGlobal("fetch", vi.fn(async () => {
throw new TypeError("fetch failed", { cause: Object.assign(new Error("external transport failed"), { code }) });
}));
const result = await execute(makeCtx(config));
expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false,
errorCode: "hermes_gateway_connect_failed", errorFamily: "transient_upstream", retryNotBefore: null,
resultJson: { connectionFailure: marker("create_run", reason) } });
expect(result.errorMessage).toContain(code);
expect(result.executionRecovery).toBeUndefined();
});
it("classifies a network failure reading the create response without rewriting its error", async () => {
const response = new Response("unread");
vi.spyOn(response, "text").mockRejectedValue(new TypeError("terminated", { cause: { code: "UND_ERR_SOCKET" } }));
vi.stubGlobal("fetch", vi.fn(async () => response));
const result = await execute(makeCtx(config));
expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false,
errorCode: "hermes_gateway_protocol_error", errorFamily: null, errorMessage: "terminated",
resultJson: { connectionFailure: marker("create_run", "connection_reset") } });
expect(result.executionRecovery).toBeUndefined();
});
it("requires every aggregate member to prove the same external failure", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("fetch failed", { cause: {
code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, { code: "ECONNREFUSED" }],
} }); }));
const result = await execute(makeCtx(config));
expect(result.resultJson).toEqual({ connectionFailure: marker("create_run", "connection_refused") });
});
it.each([
undefined, { message: "ECONNREFUSED" }, { code: "ERR_INTERNAL_ASSERTION" }, { code: "toString" },
{ code: "ECONNREFUSED", errors: [] }, { code: "ECONNREFUSED", errors: "invalid" },
{ code: "ECONNREFUSED", errors: Array.from({ length: 9 }, () => ({ code: "ECONNREFUSED" })) },
{ code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, { code: "ETIMEDOUT" }] },
{ code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, new TypeError("internal bug")] },
{ code: "ERR_INTERNAL_ASSERTION", errors: [{ code: "ECONNREFUSED" }] },
{ code: "ECONNRESET", name: "AbortError" },
])("keeps unknown, mixed, or internal transport causes reportable: %j", async (cause) => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("ECONNREFUSED", { cause }); }));
const result = await execute(makeCtx(config));
expect(result.errorCode).toBe("hermes_gateway_connect_failed");
expect(result.resultJson).toBeUndefined();
});
it("keeps abort failures reportable even if their cause looks like a transport error", async () => {
const aborted = Object.assign(new Error("cancelled", { cause: { code: "ECONNRESET" } }), { name: "AbortError" });
vi.stubGlobal("fetch", vi.fn(async () => { throw aborted; }));
const result = await execute(makeCtx(config));
expect(result.resultJson).toBeUndefined();
expect(result.errorCode).toBe("hermes_gateway_connect_failed");
});
it.each([400, 409, 422])("does not trust HTTP %i body claims about connection failures", async (status) => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
error: "ECONNREFUSED", connectionFailure: marker("create_run", "connection_refused"),
}), { status })));
const result = await execute(makeCtx(config));
expect(result.errorCode).toBe("hermes_gateway_protocol_error");
expect(result.resultJson).toBeUndefined();
});
it.each(["not json", JSON.stringify({ status: "running", connectionFailure: marker("create_run", "connection_refused") })])(
"keeps malformed successful create responses reportable", async (body) => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(body)));
const result = await execute(makeCtx(config));
expect(result.errorCode).toBe("hermes_gateway_protocol_error");
expect(result.resultJson).toBeUndefined();
},
);
it("does not classify a local response reader TypeError", async () => {
const response = new Response("unread");
vi.spyOn(response, "text").mockRejectedValue(new TypeError("cannot read property of undefined"));
vi.stubGlobal("fetch", vi.fn(async () => response));
const result = await execute(makeCtx(config));
expect(result.errorCode).toBe("hermes_gateway_protocol_error");
expect(result.resultJson).toBeUndefined();
});
it("does not trust a callback error with forged gateway codes or markers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const ctx = makeCtx(config);
ctx.onDispatch = () => { throw Object.assign(new Error("callback bug"), {
code: "hermes_gateway_auth_failed", status: 401, cause: { code: "ECONNREFUSED" },
connectionFailure: marker("create_run", "authentication_failed"),
}); };
const result = await execute(ctx);
expect(result.resultJson).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it.each(["completed", "failed", "cancelled"])("does not classify remote terminal %s from text or a forged marker", (status) => {
const result = mapFinalResultForTest({
terminal: { runId: "run-remote", status, payload: {
error: "ECONNREFUSED", code: "ECONNREFUSED",
connectionFailure: marker("create_run", "connection_refused"),
} }, outputChunks: [], sessionKey: null, strategy: "none",
});
expect(result.resultJson?.connectionFailure).toBeUndefined();
expect(result.errorCode).toBe(status === "completed" ? undefined : status === "failed" ? "hermes_gateway_run_failed" : "hermes_gateway_cancelled");
});
it("retains an ambiguous accepted-run timeout after stream, poll, and stop connection failures", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).endsWith("/v1/runs")) return new Response(JSON.stringify({ run_id: "run-accepted" }));
throw new TypeError("fetch failed", { cause: { code: "ECONNRESET" } });
});
vi.stubGlobal("fetch", fetchMock);
const ctx = makeCtx({ ...config, timeoutSec: 0.3, pollIntervalMs: 250, eventReconnectMs: 250 });
const result = await execute(ctx);
expect(result).toMatchObject({ timedOut: true, errorCode: "hermes_gateway_timeout" });
expect(result.resultJson?.connectionFailure).toBeUndefined();
expect(result.resultJson?.run_id).toBe("run-accepted");
expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/stop"))).toBe(true);
expect(ctx.onLog).toHaveBeenCalledWith("stderr", expect.stringContaining("status poll failed"));
});
});
describe("testEnvironment", () => {
it("fails remote plain HTTP before probing health", async () => {
const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
@@ -3,6 +3,7 @@ import type {
AdapterExecutionResult,
UsageSummary,
} from "@paperclipai/adapter-utils";
import type { ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
import {
asNumber,
asString,
@@ -22,6 +23,7 @@ import {
} from "../shared/constants.js";
import {
allowsInsecureRemoteHttp,
isLoopbackHostname,
isRemotePlainHttp,
remotePlainHttpDeniedMessage,
} from "./transport-security.js";
@@ -38,6 +40,7 @@ type HermesHttpError = Error & {
code?: string;
retryNotBefore?: string | null;
body?: unknown;
transportCause?: unknown;
};
type TerminalState = {
@@ -59,6 +62,72 @@ type ExecutionState = {
type TextRedactor = (value: string) => string;
type GatewayConfigurationFailure = Extract<ConnectionFailure, { provider: "hermes_gateway"; operation: "configuration" }>;
type GatewayRequestFailure = Extract<ConnectionFailure, { provider: "hermes_gateway"; operation: "create_run" }>;
// Only errors observed at our outbound request boundary acquire this provenance.
// A callback or remote payload cannot opt itself out of product error reporting.
const requestConnectionFailures = new WeakMap<Error, GatewayRequestFailure>();
const NETWORK_FAILURE_REASONS: Readonly<Record<string, GatewayRequestFailure["reason"]>> = {
ECONNREFUSED: "connection_refused",
ENOTFOUND: "dns_failure",
EAI_AGAIN: "dns_failure",
EHOSTUNREACH: "network_unreachable",
ENETUNREACH: "network_unreachable",
ECONNRESET: "connection_reset",
EPIPE: "connection_reset",
UND_ERR_SOCKET: "connection_reset",
ETIMEDOUT: "connection_timeout",
UND_ERR_CONNECT_TIMEOUT: "connection_timeout",
UND_ERR_HEADERS_TIMEOUT: "connection_timeout",
UND_ERR_BODY_TIMEOUT: "connection_timeout",
CERT_HAS_EXPIRED: "tls_failure",
CERT_NOT_YET_VALID: "tls_failure",
DEPTH_ZERO_SELF_SIGNED_CERT: "tls_failure",
SELF_SIGNED_CERT_IN_CHAIN: "tls_failure",
UNABLE_TO_VERIFY_LEAF_SIGNATURE: "tls_failure",
UNABLE_TO_GET_ISSUER_CERT_LOCALLY: "tls_failure",
ERR_TLS_CERT_ALTNAME_INVALID: "tls_failure",
};
function gatewayConfigurationFailure(reason: GatewayConfigurationFailure["reason"]): GatewayConfigurationFailure {
return { schemaVersion: 1, provider: "hermes_gateway", operation: "configuration", reason };
}
function networkFailureReason(value: unknown, depth = 0): GatewayRequestFailure["reason"] | null {
const cause = asRecord(value);
if (!cause || depth > 2 || cause.name === "AbortError") return null;
const reason = typeof cause.code === "string" && Object.hasOwn(NETWORK_FAILURE_REASONS, cause.code)
? NETWORK_FAILURE_REASONS[cause.code]!
: null;
if (cause.code !== undefined && !reason) return null;
if (cause.errors !== undefined) {
if (!Array.isArray(cause.errors) || cause.errors.length === 0 || cause.errors.length > 8) return null;
const reasons = cause.errors.map((error) => networkFailureReason(error, depth + 1));
const first = reasons[0];
return first && reasons.every((item) => item === first) && (!reason || reason === first) ? first : null;
}
return reason;
}
function rememberNetworkFailure(target: Error, error: unknown): void {
if (!(error instanceof Error) || error.name === "AbortError") return;
// Native fetch wraps transport errors in cause. Do not classify an arbitrary
// TypeError, its text, or remote JSON that happens to name a network error.
const reason = networkFailureReason(error.cause);
if (reason) requestConnectionFailures.set(target, {
schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason,
});
}
function httpFailureReason(status: number): GatewayRequestFailure["reason"] | null {
if (status === 401 || status === 403) return "authentication_failed";
if (status === 404) return "endpoint_not_found";
if (status === 429) return "rate_limited";
if (status >= 500 && status <= 599) return "remote_unavailable";
return null;
}
const CRITICAL_HEADERS = new Set([
"authorization",
"content-type",
@@ -377,9 +446,17 @@ async function fetchJson(input: RequestInfo | URL, init: RequestInit): Promise<u
} catch (err) {
const fetchErr = new Error(`Hermes gateway request failed: ${fetchFailureMessage(err)}`) as HermesHttpError;
fetchErr.code = "hermes_gateway_connect_failed";
fetchErr.transportCause = err instanceof Error ? err.cause : undefined;
rememberNetworkFailure(fetchErr, err);
throw fetchErr;
}
const body = await readResponseJson(response);
let body: unknown;
try {
body = await readResponseJson(response);
} catch (err) {
if (err instanceof Error) rememberNetworkFailure(err, err);
throw err;
}
if (!response.ok) {
const classified = classifyHttpError(response.status);
const err = new Error(`Hermes gateway HTTP ${response.status}`) as HermesHttpError;
@@ -387,6 +464,10 @@ async function fetchJson(input: RequestInfo | URL, init: RequestInit): Promise<u
err.code = classified.code;
err.retryNotBefore = response.headers.get("retry-after");
err.body = body;
const reason = httpFailureReason(response.status);
if (reason) requestConnectionFailures.set(err, {
schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason,
});
throw err;
}
return body;
@@ -758,13 +839,34 @@ function redactErrorMessage(err: unknown, redactText: TextRedactor = sanitizeSen
return redactText(String(err));
}
function errorResult(err: unknown, redactText: TextRedactor = sanitizeSensitiveText): AdapterExecutionResult {
function isConfiguredLoopbackRefusal(error: HermesHttpError, baseUrl: URL): boolean {
if (error.code !== "hermes_gateway_connect_failed" || !isLoopbackHostname(baseUrl.hostname)) return false;
const cause = asRecord(error.transportCause);
if (cause?.code !== "ECONNREFUSED") return false;
const port = Number(baseUrl.port || (baseUrl.protocol === "https:" ? 443 : 80));
const hostname = baseUrl.hostname.toLowerCase().replace(/^\[|\]$/g, "");
const refusals = Array.isArray(cause.errors) ? cause.errors : [cause];
if (refusals.length === 0 || refusals.length > 8) return false;
return refusals.every((value) => {
const refusal = asRecord(value);
return refusal?.code === "ECONNREFUSED" && refusal.syscall === "connect" &&
refusal.port === port && typeof refusal.address === "string" &&
isLoopbackHostname(refusal.address) &&
(hostname === "localhost" || refusal.address.toLowerCase() === hostname);
});
}
function errorResult(err: unknown, baseUrl: URL, redactText: TextRedactor = sanitizeSensitiveText): AdapterExecutionResult {
const hermesError = err as HermesHttpError;
const code = hermesError.code ?? "hermes_gateway_protocol_error";
const classified = hermesError.status ? classifyHttpError(hermesError.status) : null;
const loopbackRefused = isConfiguredLoopbackRefusal(hermesError, baseUrl);
const connectionFailure = err instanceof Error ? requestConnectionFailures.get(err) : undefined;
const errorMessage = code === "hermes_gateway_auth_failed"
? `${redactErrorMessage(err, redactText)}. Check adapterConfig.apiKey matches the Hermes API_SERVER_KEY for the running gateway.`
: redactErrorMessage(err, redactText);
: loopbackRefused
? `${redactErrorMessage(err, redactText)}. The configured loopback gateway refers to the Paperclip server, not an agent sandbox or your browser's machine. Check that the Hermes API server is running there, or set adapterConfig.apiBaseUrl to its address reachable from the Paperclip server. Hermes Gateway connects to an already-running gateway; use hermes_local if Paperclip should launch the local Hermes CLI.`
: redactErrorMessage(err, redactText);
return {
exitCode: 1,
signal: null,
@@ -773,7 +875,11 @@ function errorResult(err: unknown, redactText: TextRedactor = sanitizeSensitiveT
errorFamily: classified?.family ?? (code === "hermes_gateway_connect_failed" ? "transient_upstream" : null),
retryNotBefore: hermesError.retryNotBefore ?? null,
errorMessage,
...(connectionFailure ? { resultJson: { connectionFailure } } : {}),
errorMeta: {
// Diagnosis only: a redirected request may have received a response
// before refusing a later connection. This never proves non-dispatch.
...(loopbackRefused ? { category: "gateway_loopback_connection_refused", phase: "create_run" } : {}),
...(hermesError.status ? { status: hermesError.status } : {}),
...(hermesError.body ? { body: redactForLog(hermesError.body, [], 0, redactText) as Record<string, unknown> } : {}),
},
@@ -789,6 +895,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timedOut: false,
errorCode: "hermes_gateway_api_base_url_missing",
errorMessage: "Hermes gateway adapter requires apiBaseUrl.",
resultJson: { connectionFailure: gatewayConfigurationFailure("endpoint_missing") },
};
}
@@ -800,6 +907,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timedOut: false,
errorCode: "hermes_gateway_api_base_url_invalid",
errorMessage: `Invalid Hermes gateway apiBaseUrl: ${apiBaseUrlValue}`,
resultJson: { connectionFailure: gatewayConfigurationFailure("endpoint_invalid") },
};
}
if (isRemotePlainHttp(baseUrl) && !allowsInsecureRemoteHttp(ctx.config)) {
@@ -809,6 +917,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timedOut: false,
errorCode: "hermes_gateway_plain_http_remote_denied",
errorMessage: remotePlainHttpDeniedMessage(baseUrl.hostname),
resultJson: { connectionFailure: gatewayConfigurationFailure("insecure_transport") },
};
}
@@ -820,6 +929,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timedOut: false,
errorCode: "hermes_gateway_api_key_missing",
errorMessage: "Hermes gateway adapter requires apiKey.",
resultJson: { connectionFailure: gatewayConfigurationFailure("credentials_missing") },
};
}
@@ -898,7 +1008,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
}
} catch (err) {
return errorResult(err, redactText);
return errorResult(err, baseUrl, redactText);
}
await ctx.onLog("stdout", `[hermes-gateway] run created: ${runId}\n`);
+8
View File
@@ -114,6 +114,14 @@ tools, persistent memory, session persistence, skills, and MCP support.
| env | object | {} | Extra environment variables |
| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders |
Managed \`instructionsFilePath\` bundles and standard Paperclip API guidance use
Hermes's native \`HERMES_EPHEMERAL_SYSTEM_PROMPT\` overlay on every run, including
resume and reset. They are applied at request time and are not appended to user
history. The query keeps current runtime identity, wake/task content, and custom
templates. Use a Hermes CLI that supports this environment hook. An explicit
overlay in \`env\` is preserved; the hook takes precedence over Hermes's configured
profile personality/system prompt.
## Hermes-Originated Paperclip Tasks
This adapter package also ships a Hermes-facing Paperclip task bridge skill:
@@ -0,0 +1,210 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { execute } from "./execute.js";
import { sessionCodec } from "./index.js";
const SESSION_ID = "20261006_200000_abcdef";
const INSTRUCTIONS = "Synthetic governance marker: request approval before governed actions.";
interface Invocation {
args: string[];
systemPrompt: string | null;
runId: string;
}
describe("Hermes managed instruction delivery (real capture process)", () => {
let dir: string;
let command: string;
let instructionsFilePath: string;
let capturePath: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-instructions-"));
command = path.join(dir, "hermes.cjs");
instructionsFilePath = path.join(dir, "instructions.md");
capturePath = path.join(dir, "invocations.jsonl");
await fs.writeFile(instructionsFilePath, INSTRUCTIONS);
await fs.writeFile(command, `#!${process.execPath}
const fs = require("node:fs");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.CAPTURE_PATH, JSON.stringify({
args,
systemPrompt: process.env.HERMES_EPHEMERAL_SYSTEM_PROMPT ?? null,
runId: process.env.PAPERCLIP_RUN_ID,
}) + "\\n");
if (process.env.FIXTURE_STDOUT) console.log(process.env.FIXTURE_STDOUT);
if (args.includes("missing-session")) {
console.error("\\u001b[1;31mSession missing-session not found.\\u001b[0m");
process.exit(1);
}
if (process.env.FIXTURE_PROVIDER_ERROR) {
console.error(process.env.FIXTURE_PROVIDER_ERROR);
process.exit(1);
}
console.log("Fixture completed\\nsession_id: ${SESSION_ID}");
`, { mode: 0o700 });
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
function context(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-fresh",
agent: {
id: "agent-synthetic", companyId: "company-synthetic", name: "Hermes fixture",
adapterType: "hermes_local", adapterConfig: {},
},
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task-synthetic" },
config: {
hermesCommand: command, provider: "openrouter", model: "fixture-model", cwd: dir,
instructionsFilePath, env: { CAPTURE_PATH: capturePath }, timeoutSec: 10, graceSec: 1,
},
context: {
issueId: "task-synthetic",
paperclipTaskMarkdownAssignment: "Complete current synthetic task.",
paperclipTaskMarkdownCompact: "Current synthetic task.",
paperclipWake: {
reason: "issue_commented",
issue: { id: "task-synthetic", title: "Synthetic task", status: "in_progress" },
comments: [{ id: "comment-synthetic", body: "Fresh human direction." }],
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
fallbackFetchNeeded: false,
},
},
onLog: async () => {},
...overrides,
};
}
async function invocations(): Promise<Invocation[]> {
return (await fs.readFile(capturePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
}
function query(invocation: Invocation): string {
return invocation.args[invocation.args.indexOf("-q") + 1];
}
it.each([false, true])("keeps instructions out of fresh and resumed user turns (conversation=%s)", async (conversationMode) => {
const fresh = context();
fresh.context.conversationMode = conversationMode;
const first = await execute(fresh);
const params = sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null));
expect(params?.sessionId).toBe(SESSION_ID);
await execute({ ...fresh, runId: "run-resumed", runtime: { ...fresh.runtime, sessionParams: params } });
const [initial, resumed] = await invocations();
expect(initial.args).not.toContain("--resume");
expect(resumed.args.slice(resumed.args.indexOf("--resume"))).toEqual(["--resume", SESSION_ID]);
for (const invocation of [initial, resumed]) {
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain(`Resolve any relative file references from ${dir}/`);
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(query(invocation)).not.toContain("Safe multiline update pattern:");
expect(query(invocation)).toContain("Fresh human direction.");
expect(query(invocation)).toContain(invocation.runId);
expect(query(invocation)).toContain("company-synthetic");
expect(invocation.systemPrompt).toContain("$PAPERCLIP_API_KEY");
expect(invocation.systemPrompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
expect(invocation.systemPrompt).toContain("body=$(cat <<'MD'");
expect(invocation.systemPrompt).toContain("--data-binary @-");
expect(invocation.systemPrompt).toContain('$api/issues/$PAPERCLIP_TASK_ID');
expect(invocation.systemPrompt).not.toContain(invocation.runId);
}
});
it("reapplies current instructions after edits and a session reset", async () => {
const ctx = context();
await execute(ctx);
await fs.writeFile(instructionsFilePath, "Updated governance marker.");
await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
await execute({ ...ctx, runId: "run-reset" });
const [, edited, reset] = await invocations();
for (const invocation of [edited, reset]) {
expect(invocation.systemPrompt).toContain("Updated governance marker.");
expect(invocation.systemPrompt).not.toContain(INSTRUCTIONS);
expect(query(invocation)).not.toContain("Updated governance marker.");
}
expect(reset.args).not.toContain("--resume");
});
it("uses fresh wake context when persistence is disabled despite stored session params", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, persistSession: false }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [invocation] = await invocations();
expect(invocation.args).not.toContain("--resume");
expect(query(invocation)).toContain("## Paperclip Wake Payload");
expect(query(invocation)).not.toContain("## Paperclip Resume Delta");
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
expect(result.sessionParams).toBeUndefined();
});
it("keeps custom templates and quiet mode dynamic while preserving a configured system overlay", async () => {
const ctx = context();
await execute({ ...ctx, config: {
...ctx.config, quiet: true, promptTemplate: "Current custom direction for {{runId}}.",
env: { CAPTURE_PATH: capturePath, HERMES_EPHEMERAL_SYSTEM_PROMPT: "User configured system guidance." },
}, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [invocation] = await invocations();
expect(invocation.args).toContain("-Q");
expect(query(invocation)).toContain("Current custom direction for run-fresh.");
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain("User configured system guidance.");
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
});
it.each([false, true])("keeps an empty wake runnable with current identity (conversation=%s)", async (conversationMode) => {
await execute(context({ context: { conversationMode } }));
const [invocation] = await invocations();
expect(query(invocation)).toContain("run-fresh");
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
});
it("surfaces a missing session without automatically restarting work", async () => {
const ctx = context();
const result = await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: "missing-session" } } });
const calls = await invocations();
expect(calls).toHaveLength(1);
expect(calls[0].args).toContain("--resume");
expect(calls[0].systemPrompt).toContain(INSTRUCTIONS);
expect(result.exitCode).toBe(1);
expect(result.sessionParams).toBeUndefined();
});
it("does not retry provider failures as a missing session", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, env: { CAPTURE_PATH: capturePath, FIXTURE_PROVIDER_ERROR: "Error: provider unavailable" } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
expect(await invocations()).toHaveLength(1);
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toBe("Error: provider unavailable");
});
it("preserves failed work even when its output contains a missing-session diagnostic", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, env: {
CAPTURE_PATH: capturePath,
FIXTURE_STDOUT: `Already updated the task.\nSession ${SESSION_ID} not found.`,
FIXTURE_PROVIDER_ERROR: "Error: provider unavailable after tool execution",
} }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
expect(await invocations()).toHaveLength(1);
expect(result.exitCode).toBe(1);
expect(result.summary).toContain("Already updated the task.");
expect(result.errorMessage).toBe("Error: provider unavailable after tool execution");
});
it("removes an obsolete instruction overlay when the bundle is removed", async () => {
const ctx = context();
await execute(ctx);
await execute({ ...ctx, config: { ...ctx.config, instructionsFilePath: undefined }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [, resumed] = await invocations();
expect(resumed.systemPrompt).not.toContain(INSTRUCTIONS);
expect(resumed.systemPrompt).toContain("Paperclip API guidance:");
expect(query(resumed)).toContain("Fresh human direction.");
});
});
+31 -24
View File
@@ -34,7 +34,6 @@ import {
renderTemplate,
ensureAbsoluteDirectory,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
joinPromptSections,
selectPaperclipPromptSections,
stringifyPaperclipWakePayload,
@@ -82,7 +81,7 @@ export function resolveHermesCommand(config: Record<string, unknown>): string {
// Wake-up prompt builder
// ---------------------------------------------------------------------------
const HERMES_DEFAULT_PROMPT_TEMPLATE = [
const HERMES_RUNTIME_IDENTITY_TEMPLATE = [
'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.',
"",
"Paperclip runtime identity:",
@@ -90,7 +89,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
"- Company ID: {{agent.companyId}}",
"- Run ID: {{run.id}}",
"- API base: {{paperclipApiUrl}}",
"",
].join("\n");
// Hermes applies this overlay at each API call, including after compaction,
// without storing it in conversation history. Keep run/task deltas in -q.
const HERMES_SYSTEM_PROMPT_TEMPLATE = [
"Paperclip API guidance:",
"- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.",
"- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.",
@@ -112,7 +115,7 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
"MD",
")",
"jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\",
" curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\",
" curl -sS -X PATCH \"$api/issues/$PAPERCLIP_TASK_ID\" \\",
" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\",
" -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\",
" -H \"Content-Type: application/json\" \\",
@@ -141,9 +144,7 @@ export function buildPrompt(
options: { resumedSession?: boolean } = {},
): string {
const context = (ctx as any).context || {};
const template = cfgString(config.promptTemplate) || (context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: HERMES_DEFAULT_PROMPT_TEMPLATE);
const template = cfgString(config.promptTemplate);
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
@@ -200,10 +201,11 @@ export function buildPrompt(
paperclipRunIdEnv: "PAPERCLIP_RUN_ID",
};
const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake)
? ""
: renderTemplate(renderConditionalSections(template, vars), vars);
const rendered = template && !isPaperclipRecoveryWakePayload(context.paperclipWake)
? renderTemplate(renderConditionalSections(template, vars), vars)
: "";
return joinPromptSections([
renderTemplate(HERMES_RUNTIME_IDENTITY_TEMPLATE, vars),
wakePrompt,
sessionHandoffMarkdown,
taskContextMarkdown,
@@ -404,7 +406,7 @@ export async function execute(
// ── Load agent instructions file (Paperclip instruction bundles) ──────
// Paperclip can materialize managed instructions into instructionsFilePath;
// when present, inject that bundle into the Hermes prompt.
// when present, reapply that bundle through Hermes's native system overlay.
const instructionsFilePath = cfgString(config.instructionsFilePath);
let agentInstructions = "";
if (instructionsFilePath) {
@@ -430,10 +432,8 @@ export async function execute(
}
// ── Build prompt ───────────────────────────────────────────────────────
let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) });
if (agentInstructions) {
prompt = agentInstructions + "\n\n---\n\n" + prompt;
}
const sessionId = persistSession ? prevSessionId : undefined;
const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) });
// ── Build command args ─────────────────────────────────────────────────
// Use -Q (quiet) to get clean output: just response + session_id line
@@ -474,13 +474,9 @@ export async function execute(
// system is designed for human-attended interactive sessions.
args.push("--yolo");
if (persistSession && prevSessionId) {
args.push("--resume", prevSessionId);
}
if (extraArgs?.length) {
args.push(...extraArgs);
}
// Failed resumes remain failures. Do not infer a safe restart from CLI text.
if (sessionId) args.push("--resume", sessionId);
if (extraArgs?.length) args.push(...extraArgs);
// ── Build environment ──────────────────────────────────────────────────
const userEnv = config.env as Record<string, string> | undefined;
@@ -491,6 +487,17 @@ export async function execute(
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
// Scope the overlay to this child process. Read the current bundle on every
// invocation; never infer its availability from a persisted session ID.
// Preserve an operator's explicit overlay before Paperclip's instructions.
env.HERMES_EPHEMERAL_SYSTEM_PROMPT = joinPromptSections([
env.HERMES_EPHEMERAL_SYSTEM_PROMPT,
agentInstructions,
renderTemplate(HERMES_SYSTEM_PROMPT_TEMPLATE, {
agent: ctx.agent,
}),
]);
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
@@ -527,10 +534,10 @@ export async function execute(
"stdout",
`[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`,
);
if (prevSessionId) {
if (sessionId) {
await ctx.onLog(
"stdout",
`[hermes] Resuming session: ${prevSessionId}\n`,
`[hermes] Resuming session: ${sessionId}\n`,
);
}
@@ -234,19 +234,16 @@ test("keeps authoritative parent and ancestor context from task markdown", () =>
expect(prompt).not.toContain("check the issue body or comments for references");
});
test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => {
test("keeps current runtime identity in the user turn without repeating static API examples", () => {
const prompt = buildPrompt(baseContext(), {
paperclipApiUrl: "http://paperclip.local/api",
});
expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`");
expect(prompt).toContain("Displayed command logs may redact secrets");
expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"');
expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
expect(prompt).toContain("body=$(cat <<'MD'");
expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\"");
expect(prompt).toContain("--data-binary @-");
expect(prompt).not.toContain("Authorization: Bearer <");
expect(prompt).toContain("- Agent ID: agent-1");
expect(prompt).toContain("- Company ID: company-1");
expect(prompt).toContain("- Run ID: run-1");
expect(prompt).toContain("- API base: http://paperclip.local/api");
expect(prompt).not.toContain("Safe multiline update pattern:");
});
test("preserves custom prompt templates while exposing runtime and wake variables", () => {
@@ -276,7 +273,7 @@ test("preserves custom prompt templates while exposing runtime and wake variable
expect(prompt).toContain('"reason":"issue_assigned"');
expect(prompt).toContain("## Paperclip Wake Payload");
expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```");
expect(prompt).not.toContain("Paperclip runtime identity:");
expect(prompt).toContain("Paperclip runtime identity:");
});
test("keeps historical task markdown available to custom templates while automatic context uses assignment markdown", () => {
+31
View File
@@ -168,6 +168,37 @@ describeEmbeddedPostgres("resetPostgresDatabase", () => {
});
describeEmbeddedPostgres("applyPendingMigrations", () => {
it("upgrades an older clone whose replaced connection constraint is already absent", async () => {
const clusterUrl = new URL(await createTempDatabase());
const databaseName = "missing_connection_constraint";
clusterUrl.pathname = "/postgres";
await ensurePostgresDatabase(clusterUrl.toString(), databaseName);
clusterUrl.pathname = "/" + databaseName;
const connectionString = clusterUrl.toString();
const prefixFolder = await fs.promises.mkdtemp(join(tmpdir(), "paperclip-migration-prefix-"));
cleanups.push(() => fs.promises.rm(prefixFolder, { recursive: true, force: true }));
await fs.promises.mkdir(join(prefixFolder, "meta"));
const journal = JSON.parse(await fs.promises.readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx < 255);
await fs.promises.writeFile(join(prefixFolder, "meta/_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) {
await fs.promises.copyFile(new URL(`./migrations/${entry.tag}.sql`, import.meta.url), join(prefixFolder, `${entry.tag}.sql`));
}
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await migrate(drizzle(sql), { migrationsFolder: prefixFolder });
await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_transport_check"`;
await applyPendingMigrations(connectionString);
expect((await inspectMigrations(connectionString)).status).toBe("upToDate");
const [constraint] = await sql<{ definition: string }[]>`
SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE conrelid = 'tool_connections'::regclass AND conname = 'tool_connections_transport_check'
`;
expect(constraint?.definition).toContain("chat_sdk");
expect(constraint?.definition).toContain("runtime_auth");
} finally { await sql.end(); }
}, 30_000);
it("upgrades renumbered recovery migrations and replays their schema idempotently", async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
+7 -1
View File
@@ -522,7 +522,13 @@ async function applyPendingMigrationsManually(
}
}
}
await sql.unsafe(statement);
// Older dev schemas can lack a replaced constraint. Preserve the
// published SQL/hash while allowing the harmless drop to proceed.
const executable = statement.replace(
/^(\s*ALTER TABLE "[^"]+" DROP CONSTRAINT) (?!IF EXISTS\b)/i,
"$1 IF EXISTS ",
);
await sql.unsafe(executable);
}
await recordMigrationHistoryEntry(
@@ -0,0 +1,28 @@
-- Runs through the Paperclip migration executor outside a file-wide transaction.
-- Each idempotent keyset batch commits before advancing; history is recorded last.
-- Bind historical inline draft images to their first owned task. Unbound drafts
-- remain uploader-only. Attachment deletion never makes the draft company-open.
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN
LOOP
SELECT max(id::text)::uuid INTO next_id FROM (
SELECT id FROM assets WHERE id > cursor_id AND object_key LIKE company_id::text || '/assets/issues/drafts/%'
ORDER BY id LIMIT 1000
) batch;
EXIT WHEN next_id IS NULL;
INSERT INTO issue_attachments (company_id, issue_id, asset_id)
SELECT a.company_id, first_issue.id, a.id FROM assets a
CROSS JOIN LATERAL (
SELECT i.id FROM issues i WHERE i.company_id = a.company_id
AND (i.created_by_user_id = a.created_by_user_id OR i.responsible_user_id = a.created_by_user_id
OR i.created_by_agent_id = a.created_by_agent_id)
AND strpos(coalesce(i.description, ''), '/api/assets/' || a.id::text || '/content') > 0
ORDER BY i.created_at, i.id LIMIT 1
) first_issue
WHERE a.id > cursor_id AND a.id <= next_id AND a.object_key LIKE a.company_id::text || '/assets/issues/drafts/%'
ON CONFLICT DO NOTHING;
cursor_id := next_id;
COMMIT;
END LOOP;
END $$;
@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS "user_company_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"primary_agent_id" uuid,
"primary_agent_initialized" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_primary_agent_id_agents_id_fk" FOREIGN KEY ("primary_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "user_company_preferences_company_user_uq" ON "user_company_preferences" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_company_preferences_primary_agent_idx" ON "user_company_preferences" USING btree ("primary_agent_id");--> statement-breakpoint
-- Attribute only explicit human creation events. Select the original event
-- before joining the agent: deletion/termination must not promote a later hire.
WITH first_creations AS (
SELECT DISTINCT ON (company_id, actor_id)
company_id, actor_id AS user_id, entity_id AS agent_id
FROM activity_log
WHERE actor_type = 'user'
AND actor_id NOT IN ('', 'board', 'system', 'unknown', 'unknown-user')
AND entity_type = 'agent'
AND action IN ('agent.created', 'agent.hire_created')
ORDER BY company_id, actor_id, created_at, id
)
INSERT INTO user_company_preferences (company_id, user_id, primary_agent_id, primary_agent_initialized)
SELECT c.company_id, c.user_id,
CASE WHEN a.status != 'terminated' AND m.state IS DISTINCT FROM 'left'
THEN a.id ELSE NULL END,
true
FROM first_creations c
LEFT JOIN agents a ON a.id::text = c.agent_id AND a.company_id = c.company_id
LEFT JOIN agent_memberships m ON m.company_id = c.company_id AND m.user_id = c.user_id AND m.agent_id = a.id
WHERE a.metadata->'paperclipBuiltInAgent' IS NULL
ON CONFLICT (company_id, user_id) DO NOTHING;
@@ -0,0 +1,81 @@
-- Idempotent for installations that applied the pre-merge decision migration.
CREATE TABLE IF NOT EXISTS "company_decision_models" (
"company_id" uuid PRIMARY KEY NOT NULL,
"connection_id" uuid,
"grant_id" uuid,
"enabled" boolean DEFAULT false NOT NULL,
"allow_background" boolean DEFAULT true NOT NULL,
"provider" text,
"model" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "company_decisions_configured" CHECK (("company_decision_models"."connection_id" is null) = ("company_decision_models"."grant_id" is null) and (not "company_decision_models"."enabled" or ("company_decision_models"."connection_id" is not null and "company_decision_models"."provider" is not null and "company_decision_models"."model" is not null)))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "decision_invocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"feature" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text NOT NULL,
"responsible_user_id" text,
"agent_id" uuid,
"issue_id" uuid,
"project_id" uuid,
"run_id" uuid,
"identity_context_id" uuid,
"connection_id" uuid NOT NULL,
"grant_id" uuid NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"question_types" jsonb NOT NULL,
"status" text DEFAULT 'running' NOT NULL,
"error_code" text,
"provider_request_id" text,
"cost_event_id" uuid,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"duration_ms" integer,
"input_tokens" integer,
"output_tokens" integer,
CONSTRAINT "decision_invocations_status_check" CHECK ("decision_invocations"."status" in ('running', 'succeeded', 'failed', 'unknown'))
);
--> statement-breakpoint
ALTER TABLE "budget_reservations" ALTER COLUMN "run_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_reservations" ALTER COLUMN "agent_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ALTER COLUMN "agent_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_reservations" ADD COLUMN IF NOT EXISTS "decision_invocation_id" uuid;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "usage_kind" text DEFAULT 'agent' NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_decision_models_company_id_companies_id_fk' AND conrelid = 'company_decision_models'::regclass) THEN
ALTER TABLE "company_decision_models" ADD CONSTRAINT "company_decision_models_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_invocations_company_id_companies_id_fk' AND conrelid = 'decision_invocations'::regclass) THEN
ALTER TABLE "decision_invocations" ADD CONSTRAINT "decision_invocations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_invocations_cost_event_id_cost_events_id_fk' AND conrelid = 'decision_invocations'::regclass) THEN
ALTER TABLE "decision_invocations" ADD CONSTRAINT "decision_invocations_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_company_started_idx" ON "decision_invocations" USING btree ("company_id","started_at","id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_pending_idx" ON "decision_invocations" USING btree ("started_at") WHERE "decision_invocations"."status" = 'running';--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_cost_idx" ON "decision_invocations" USING btree ("cost_event_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "budget_reservations_decision_idx" ON "budget_reservations" USING btree ("company_id","decision_invocation_id");--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_source_check' AND conrelid = 'budget_reservations'::regclass) THEN
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_source_check" CHECK (("budget_reservations"."run_id" is not null)::int + ("budget_reservations"."decision_invocation_id" is not null)::int = 1);
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_events_usage_kind_check' AND conrelid = 'cost_events'::regclass) THEN
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_usage_kind_check" CHECK ("cost_events"."usage_kind" = 'decision' or ("cost_events"."usage_kind" = 'agent' and "cost_events"."agent_id" is not null));
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE "company_decision_models" DROP CONSTRAINT IF EXISTS "company_decision_models_connection_id_tool_connections_id_fk";
--> statement-breakpoint
ALTER TABLE "company_decision_models" DROP CONSTRAINT IF EXISTS "company_decision_models_grant_id_connection_grants_id_fk";
@@ -0,0 +1,189 @@
-- Dot-only additions after the merged public MCP gateway. Safe to reapply to a prototype schema.
CREATE TABLE IF NOT EXISTS "dot_agent_bindings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"operator_id" text NOT NULL,
"grant_id" uuid,
"generation" integer DEFAULT 1 NOT NULL,
"status" text DEFAULT 'pairing' NOT NULL,
"pairing_code_hash" text,
"pairing_expires_at" timestamp with time zone,
"dot_url" text,
"challenge_hash" text,
"challenge_expires_at" timestamp with time zone,
"ready_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "dot_mailbox_items" (
"id" bigserial PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"binding_generation" integer NOT NULL,
"assignment_id" uuid,
"kind" text NOT NULL,
"source_event_id" text NOT NULL,
"references" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "dot_runner_assignments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"binding_generation" integer NOT NULL,
"run_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"normalized_session_id" text NOT NULL,
"turn_id" text NOT NULL,
"revision" integer DEFAULT 1 NOT NULL,
"controller_generation" integer NOT NULL,
"catalog_digest" text NOT NULL,
"status" text NOT NULL,
"projection" jsonb NOT NULL,
"accept_by" timestamp with time zone NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"accepted_at" timestamp with time zone,
"settled_at" timestamp with time zone,
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "dot_runner_operations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"assignment_id" uuid NOT NULL,
"request_id" uuid NOT NULL,
"digest" text NOT NULL,
"command" jsonb NOT NULL,
"status" text DEFAULT 'reserved' NOT NULL,
"outcome" jsonb,
"source_event_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mcp_event_deliveries" ALTER COLUMN "activity_id" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "mcp_event_subscriptions" ALTER COLUMN "task_id" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "mcp_event_deliveries" ADD COLUMN IF NOT EXISTS "mailbox_item_id" integer;
--> statement-breakpoint
ALTER TABLE "mcp_event_subscriptions" ADD COLUMN IF NOT EXISTS "binding_id" uuid;
--> statement-breakpoint
ALTER TABLE "mcp_oauth_grants" ADD COLUMN IF NOT EXISTS "purpose" text DEFAULT 'personal' NOT NULL;
--> statement-breakpoint
ALTER TABLE "mcp_oauth_grants" ADD COLUMN IF NOT EXISTS "agent_id" uuid;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_agent_bindings_company_id_companies_id_fk' AND conrelid = 'public.dot_agent_bindings'::regclass) THEN
ALTER TABLE "dot_agent_bindings" ADD CONSTRAINT "dot_agent_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_agent_bindings_agent_id_agents_id_fk' AND conrelid = 'public.dot_agent_bindings'::regclass) THEN
ALTER TABLE "dot_agent_bindings" ADD CONSTRAINT "dot_agent_bindings_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_agent_bindings_grant_id_mcp_oauth_grants_id_fk' AND conrelid = 'public.dot_agent_bindings'::regclass) THEN
ALTER TABLE "dot_agent_bindings" ADD CONSTRAINT "dot_agent_bindings_grant_id_mcp_oauth_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."mcp_oauth_grants"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_mailbox_items_company_id_companies_id_fk' AND conrelid = 'public.dot_mailbox_items'::regclass) THEN
ALTER TABLE "dot_mailbox_items" ADD CONSTRAINT "dot_mailbox_items_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_mailbox_items_binding_id_dot_agent_bindings_id_fk' AND conrelid = 'public.dot_mailbox_items'::regclass) THEN
ALTER TABLE "dot_mailbox_items" ADD CONSTRAINT "dot_mailbox_items_binding_id_dot_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."dot_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_mailbox_items_assignment_id_dot_runner_assignments_id_fk' AND conrelid = 'public.dot_mailbox_items'::regclass) THEN
ALTER TABLE "dot_mailbox_items" ADD CONSTRAINT "dot_mailbox_items_assignment_id_dot_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."dot_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_assignments_company_id_companies_id_fk' AND conrelid = 'public.dot_runner_assignments'::regclass) THEN
ALTER TABLE "dot_runner_assignments" ADD CONSTRAINT "dot_runner_assignments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_assignments_binding_id_dot_agent_bindings_id_fk' AND conrelid = 'public.dot_runner_assignments'::regclass) THEN
ALTER TABLE "dot_runner_assignments" ADD CONSTRAINT "dot_runner_assignments_binding_id_dot_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."dot_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_assignments_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.dot_runner_assignments'::regclass) THEN
ALTER TABLE "dot_runner_assignments" ADD CONSTRAINT "dot_runner_assignments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_assignments_agent_id_agents_id_fk' AND conrelid = 'public.dot_runner_assignments'::regclass) THEN
ALTER TABLE "dot_runner_assignments" ADD CONSTRAINT "dot_runner_assignments_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_operations_company_id_companies_id_fk' AND conrelid = 'public.dot_runner_operations'::regclass) THEN
ALTER TABLE "dot_runner_operations" ADD CONSTRAINT "dot_runner_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'dot_runner_operations_assignment_id_dot_runner_assignments_id_fk' AND conrelid = 'public.dot_runner_operations'::regclass) THEN
ALTER TABLE "dot_runner_operations" ADD CONSTRAINT "dot_runner_operations_assignment_id_dot_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."dot_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_bindings_active_agent_uq" ON "dot_agent_bindings" USING btree ("company_id","agent_id") WHERE "dot_agent_bindings"."revoked_at" IS NULL;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_bindings_active_grant_uq" ON "dot_agent_bindings" USING btree ("grant_id") WHERE "dot_agent_bindings"."revoked_at" IS NULL AND "dot_agent_bindings"."grant_id" IS NOT NULL;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_bindings_pairing_code_uq" ON "dot_agent_bindings" USING btree ("pairing_code_hash");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_mailbox_event_uq" ON "dot_mailbox_items" USING btree ("binding_id","binding_generation","source_event_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "dot_mailbox_binding_cursor_idx" ON "dot_mailbox_items" USING btree ("binding_id","binding_generation","id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_assignments_turn_uq" ON "dot_runner_assignments" USING btree ("run_id","turn_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_assignments_active_binding_uq" ON "dot_runner_assignments" USING btree ("binding_id") WHERE "dot_runner_assignments"."status" IN ('offered', 'accepted');
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "dot_assignments_company_idx" ON "dot_runner_assignments" USING btree ("company_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "dot_operations_request_uq" ON "dot_runner_operations" USING btree ("assignment_id","request_id");
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_oauth_grants_agent_id_agents_id_fk' AND conrelid = 'public.mcp_oauth_grants'::regclass) THEN
ALTER TABLE "mcp_oauth_grants" ADD CONSTRAINT "mcp_oauth_grants_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "mcp_event_deliveries_mailbox_uq" ON "mcp_event_deliveries" USING btree ("subscription_id","mailbox_item_id");
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_delivery_source_check' AND conrelid = 'public.mcp_event_deliveries'::regclass) THEN
ALTER TABLE "mcp_event_deliveries" ADD CONSTRAINT "mcp_delivery_source_check" CHECK (("mcp_event_deliveries"."activity_id" IS NOT NULL AND "mcp_event_deliveries"."mailbox_item_id" IS NULL) OR ("mcp_event_deliveries"."activity_id" IS NULL AND "mcp_event_deliveries"."mailbox_item_id" IS NOT NULL));
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_subscription_resource_check' AND conrelid = 'public.mcp_event_subscriptions'::regclass) THEN
ALTER TABLE "mcp_event_subscriptions" ADD CONSTRAINT "mcp_subscription_resource_check" CHECK (("mcp_event_subscriptions"."task_id" IS NOT NULL AND "mcp_event_subscriptions"."binding_id" IS NULL) OR ("mcp_event_subscriptions"."task_id" IS NULL AND "mcp_event_subscriptions"."binding_id" IS NOT NULL));
END IF;
END $$;
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS "chat_slack_registrations" (
"endpoint_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"request_id" uuid NOT NULL,
"status" text NOT NULL,
"manifest" jsonb NOT NULL,
"manifest_hash" text NOT NULL,
"callback_uri" text NOT NULL,
"app_id" text,
"client_id" text,
"secret_ids" jsonb DEFAULT '{}'::jsonb NOT NULL,
"workspace_id" text,
"bot_user_id" text,
"error_code" text,
"created_by_user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_slack_registration_status_check" CHECK ("chat_slack_registrations"."status" in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed'))
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_slack_registrations" ADD CONSTRAINT "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"id": "adf783f2-c391-4fd5-b93a-d85e6d3c60a0",
"prevId": "9134843c-7e84-421e-915b-beab3c30acb3",
"id": "26691ae0-39a8-42ac-868d-e36ab7219073",
"prevId": "a1aefefe-8911-4583-9c52-7c658e46e9b4",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -51654,6 +51654,129 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_company_preferences": {
"name": "user_company_preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"primary_agent_id": {
"name": "primary_agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"primary_agent_initialized": {
"name": "primary_agent_initialized",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_company_preferences_company_user_uq": {
"name": "user_company_preferences_company_user_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_company_preferences_primary_agent_idx": {
"name": "user_company_preferences_primary_agent_idx",
"columns": [
{
"expression": "primary_agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_company_preferences_company_id_companies_id_fk": {
"name": "user_company_preferences_company_id_companies_id_fk",
"tableFrom": "user_company_preferences",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_company_preferences_primary_agent_id_agents_id_fk": {
"name": "user_company_preferences_primary_agent_id_agents_id_fk",
"tableFrom": "user_company_preferences",
"tableTo": "agents",
"columnsFrom": [
"primary_agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_inbox_agent_policies": {
"name": "user_inbox_agent_policies",
"schema": "",
+36 -1
View File
@@ -2185,6 +2185,41 @@
"when": 1791371746435,
"tag": "0313_private_task_access",
"breakpoints": true
},
{
"idx": 314,
"version": "7",
"when": 1791371819805,
"tag": "0314_private_task_draft_assets",
"breakpoints": true
},
{
"idx": 315,
"version": "7",
"when": 1791390905429,
"tag": "0315_rapid_emma_frost",
"breakpoints": true
},
{
"idx": 316,
"version": "7",
"when": 1791400295370,
"tag": "0316_premium_slayback",
"breakpoints": true
},
{
"idx": 317,
"version": "7",
"when": 1791416259455,
"tag": "0317_messy_famine",
"breakpoints": true
},
{
"idx": 318,
"version": "7",
"when": 1791456875836,
"tag": "0318_strong_blacklash",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,35 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import postgres from "postgres";
import { expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
itEmbeddedPostgres("backfills owned draft images without attaching another uploader's images", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-draft-privacy-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const migration = await fs.readFile(new URL("./migrations/0314_private_task_draft_assets.sql", import.meta.url), "utf8");
const hash = createHash("sha256").update(migration).digest("hex");
const company = randomUUID(), task = randomUUID(), owned = randomUUID(), foreign = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Draft privacy', 'DRAFT')`;
for (const [id, owner] of [[owned, 'owner'], [foreign, 'someone-else']]) {
await sql`INSERT INTO assets (id, company_id, provider, object_key, content_type, byte_size, sha256, created_by_user_id)
VALUES (${id}, ${company}, 'local_disk', ${`${company}/assets/issues/drafts/${id}`}, 'image/png', 1, 'test', ${owner})`;
}
await sql`INSERT INTO issues (id, company_id, title, description, visibility, created_by_user_id)
VALUES (${task}, ${company}, 'Private task', ${`![owned](/api/assets/${owned}/content) ![foreign](/api/assets/${foreign}/content)`}, 'private', 'owner')`;
for (let run = 0; run < 2; run += 1) {
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
await applyPendingMigrations(database.connectionString);
expect(await sql`SELECT issue_id, asset_id FROM issue_attachments WHERE company_id = ${company}`)
.toEqual([{ issue_id: task, asset_id: owned }]);
}
} finally {
await sql.end();
await database.cleanup();
}
}, 60_000);
+5 -2
View File
@@ -52,8 +52,9 @@ export const accountingRuntimeBaselines = pgTable("accounting_runtime_baselines"
export const budgetReservations = pgTable("budget_reservations", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "cascade" }),
decisionInvocationId: uuid("decision_invocation_id"),
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "cascade" }),
projectId: uuid("project_id"),
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
state: text("state").notNull().default("held"),
@@ -61,6 +62,8 @@ export const budgetReservations = pgTable("budget_reservations", {
settledAt: timestamp("settled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
decision: uniqueIndex("budget_reservations_decision_idx").on(table.companyId, table.decisionInvocationId),
source: check("budget_reservations_source_check", sql`(${table.runId} is not null)::int + (${table.decisionInvocationId} is not null)::int = 1`),
run: uniqueIndex("budget_reservations_run_idx").on(table.companyId, table.runId),
active: index("budget_reservations_active_idx").on(table.companyId, table.agentId, table.projectId).where(sql`${table.state} = 'held'`),
stateCheck: check("budget_reservations_state_check", sql`${table.state} in ('held', 'settled', 'released')`),
@@ -0,0 +1,27 @@
import { sql } from "drizzle-orm";
import { check, foreignKey, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import type { SlackRegistrationStatus } from "@paperclipai/shared";
import { chatEndpoints } from "./chat_channels.js";
/** App provisioning is distinct from installation and runtime credentials. No plaintext secrets. */
export const chatSlackRegistrations = pgTable("chat_slack_registrations", {
endpointId: uuid("endpoint_id").primaryKey(),
companyId: uuid("company_id").notNull(),
requestId: uuid("request_id").notNull(),
status: text("status").$type<SlackRegistrationStatus>().notNull(),
manifest: jsonb("manifest").$type<Record<string, unknown>>().notNull(),
manifestHash: text("manifest_hash").notNull(),
callbackUri: text("callback_uri").notNull(),
appId: text("app_id"),
clientId: text("client_id"),
secretIds: jsonb("secret_ids").$type<Record<string, string>>().notNull().default({}),
workspaceId: text("workspace_id"),
botUserId: text("bot_user_id"),
errorCode: text("error_code"),
createdByUserId: text("created_by_user_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => [
foreignKey({ columns: [t.companyId, t.endpointId], foreignColumns: [chatEndpoints.companyId, chatEndpoints.id] }).onDelete("cascade"),
check("chat_slack_registration_status_check", sql`${t.status} in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')`),
]);
+4 -1
View File
@@ -12,7 +12,9 @@ export const costEvents = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
agentId: uuid("agent_id").references(() => agents.id),
usageKind: text("usage_kind").notNull().default("agent"),
responsibleUserId: text("responsible_user_id"),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
projectId: uuid("project_id").references(() => projects.id),
goalId: uuid("goal_id").references(() => goals.id),
@@ -36,6 +38,7 @@ export const costEvents = pgTable(
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
usageKindCheck: check("cost_events_usage_kind_check", sql`${table.usageKind} = 'decision' or (${table.usageKind} = 'agent' and ${table.agentId} is not null)`),
receiptUniqueIdx: uniqueIndex("cost_events_company_receipt_idx").on(table.companyId, table.idempotencyKey),
providerRequestIdx: index("cost_events_provider_request_idx").on(table.companyId, table.biller, table.providerRequestId),
nonnegativeAmounts: check("cost_events_nonnegative_amounts", sql`${table.costCents} >= 0 and ${table.inputTokens} >= 0 and ${table.cachedInputTokens} >= 0 and ${table.outputTokens} >= 0`),
+51
View File
@@ -0,0 +1,51 @@
import { sql } from "drizzle-orm";
import { boolean, check, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { costEvents } from "./cost_events.js";
export const companyDecisionModels = pgTable("company_decision_models", {
companyId: uuid("company_id").primaryKey().references(() => companies.id, { onDelete: "cascade" }),
// Retain company preferences when a connection/grant is deleted. Resolution
// fails closed for stale IDs; choosing a replacement preserves sponsorship.
connectionId: uuid("connection_id"),
grantId: uuid("grant_id"),
enabled: boolean("enabled").notNull().default(false),
allowBackground: boolean("allow_background").notNull().default(true),
provider: text("provider"),
model: text("model"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => ({ configured: check("company_decisions_configured", sql`(${t.connectionId} is null) = (${t.grantId} is null) and (not ${t.enabled} or (${t.connectionId} is not null and ${t.provider} is not null and ${t.model} is not null))`) }));
/** Attribution references are archival: history survives deleted runs and connections. No input or answer content. */
export const decisionInvocations = pgTable("decision_invocations", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
feature: text("feature").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id").notNull(),
responsibleUserId: text("responsible_user_id"),
agentId: uuid("agent_id"),
issueId: uuid("issue_id"),
projectId: uuid("project_id"),
runId: uuid("run_id"),
identityContextId: uuid("identity_context_id"),
connectionId: uuid("connection_id").notNull(),
grantId: uuid("grant_id").notNull(),
provider: text("provider").notNull(),
model: text("model").notNull(),
questionTypes: jsonb("question_types").$type<string[]>().notNull(),
status: text("status").notNull().default("running"),
errorCode: text("error_code"),
providerRequestId: text("provider_request_id"),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp("finished_at", { withTimezone: true }),
durationMs: integer("duration_ms"),
inputTokens: integer("input_tokens"),
outputTokens: integer("output_tokens"),
}, t => ({
history: index("decision_invocations_company_started_idx").on(t.companyId, t.startedAt, t.id),
pending: index("decision_invocations_pending_idx").on(t.startedAt).where(sql`${t.status} = 'running'`),
cost: index("decision_invocations_cost_idx").on(t.costEventId),
statusCheck: check("decision_invocations_status_check", sql`${t.status} in ('running', 'succeeded', 'failed', 'unknown')`),
}));
+85
View File
@@ -0,0 +1,85 @@
import { sql } from "drizzle-orm";
import { bigserial, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { mcpOauthGrants } from "./public_mcp.js";
export const dotAgentBindings = pgTable("dot_agent_bindings", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
operatorId: text("operator_id").notNull(),
grantId: uuid("grant_id").references(() => mcpOauthGrants.id, { onDelete: "set null" }),
generation: integer("generation").notNull().default(1),
status: text("status").$type<"pairing" | "connected" | "ready" | "revoked">().notNull().default("pairing"),
pairingCodeHash: text("pairing_code_hash"),
pairingExpiresAt: timestamp("pairing_expires_at", { withTimezone: true }),
dotUrl: text("dot_url"),
challengeHash: text("challenge_hash"),
challengeExpiresAt: timestamp("challenge_expires_at", { withTimezone: true }),
readyAt: timestamp("ready_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => [
uniqueIndex("dot_bindings_active_agent_uq").on(t.companyId, t.agentId).where(sql`${t.revokedAt} IS NULL`),
uniqueIndex("dot_bindings_active_grant_uq").on(t.grantId).where(sql`${t.revokedAt} IS NULL AND ${t.grantId} IS NOT NULL`),
uniqueIndex("dot_bindings_pairing_code_uq").on(t.pairingCodeHash),
]);
export const dotRunnerAssignments = pgTable("dot_runner_assignments", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => dotAgentBindings.id, { onDelete: "cascade" }),
bindingGeneration: integer("binding_generation").notNull(),
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
normalizedSessionId: text("normalized_session_id").notNull(),
turnId: text("turn_id").notNull(),
revision: integer("revision").notNull().default(1),
controllerGeneration: integer("controller_generation").notNull(),
catalogDigest: text("catalog_digest").notNull(),
status: text("status").$type<"offered" | "accepted" | "settled" | "fenced">().notNull(),
projection: jsonb("projection").$type<Record<string, unknown>>().notNull(),
acceptBy: timestamp("accept_by", { withTimezone: true }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
settledAt: timestamp("settled_at", { withTimezone: true }),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, t => [
uniqueIndex("dot_assignments_turn_uq").on(t.runId, t.turnId),
uniqueIndex("dot_assignments_active_binding_uq").on(t.bindingId).where(sql`${t.status} IN ('offered', 'accepted')`),
index("dot_assignments_company_idx").on(t.companyId),
]);
export const dotRunnerOperations = pgTable("dot_runner_operations", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
assignmentId: uuid("assignment_id").notNull().references(() => dotRunnerAssignments.id, { onDelete: "cascade" }),
requestId: uuid("request_id").notNull(),
digest: text("digest").notNull(),
command: jsonb("command").$type<Record<string, unknown>>().notNull(),
status: text("status").$type<"reserved" | "admitted" | "completed" | "rejected" | "unknown">().notNull().default("reserved"),
outcome: jsonb("outcome").$type<Record<string, unknown>>(),
sourceEventId: text("source_event_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => [uniqueIndex("dot_operations_request_uq").on(t.assignmentId, t.requestId)]);
export const dotMailboxItems = pgTable("dot_mailbox_items", {
id: bigserial("id", { mode: "number" }).primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => dotAgentBindings.id, { onDelete: "cascade" }),
bindingGeneration: integer("binding_generation").notNull(),
assignmentId: uuid("assignment_id").references(() => dotRunnerAssignments.id, { onDelete: "cascade" }),
kind: text("kind").$type<"assignment" | "operation_result" | "authority_revoked" | "readiness_challenge" | "follow_up">().notNull(),
sourceEventId: text("source_event_id").notNull(),
// References only. Task content and tool results stay in authorized projections.
references: jsonb("references").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, t => [
uniqueIndex("dot_mailbox_event_uq").on(t.bindingId, t.bindingGeneration, t.sourceEventId),
index("dot_mailbox_binding_cursor_idx").on(t.bindingId, t.bindingGeneration, t.id),
]);
+4
View File
@@ -1,3 +1,4 @@
export { chatSlackRegistrations } from "./chat_slack_registrations.js";
export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { companyTransferRuns } from "./company_transfer_runs.js";
@@ -223,6 +224,7 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows
export * from "./company_skill_sources.js";
export * from "./public_mcp.js";
export * from "./dot_runner.js";
export * from "./accounting.js";
@@ -233,3 +235,5 @@ export { agentCommentary } from "./agent_commentary.js";
export { agentIdentityKeys } from "./agent_identity_keys.js";
export * from "./decision_models.js";
export { userCompanyPreferences } from "./user_company_preferences.js";
+16 -4
View File
@@ -1,6 +1,8 @@
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { boolean, check, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { authUsers } from "./auth.js";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
// OAuth client/request metadata is instance-level authentication infrastructure.
// Authority and mutation receipts are always scoped to a company and a user.
@@ -27,6 +29,8 @@ export const mcpOauthGrants = pgTable("mcp_oauth_grants", {
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
clientId: text("client_id").notNull().references(() => mcpOauthClients.id, { onDelete: "cascade" }),
resource: text("resource").notNull(),
purpose: text("purpose").$type<"personal" | "agent">().notNull().default("personal"),
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "cascade" }),
scopes: jsonb("scopes").$type<string[]>().notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -112,7 +116,8 @@ export const mcpEventSubscriptions = pgTable("mcp_event_subscriptions", {
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
grantId: uuid("grant_id").notNull().references(() => mcpOauthGrants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
taskId: uuid("task_id").notNull(),
taskId: uuid("task_id"),
bindingId: uuid("binding_id"),
arguments: jsonb("arguments").$type<Record<string, unknown>>().notNull(),
// URL, signing keys and optional Cloud authority, encrypted with the instance secret provider.
deliveryMaterial: jsonb("delivery_material").$type<Record<string, unknown>>().notNull(),
@@ -121,7 +126,11 @@ export const mcpEventSubscriptions = pgTable("mcp_event_subscriptions", {
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
scannedAt: timestamp("scanned_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [index("mcp_event_subscriptions_expiry_idx").on(t.expiresAt), index("mcp_event_subscriptions_company_idx").on(t.companyId)]);
}, (t) => [
check("mcp_subscription_resource_check", sql`(${t.taskId} IS NOT NULL AND ${t.bindingId} IS NULL) OR (${t.taskId} IS NULL AND ${t.bindingId} IS NOT NULL)`),
index("mcp_event_subscriptions_expiry_idx").on(t.expiresAt),
index("mcp_event_subscriptions_company_idx").on(t.companyId),
]);
// Short-lived admission leases bound remote verification across replicas. Finished
// attempts remain until expiry so failed callbacks cannot bypass rate limits.
@@ -143,7 +152,8 @@ export const mcpEventAdmissions = pgTable("mcp_event_admissions", {
export const mcpEventDeliveries = pgTable("mcp_event_deliveries", {
id: uuid("id").primaryKey().defaultRandom(),
subscriptionId: text("subscription_id").notNull().references(() => mcpEventSubscriptions.id, { onDelete: "cascade" }),
activityId: uuid("activity_id").notNull(),
activityId: uuid("activity_id"),
mailboxItemId: integer("mailbox_item_id"),
event: jsonb("event").$type<Record<string, unknown>>().notNull(),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
@@ -151,6 +161,8 @@ export const mcpEventDeliveries = pgTable("mcp_event_deliveries", {
// Category/status only; never a URL, secret or response body.
outcome: text("outcome"),
}, (t) => [
check("mcp_delivery_source_check", sql`(${t.activityId} IS NOT NULL AND ${t.mailboxItemId} IS NULL) OR (${t.activityId} IS NULL AND ${t.mailboxItemId} IS NOT NULL)`),
uniqueIndex("mcp_event_deliveries_mailbox_uq").on(t.subscriptionId, t.mailboxItemId),
uniqueIndex("mcp_event_deliveries_activity_uq").on(t.subscriptionId, t.activityId),
index("mcp_event_deliveries_due_idx").on(t.nextAttemptAt),
]);
@@ -0,0 +1,16 @@
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
export const userCompanyPreferences = pgTable("user_company_preferences", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
primaryAgentId: uuid("primary_agent_id").references(() => agents.id, { onDelete: "set null" }),
primaryAgentInitialized: boolean("primary_agent_initialized").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
companyUserUq: uniqueIndex("user_company_preferences_company_user_uq").on(table.companyId, table.userId),
primaryAgentIdx: index("user_company_preferences_primary_agent_idx").on(table.primaryAgentId),
}));
@@ -45,7 +45,7 @@
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/sdk": "^1.31.0",
"googleapis": "^182.0.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -44,7 +44,7 @@
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/sdk": "^1.31.0",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -43,7 +43,7 @@
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/sdk": "^1.31.0",
"@paperclipai/shared": "workspace:*",
"zod": "^4.4.3"
},
+10
View File
@@ -22,6 +22,13 @@ the provider conversation. Company, agent, task, workspace, model, instruction,
and skill compatibility still gate recovery. An MCP-only assignment change can
resume only when the selected harness explicitly supports refreshing tools.
Compact continuation messages include the active completion revision and criterion
IDs plus a reminder to obtain an accepted `paperclip_finish` or `paperclip_block`
result for this turn. Reports from earlier turns do not finish the new turn.
Provider final text alone remains insufficient; governed waits and strict native
completion validation keep their existing behavior. This reminder changes the
resumed model input, not the tool catalog or automatic retry policy.
When recovery needs a fresh conversation, the server supplies a deterministic
handoff through a lazy history loader at the fresh attempt boundary.
It includes the original request, recent messages, resolved interaction
@@ -142,6 +149,9 @@ build and include them in the server's vendored Runner output before npm packing
Assembly requires the provider pack's source revision to match `sourceRevision`
and its ACPX profiles and Cursor distribution to match the current source pins.
An independently rehashed older pack is rejected.
Provider-pack builds replace the installed Copilot platform wrappers with relative
launchers. These wrappers remain usable after the pack moves into an image. The
build still rejects any wrapper that retains its temporary deployment path.
Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every
image asset against that manifest. A mismatched image fails before the provider
starts; install the matching package and image together.
+2 -2
View File
@@ -14,12 +14,12 @@
"requiresProviderPolicy": false
},
"pi": {
"agentProfileVersion": 19,
"agentProfileVersion": 20,
"agentServerPackage": "pi-acp",
"agentServerVersion": "0.0.33",
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
"agentRuntimeVersion": "1.0.0",
"commandDigest": "sha256:b7647ebf97f802ca053ec3384c912bf0e8d18eba308d27397bb1d95a37220825",
"commandDigest": "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e",
"requiresProviderPolicy": true
},
"cursor": {
@@ -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
@@ -1674,9 +1674,18 @@
"additionalProperties": false,
"properties": {
"assigneeActorId": {
"description": "New company agent ID from list_agents.",
"minLength": 1,
"type": "string"
"description": "New company agent ID from list_agents; null when assigning a person.",
"type": [
"string",
"null"
]
},
"assigneeUserId": {
"description": "Company person ID from list_people. Mutually exclusive with assigneeActorId.",
"type": [
"string",
"null"
]
},
"expectedAssigneeActorId": {
"description": "Current assigneeAgentId from search_tasks; null means unassigned.",
@@ -1685,6 +1694,13 @@
"null"
]
},
"expectedAssigneeUserId": {
"description": "Current assigneeUserId from search_tasks; omit only when there is no person assigned.",
"type": [
"string",
"null"
]
},
"expectedStatusVersion": {
"description": "Current statusVersion from search_tasks.",
"minimum": 0,
@@ -2133,7 +2149,15 @@
"additionalProperties": false,
"properties": {
"assigneeActorId": {
"description": "Optional actor assignee.",
"description": "Optional actor assignee. Mutually exclusive with assigneeUserId.",
"maxLength": 200,
"type": [
"string",
"null"
]
},
"assigneeUserId": {
"description": "Company person ID from list_people. Mutually exclusive with assigneeActorId.",
"maxLength": 200,
"type": [
"string",
@@ -2561,6 +2585,120 @@
"title": "Comment on approval",
"version": 1
},
{
"allowedModes": [
"standard"
],
"description": "Schedule, replace or clear a persisted one-shot monitor on an owned task.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"idempotencyKey": {
"description": "Reuse with identical arguments on retries.",
"maxLength": 240,
"minLength": 1,
"type": "string"
},
"monitor": {
"additionalProperties": false,
"properties": {
"externalRef": {
"maxLength": 500,
"minLength": 1,
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"external_service",
null
],
"type": [
"string",
"null"
]
},
"maxAttempts": {
"description": "Optional cumulative attempt limit for this task's monitor.",
"maximum": 100,
"minimum": 1,
"type": [
"integer",
"null"
]
},
"nextCheckAt": {
"description": "Future UTC timestamp for the next check. This is one shot, not a recurring interval.",
"type": "string"
},
"notes": {
"description": "What to check on the next run; do not include secrets.",
"maxLength": 500,
"minLength": 1,
"type": "string"
},
"recoveryPolicy": {
"enum": [
"wake_owner",
"create_recovery_issue",
"escalate_to_board",
null
],
"type": [
"string",
"null"
]
},
"serviceName": {
"maxLength": 120,
"minLength": 1,
"type": [
"string",
"null"
]
},
"timeoutAt": {
"description": "Optional deadline later than nextCheckAt.",
"type": [
"string",
"null"
]
}
},
"required": [
"nextCheckAt",
"notes"
],
"type": [
"object",
"null"
]
},
"taskId": {
"description": "Owned task UUID. Omit for the active task.",
"type": "string"
}
},
"required": [
"idempotencyKey",
"monitor"
],
"type": "object"
},
"operationId": "set_task_monitor",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Set task monitor",
"version": 1
},
{
"allowedModes": [
"standard",
+5
View File
@@ -209,5 +209,10 @@
"typescript": "^7.0.2",
"vite": "^6.1.0",
"vitest": "^5.0.3"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.88",
"@github/copilot-darwin-x64": "1.0.88",
"@github/copilot-linux-x64": "1.0.88"
}
}
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:f99ec54989e74caee8c4edfd2f0929a69d8a571913d5e8116710e90fd452b78d",
"catalogSha256": "sha256:ff3e2bdc66e564cef9500f0940b9afe9d1878e7c8ca3d5a5a0c986b23ffe79de",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -1,6 +1,6 @@
{
"schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 3,
"name": "Unsupported required version", "description": "A fixture that requires PRP v3 must fail closed in a PRP v2 consumer.",
"schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 4,
"name": "Unsupported required version", "description": "A fixture that requires PRP v4 must fail closed in a PRP v3 consumer.",
"identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_v2", "runId": "run_replay_v2", "environmentLeaseId": "lease_replay_v2", "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_v2", "driverSessionId": "driver_replay_v2" },
"capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "future", "version": "2.0.0" }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true },
"commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_v2_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T13:00:00.000Z", "payload": {} }],
@@ -1,6 +1,6 @@
{
"schema": "paperclip.prp.contract_manifest.v1",
"protocolVersion": 2,
"protocolVersion": 3,
"fixtureVersion": 1,
"generatedFrom": [
"protocol/schemas",
@@ -12,6 +12,11 @@
"id": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json",
"sha256": "9c4771e778f20a0deab6702d0c0f2f5fbab29a60c56a5a78d9ce5290df1728cd"
},
{
"path": "schemas/capabilities-v3.schema.json",
"id": "https://paperclip.dev/schemas/prp/v3/capabilities.schema.json",
"sha256": "d640c0c4f8484050f8afd9cc12d040d76be0d7085f6cef18db10182e83acbf42"
},
{
"path": "schemas/capabilities.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json",
@@ -22,6 +27,11 @@
"id": "https://paperclip.dev/schemas/prp/v2/command.schema.json",
"sha256": "06bb3b3b0b420119007494c64e64a736fd5d58cb68d04ced843ada00c54668be"
},
{
"path": "schemas/command-v3.schema.json",
"id": "https://paperclip.dev/schemas/prp/v3/command.schema.json",
"sha256": "0aedad6ae238c532f31411a4551c54819e3a3698e70c6f5d653ae395811c60b2"
},
{
"path": "schemas/command.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/command.schema.json",
@@ -42,6 +52,11 @@
"id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
"sha256": "9dcf149bdb8e10041ca2212037231e7ceff11be08a0b8ee5dbd0f21974daa294"
},
{
"path": "schemas/event-v3.schema.json",
"id": "https://paperclip.dev/schemas/prp/v3/event.schema.json",
"sha256": "d308f826097b5b923e6ab2a4893bc125aa9e3cc14a306eef152400093db7b5f3"
},
{
"path": "schemas/event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
@@ -50,7 +65,7 @@
{
"path": "schemas/fixture.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json",
"sha256": "186caa4a8fa00964f9a76e3714563bcb2c606eee434cef97a5077dd06f3c6aa5"
"sha256": "65f331a3a3e3ac77189c91e30053bb036fa50fdeb9584e2afac582bc9daf5178"
},
{
"path": "schemas/identity.schema.json",
@@ -65,7 +80,7 @@
{
"path": "schemas/provider-descriptor.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
"sha256": "87fc155cf7c94fd6a526829f50f3d4a649769f1024b473013eee3436878b0f5d"
"sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519"
},
{
"path": "schemas/provider-event.schema.json",
@@ -160,7 +175,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "a591d6ad980146be0e68bce6fbb051324fa1e19fbce2fc5754172d050afc92f7",
"sha256": "248c366cd272e1cc13506886bee4ee15fd63813280583b5859011446035ebbbb",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -454,7 +469,7 @@
},
{
"path": "fixtures/replay/unsupported-required-version.json",
"sha256": "c7fc47b1e537c8e051353235aa0415521c627c3e90ac681a053b85c9aa5bf8e8",
"sha256": "b46ea580200a846a36cc48bd7e567583b1f1683e517d1053164fb56ff03667f2",
"expectation": "reject",
"compatibilityCase": "unknown-required-version"
}
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v3/capabilities.schema.json",
"title": "PRP v3 negotiated capabilities",
"type": "object",
"required": [
"schema",
"sessionReusePolicy",
"driver",
"steer",
"interrupt",
"resume",
"runtimeRequests",
"structuredResult",
"typedEvents",
"sessionGoals"
],
"properties": {
"schema": {
"const": "paperclip.prp.capabilities.v3"
},
"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"
},
"sessionGoals": {
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"
},
"unsupported": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"externalProvider": {
"type": "boolean"
}
},
"additionalProperties": true
}
@@ -0,0 +1,386 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v3/command.schema.json",
"title": "PRP v3 runner command",
"type": "object",
"required": [
"schema",
"commandId",
"controllerSeq",
"type",
"issuedAt",
"payload"
],
"properties": {
"schema": {
"const": "paperclip.prp.command.v3"
},
"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",
"session.goal.get",
"session.goal.set",
"session.goal.clear",
"external_provider.operation"
]
},
"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
}
},
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "session.goal.set"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"objective": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"status": {
"enum": [
"active",
"paused"
]
},
"tokenBudget": {
"type": [
"integer",
"null"
],
"minimum": 1
}
},
"additionalProperties": true
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"type": {
"const": "external_provider.operation"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"type": "object",
"required": [
"requestId",
"bindingId",
"runId",
"normalizedSessionId",
"turnId",
"bindingGeneration",
"assignmentRevision",
"digest",
"action",
"input"
],
"properties": {
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingGeneration": {
"type": "integer",
"minimum": 1
},
"assignmentRevision": {
"type": "integer",
"minimum": 1
},
"digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"action": {
"enum": [
"accept",
"tool",
"progress",
"finish"
]
},
"input": {
"type": "object"
}
},
"additionalProperties": false
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"type": {
"const": "external_provider.operation"
},
"payload": {
"properties": {
"action": {
"const": "accept"
}
},
"type": "object"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"properties": {
"input": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
},
"type": "object"
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"type": {
"const": "external_provider.operation"
},
"payload": {
"properties": {
"action": {
"const": "tool"
}
},
"type": "object"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"properties": {
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"arguments": {
"type": "object"
}
},
"required": [
"name",
"arguments"
],
"additionalProperties": false
}
},
"type": "object"
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"type": {
"const": "external_provider.operation"
},
"payload": {
"properties": {
"action": {
"const": "progress"
}
},
"type": "object"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"properties": {
"input": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"maxLength": 12000
}
},
"required": [
"text"
],
"additionalProperties": false
}
},
"type": "object"
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"type": {
"const": "external_provider.operation"
},
"payload": {
"properties": {
"action": {
"const": "finish"
}
},
"type": "object"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"properties": {
"input": {
"type": "object",
"properties": {
"result": {
"type": "object"
}
},
"required": [
"result"
],
"additionalProperties": false
}
},
"type": "object"
}
},
"type": "object"
}
}
],
"additionalProperties": true
}
@@ -0,0 +1,572 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v3/event.schema.json",
"title": "PRP v3 native event",
"type": "object",
"required": [
"schema",
"sourceEventId",
"sourceSeq",
"sourceInstanceId",
"sourceKind",
"runId",
"eventType",
"schemaVersion",
"priority",
"emittedAt",
"payload"
],
"properties": {
"schema": {
"const": "paperclip.prp.event.v3"
},
"sourceEventId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"sourceSeq": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"sourceInstanceId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"sourceKind": {
"enum": [
"runner",
"control_plane"
]
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"itemId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"eventType": {
"enum": [
"runner.connected",
"runner.reconnected",
"runner.reconciled",
"runner.disconnected",
"runner.draining",
"runner.suspending",
"runner.suspended",
"runner.stopped",
"runner.diagnostic",
"runtime.phase.changed",
"sandbox.metric",
"workspace.ready",
"workspace.change.updated",
"workspace.diff.recorded",
"workspace.file.referenced",
"harness.starting",
"harness.ready",
"harness.exited",
"harness.diagnostic",
"plan.updated",
"tool.execution.started",
"tool.execution.progressed",
"tool.execution.completed",
"research.started",
"research.progressed",
"research.completed",
"delegation.started",
"delegation.updated",
"delegation.completed",
"model.route.changed",
"model.verification.updated",
"context.compacted",
"artifact.viewed",
"artifact.generated",
"review.mode.changed",
"hook.started",
"hook.completed",
"memory.citation.referenced",
"safety.review.started",
"safety.review.completed",
"terminal.input.sent",
"wait.started",
"wait.completed",
"provider.notice.recorded",
"session.starting",
"session.started",
"session.resuming",
"session.resumed",
"session.reconciled",
"session.updated",
"session.closed",
"session.failed",
"session.capabilities.updated",
"session.goal.snapshot",
"session.goal.updated",
"session.goal.cleared",
"turn.submitted",
"turn.accepted",
"turn.started",
"turn.completed",
"turn.failed",
"turn.interrupted",
"turn.cancelled",
"item.started",
"item.delta",
"item.completed",
"item.failed",
"usage.reported",
"semantic_tool.input",
"semantic_tool.result",
"mcp_app.discovered",
"mcp_app.resource.resolved",
"mcp_app.initializing",
"mcp_app.ready",
"mcp_app.tool_input",
"mcp_app.tool_result",
"mcp_app.action.requested",
"mcp_app.action.resolved",
"mcp_app.host_context.changed",
"mcp_app.failed",
"mcp_app.teardown",
"runtime_request.created",
"runtime_request.resolved",
"runtime_request.expired",
"runtime_request.cancelled",
"interaction.request.proposed",
"interaction.request.materialized",
"interaction.request.rejected",
"interaction.response.progressed",
"interaction.response.resolved",
"interaction.response.delivered",
"run.attached",
"run.detached",
"run.result.proposed",
"run.result.accepted",
"run.result.rejected",
"attention.request.proposed",
"attention.request.routed",
"attention.request.resolved",
"attention.request.expired",
"attention.request.superseded",
"work.assessment.recorded",
"issue.status.decision.recorded",
"issue.status.decision.applied",
"issue.status.decision.rejected",
"issue.status.decision.superseded",
"run.terminal",
"external_provider.dispatch_requested",
"external_provider.operation_settled"
]
},
"schemaVersion": {
"const": 3
},
"priority": {
"enum": [
0,
1,
2
]
},
"emittedAt": {
"type": "string",
"format": "date-time"
},
"observedAt": {
"type": "string",
"format": "date-time"
},
"payload": {
"type": "object",
"additionalProperties": true
},
"debug": {
"type": "object",
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": {
"eventType": {
"enum": [
"session.goal.snapshot",
"session.goal.updated"
]
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"type": "object",
"required": [
"goal"
],
"properties": {
"goal": {
"oneOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"
},
{
"type": "null"
}
]
}
},
"additionalProperties": true
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"eventType": {
"const": "session.capabilities.updated"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"type": "object",
"required": [
"sessionGoals"
],
"properties": {
"sessionGoals": {
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"
},
"turnControls": {
"$ref": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls"
}
},
"additionalProperties": true
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"eventType": {
"const": "external_provider.dispatch_requested"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"oneOf": [
{
"type": "object",
"properties": {
"binding": {
"type": "object",
"properties": {
"companyId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingGeneration": {
"type": "integer",
"minimum": 1
},
"assignmentRevision": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"required": [
"companyId",
"agentId",
"bindingId",
"runId",
"normalizedSessionId",
"turnId",
"bindingGeneration",
"assignmentRevision"
]
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 1048576
},
"instructions": {
"type": "string",
"maxLength": 1048576
},
"acceptByUnixMs": {
"type": "integer",
"minimum": 1
},
"expiresAtUnixMs": {
"type": "integer",
"minimum": 1
},
"completionContract": {
"type": "object",
"properties": {
"revision": {
"type": "string",
"minLength": 1
},
"criterionIds": {
"type": "array",
"minItems": 1,
"maxItems": 256,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"revision",
"criterionIds"
],
"additionalProperties": false
},
"tools": {
"type": "array",
"items": {
"type": "object"
},
"maxItems": 512
}
},
"additionalProperties": false,
"required": [
"binding",
"text",
"instructions",
"acceptByUnixMs",
"expiresAtUnixMs",
"completionContract",
"tools"
]
},
{
"type": "object",
"properties": {
"binding": {
"type": "object",
"properties": {
"companyId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingGeneration": {
"type": "integer",
"minimum": 1
},
"assignmentRevision": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"required": [
"companyId",
"agentId",
"bindingId",
"runId",
"normalizedSessionId",
"turnId",
"bindingGeneration",
"assignmentRevision"
]
},
"kind": {
"const": "authority_revoked"
},
"reason": {
"type": "string"
},
"externalStopConfirmed": {
"const": false
}
},
"required": [
"binding",
"kind",
"externalStopConfirmed"
],
"additionalProperties": false
}
]
}
},
"type": "object"
}
},
{
"if": {
"properties": {
"eventType": {
"const": "external_provider.operation_settled"
}
},
"type": "object"
},
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"binding": {
"type": "object",
"properties": {
"companyId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"bindingGeneration": {
"type": "integer",
"minimum": 1
},
"assignmentRevision": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"required": [
"companyId",
"agentId",
"bindingId",
"runId",
"normalizedSessionId",
"turnId",
"bindingGeneration",
"assignmentRevision"
]
},
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"outcome": {
"type": "object"
}
},
"required": [
"binding",
"requestId",
"outcome"
],
"additionalProperties": false
}
},
"type": "object"
}
}
],
"additionalProperties": true
}
@@ -16,24 +16,58 @@
"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" },
"schema": {
"const": "paperclip.prp.fixture.v1"
},
"fixtureVersion": {
"const": 1
},
"protocolVersion": {
"enum": [
1,
2,
3
]
},
"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" }
{
"$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v3/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" }
{
"$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/command.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v3/command.schema.json"
}
]
}
},
@@ -42,16 +76,27 @@
"minItems": 1,
"items": {
"oneOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" },
{ "$ref": "https://paperclip.dev/schemas/prp/v2/event.schema.json" }
{
"$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/event.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v3/event.schema.json"
}
]
}
},
"requests": {
"type": "array",
"items": { "$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json" }
"items": {
"$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json"
}
},
"result": { "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" }
"result": {
"$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json"
}
},
"additionalProperties": true
}
@@ -3,115 +3,412 @@
"$id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
"title": "PRP provider runtime descriptor",
"type": "object",
"required": ["provider", "driver", "model", "executionKind", "providerVersion"],
"required": [
"provider",
"driver",
"model",
"executionKind",
"providerVersion"
],
"properties": {
"provider": { "enum": ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"] },
"driver": { "enum": ["codex_app_server", "opencode_server", "claude_managed_agents_api", "aws_agentcore_harness_api", "acpx_runtime"] },
"model": { "type": ["string", "null"], "maxLength": 240 },
"executionKind": { "enum": ["local_process", "remote_service"] },
"providerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"service": { "enum": ["anthropic_managed_agents", "aws_bedrock_agentcore_harness"] },
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
"processId": { "type": ["integer", "null"], "minimum": 1 },
"agentProcessId": { "type": ["integer", "null"], "minimum": 1 },
"endpointArn": { "type": "string", "minLength": 1, "maxLength": 2048 },
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
"eventExpiryDays": { "const": 90 },
"agent": { "enum": ["pi", "claude", "codex", "cursor", "copilot"] },
"requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 },
"acpProtocolVersion": { "const": 1 },
"agentServerPackage": { "type": "string", "minLength": 1, "maxLength": 240 },
"agentServerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"agentRuntimePackage": { "type": ["string", "null"], "maxLength": 240 },
"agentRuntimeVersion": { "type": ["string", "null"], "maxLength": 120 },
"acpxRecordId": { "type": ["string", "null"], "maxLength": 240 },
"piThinkingLevel": { "enum": ["off", "low", "high", "max"] },
"turnControls": { "$ref": "#/$defs/turnControls" }
"provider": {
"enum": [
"codex",
"opencode",
"claude_managed",
"aws_agentcore",
"acpx",
"openai_dot"
]
},
"driver": {
"enum": [
"codex_app_server",
"opencode_server",
"claude_managed_agents_api",
"aws_agentcore_harness_api",
"acpx_runtime",
"openai_dot_mcp"
]
},
"model": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"executionKind": {
"enum": [
"local_process",
"remote_service"
]
},
"providerVersion": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"service": {
"enum": [
"anthropic_managed_agents",
"aws_bedrock_agentcore_harness",
"openai_dot"
]
},
"providerSessionId": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"processId": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"agentProcessId": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"endpointArn": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"endpointQualifier": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"memoryId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"eventExpiryDays": {
"const": 90
},
"agent": {
"enum": [
"pi",
"claude",
"codex",
"cursor",
"copilot"
]
},
"requestedModel": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"acpProtocolVersion": {
"const": 1
},
"agentServerPackage": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentServerVersion": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"agentRuntimePackage": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"agentRuntimeVersion": {
"type": [
"string",
"null"
],
"maxLength": 120
},
"acpxRecordId": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"turnControls": {
"$ref": "#/$defs/turnControls"
},
"piThinkingLevel": {
"enum": [
"off",
"low",
"high",
"max"
]
}
},
"allOf": [
{
"if": { "required": ["piThinkingLevel"] },
"if": {
"required": [
"piThinkingLevel"
]
},
"then": {
"properties": { "provider": { "const": "acpx" }, "agent": { "const": "pi" } },
"required": ["agent"]
"properties": {
"provider": {
"const": "acpx"
},
"agent": {
"const": "pi"
}
},
"required": [
"agent"
]
}
},
{
"oneOf": [
{
"properties": {
"provider": { "const": "codex" },
"driver": { "const": "codex_app_server" },
"executionKind": { "const": "local_process" }
"provider": {
"const": "codex"
},
"driver": {
"const": "codex_app_server"
},
"executionKind": {
"const": "local_process"
}
}
},
{
"properties": {
"provider": { "const": "opencode" },
"driver": { "const": "opencode_server" },
"executionKind": { "const": "local_process" }
"provider": {
"const": "opencode"
},
"driver": {
"const": "opencode_server"
},
"executionKind": {
"const": "local_process"
}
}
},
{
"properties": {
"provider": { "const": "claude_managed" },
"driver": { "const": "claude_managed_agents_api" },
"executionKind": { "const": "remote_service" }
"provider": {
"const": "claude_managed"
},
"driver": {
"const": "claude_managed_agents_api"
},
"executionKind": {
"const": "remote_service"
}
}
},
{
"properties": {
"provider": { "const": "aws_agentcore" },
"driver": { "const": "aws_agentcore_harness_api" },
"executionKind": { "const": "remote_service" }
"provider": {
"const": "aws_agentcore"
},
"driver": {
"const": "aws_agentcore_harness_api"
},
"executionKind": {
"const": "remote_service"
}
}
},
{
"properties": {
"provider": { "const": "acpx" },
"driver": { "const": "acpx_runtime" },
"executionKind": { "const": "local_process" }
"provider": {
"const": "acpx"
},
"driver": {
"const": "acpx_runtime"
},
"executionKind": {
"const": "local_process"
}
}
},
{
"properties": {
"provider": {
"const": "openai_dot"
},
"driver": {
"const": "openai_dot_mcp"
},
"executionKind": {
"const": "remote_service"
}
}
}
]
},
{
"if": { "properties": { "executionKind": { "const": "remote_service" } } },
"if": {
"properties": {
"executionKind": {
"const": "remote_service"
}
}
},
"then": {
"required": ["service"],
"properties": { "processId": { "const": null } }
"required": [
"service"
],
"properties": {
"processId": {
"const": null
}
}
}
},
{
"if": { "properties": { "executionKind": { "const": "local_process" } } },
"then": { "properties": { "service": false } }
},
{
"if": { "properties": { "provider": { "const": "claude_managed" } }, "required": ["provider"] },
"then": { "properties": { "service": { "const": "anthropic_managed_agents" } }, "required": ["service"] }
},
{
"if": { "properties": { "provider": { "const": "aws_agentcore" } }, "required": ["provider"] },
"if": {
"properties": {
"executionKind": {
"const": "local_process"
}
}
},
"then": {
"properties": { "service": { "const": "aws_bedrock_agentcore_harness" } },
"required": ["service", "endpointArn", "endpointQualifier", "memoryId", "eventExpiryDays"]
"properties": {
"service": false
}
}
},
{
"if": { "properties": { "provider": { "const": "acpx" } }, "required": ["provider"] },
"if": {
"properties": {
"provider": {
"const": "claude_managed"
}
},
"required": [
"provider"
]
},
"then": {
"required": ["agent", "requestedModel", "acpProtocolVersion", "agentServerPackage", "agentServerVersion", "acpxRecordId", "agentProcessId"]
"properties": {
"service": {
"const": "anthropic_managed_agents"
}
},
"required": [
"service"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "aws_agentcore"
}
},
"required": [
"provider"
]
},
"then": {
"properties": {
"service": {
"const": "aws_bedrock_agentcore_harness"
}
},
"required": [
"service",
"endpointArn",
"endpointQualifier",
"memoryId",
"eventExpiryDays"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "acpx"
}
},
"required": [
"provider"
]
},
"then": {
"required": [
"agent",
"requestedModel",
"acpProtocolVersion",
"agentServerPackage",
"agentServerVersion",
"acpxRecordId",
"agentProcessId"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "openai_dot"
}
},
"required": [
"provider"
]
},
"then": {
"required": [
"service",
"providerSessionId"
],
"properties": {
"service": {
"const": "openai_dot"
},
"model": {
"const": null
},
"providerSessionId": {
"const": null
},
"agentProcessId": {
"const": null
}
}
}
}
],
"$defs": {
"turnControls": {
"type": "object",
"required": ["steering", "queuedFollowUp"],
"required": [
"steering",
"queuedFollowUp"
],
"properties": {
"steering": { "type": "boolean" },
"queuedFollowUp": { "type": "boolean" }
"steering": {
"type": "boolean"
},
"queuedFollowUp": {
"type": "boolean"
}
},
"additionalProperties": false
}
Loaded 100 of 728 files, more files were not shown because too many files have changed in this diff. Show more