mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Reconcile qualified Pi stack with current master and source attestation
This commit is contained in:
commit
8dbfea7998
728 files changed
+117813
-64165
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## 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.
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 $$;
|
||||
-52564
File diff suppressed because it is too large.
Load diff
+54905
File diff suppressed because it is too large.
Load diff
+125
-2
@@ -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": "",
|
||||
+1017
-7
File diff suppressed because it is too large.
Load diff
+3184
-19
File diff suppressed because it is too large.
Load diff
+3624
-18
File diff suppressed because it is too large.
Load diff
+36
-1
@@ -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', ${` `}, '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);
|
||||
@@ -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')`),
|
||||
]);
|
||||
@@ -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`),
|
||||
|
||||
@@ -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')`),
|
||||
}));
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-2
@@ -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
Reference in new issue
Block a user