Update the Pi companion after core integration
Refresh the immutable provider lock fingerprint for the combined dependency graph. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
3df487feb8
|
|
@ -275,6 +275,15 @@ These browser suites are intended for targeted local verification and CI, not th
|
|||
|
||||
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
|
||||
|
||||
### Recent task ordering
|
||||
|
||||
The streamlined sidebar keeps five recent tasks per company and account in browser
|
||||
storage. It sorts by the newest observed task or comment activity, not by live-run
|
||||
state. Older detail responses cannot move the stored activity time backward.
|
||||
Activity-only reorderings wait for one second without further activity changes;
|
||||
new and removed tasks appear immediately. Titles, status, and live indicators stay
|
||||
current during that delay.
|
||||
|
||||
## One-Command Local Run
|
||||
|
||||
For a first-time local install, you can bootstrap and run in one command:
|
||||
|
|
@ -1266,3 +1275,7 @@ Networking behavior for this smoke script:
|
|||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
|
||||
### GitHub identity for shared agents
|
||||
|
||||
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
# GitHub identity during agent execution
|
||||
|
||||
Shared agents use the GitHub connection of the person whose accepted instructions they are executing. Task ownership remains unchanged. GitHub is optional: ordinary work can start without a connection; a private checkout, authenticated API call, or commit can fail when that operation needs credentials or author metadata.
|
||||
|
||||
## Accepted instructions and continuations
|
||||
|
||||
`run_identity_contexts` records ordered revisions, stored message authors, originating causes, parent contexts, acceptance state, and redacted GitHub outcomes. `heartbeat_runs.active_identity_context_id` selects the current revision. Existing historical runs are not backfilled with inferred authorship.
|
||||
|
||||
Human messages use their stored authenticated author. Queued messages retain their delivery order. Accepting steering reserves a pending revision before delivery, then activates it after the provider acknowledgement. Rejected delivery leaves the prior revision active. An uncertain acknowledgement holds new credential acquisition; a later acknowledgement or its authenticated native event receipt reconciles the reservation. Replays cannot reactivate an older revision. Activation locks the task before the run, matching task and queue mutations so concurrent status changes cannot deadlock identity initialization.
|
||||
|
||||
Delegated work and interactions persist their originating context. Retries retain the originating run's active context. Background continuations carry their source run; dependency wakes use the task's continuation context, independently of its owner. Scheduled and webhook routines use the routine's responsible person; manual invocations use the caller, and edits preserve the routine's responsible person.
|
||||
|
||||
## Managed GitHub operations
|
||||
|
||||
New executions receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that run’s capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
|
||||
|
||||
The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process.
|
||||
|
||||
Server-side Git operations and GitHub gateway calls follow the same selection rules. Approved gateway operations retain their signed originating identity. Connection audience and tool policies continue to apply to the selected person's connection. Native catalogs remain stable across identity changes, but each invocation resolves the selected grant again. Personal OAuth secret declarations survive connection pauses and metadata edits.
|
||||
|
||||
Managed commands disable ambient Git credential helpers, Git global/system configuration, host GitHub CLI configuration, and host SSH identity access. Per-operation GitHub CLI configuration is isolated in a writable configuration directory beneath the managed launcher directory. Missing credentials clear previous author and token values; no teammate, standing delegation, host token, or company-default user's account is substituted. Anonymous/local operations remain available where supported.
|
||||
|
||||
Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract.
|
||||
|
||||
## Dedicated accounts and diagnostics
|
||||
|
||||
An explicit dedicated-agent grant overrides personal selection. Revoked, disabled, unavailable, or ambiguous dedicated grants do not fall back to a person's account. Removing the dedicated configuration restores personal selection.
|
||||
|
||||
Connection setup and permissions display: “This agent uses this GitHub account for everyone's work, instead of the person giving instructions.”
|
||||
|
||||
Run details show identity revisions and redacted GitHub results: responsible person, selected login when available, personal/dedicated source, and an unavailable reason. Tasks do not receive an additional identity indicator or takeover action.
|
||||
|
||||
## Deployment and verification
|
||||
|
||||
Deploy the schema, server broker, launcher staging, and runtime environment contract together. Already-running processes retain their original environment; only newly dispatched processes receive the broker contract. Run-scoped capabilities remain valid only while their bound run is active.
|
||||
|
||||
Focused coverage lives in `run-identity.test.ts`, `github-operation-credentials.test.ts`, and `github-launcher.test.ts`, alongside the native steering, gateway, routine, and callback-bridge suites. Live acceptance additionally requires two authenticated Paperclip users, two authorized GitHub accounts, and a designated disposable repository for push verification. Local commit metadata and mocked API results do not replace that live push test.
|
||||
|
||||
### Release procedure
|
||||
|
||||
1. Back up the instance database using the normal deployment procedure.
|
||||
2. Build and deploy one revision containing migrations 0240–0245, the server broker, managed launchers, and the runner artifacts. Run the standard pending-migration check before admitting new runs. These additive migrations are safe to replay and do not infer authorship for historical runs.
|
||||
3. Let pre-rollout executions finish with their original runtime contract. New executions must have an active identity context and the managed launcher capability before provider startup.
|
||||
4. Check one ordinary run without a GitHub connection, then an authenticated GitHub operation. Inspect the run details for the responsible person and credential outcome. Verify a queued continuation on the same conversation.
|
||||
5. If rollback is needed, finish or explicitly stop executions using the new broker before removing its endpoint. Keep the additive schema and identity history. Do not drop identity columns or tables to roll back application code.
|
||||
|
||||
Remote acceptance uses the existing paid runner workflow with a narrow selection. Run it against the same immutable revision as the release; a successful local test does not qualify a different remote runner artifact.
|
||||
|
||||
Identity history survives deletion of the originating agent or run, so surviving
|
||||
subtasks and approvals retain their responsible person. The company foreign key and company-deletion service remove
|
||||
these company-scoped records when their company is deleted. Completed runs remove their managed launcher files
|
||||
before releasing a remote environment; same-run recovery retains them until the
|
||||
terminal boundary. Cleanup failures are logged and do not change the run result.
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Runner API production readiness — 2026-09-07
|
||||
|
||||
The implementation is ready for final review and an opt-in rollout after the
|
||||
remaining release checks pass. General release is not yet approved. The tools
|
||||
are disabled by default and can be enabled for selected companies.
|
||||
|
||||
## Implemented safeguards
|
||||
|
||||
- The catalog covers mounted REST routes and identifies non-REST protocols.
|
||||
- Calls use run-bound agent credentials and the real HTTP authorization path.
|
||||
- Active-run and work-mode checks run again after file preparation.
|
||||
- File opens reject symlinks at every path component.
|
||||
- Runner lifecycle changes and active-task deletion aliases are blocked.
|
||||
- Mutation receipts prevent automatic replay after an uncertain outcome.
|
||||
- Credential-value and management operations cannot enter generic tool results
|
||||
or replay receipts. Safe secret metadata remains discoverable.
|
||||
- HTTP errors that may follow a committed write retain an unknown outcome.
|
||||
- Dedicated child creation now records its agent and run in the activity log.
|
||||
- Eval journals and bounded provider traces survive disposable server cleanup.
|
||||
- The shared ledger blocks new paid work if accounting is incomplete.
|
||||
|
||||
## Qualification evidence
|
||||
|
||||
Sonnet 5 through OpenCode 1.18.17 and OpenRouter passed the read, mutation and
|
||||
cross-company denial smoke cases after fixes. It also passed eight additional
|
||||
cases covering files, Ask/Plan modes, API-only options and a mixed workflow.
|
||||
The initial malformed-JSON failure remains in the report. The corrected tool
|
||||
schema tells models to pass structured JSON directly, and invalid string-encoded
|
||||
objects receive an actionable error before HTTP dispatch.
|
||||
|
||||
Sonnet passed all 60 common-task regression runs: ten workflows, three repetitions
|
||||
per arm. It used no unnecessary API fallback. Per-workflow average cost changes
|
||||
ranged from -1.4% to +5.5%. No cost or latency increase crossed the 20% investigation
|
||||
threshold. These are small samples, not a guarantee for all workloads.
|
||||
|
||||
Gemini 3.8 Flash passed read, mutation and denial smoke cases. DeepSeek V4 Flash
|
||||
0731 completed the API read but did not finish within 120 seconds. It remains
|
||||
unqualified under this limit. Both interrupted attempts have retained billing
|
||||
reconciliation evidence. No missing charge was discarded or treated as zero.
|
||||
|
||||
Luna passed all 60 paired common-task runs with no unnecessary fallback. Two
|
||||
workflows exceeded the 20% cost threshold: document reading (+38.8%) and task
|
||||
search (+35.1%). Separate one-pair repeats changed cost by -29.1% and +7.8%.
|
||||
Task-search time increased 54.5% in its single repeat. Cache use and model turns
|
||||
varied. These samples do not establish the cause; retain the flags in rollout
|
||||
monitoring. Corrected child creation includes the required audit event.
|
||||
|
||||
Final rebased read tests passed on both Luna and Sonnet. Sonnet used the new
|
||||
repository-pinned OpenCode 1.18.29; the larger cohort used 1.18.17. Paid testing
|
||||
stopped at $9.875960 and 88.16 active minutes. No missing accounting remains.
|
||||
The remaining campaign time cannot fit another 120-second reservation.
|
||||
|
||||
The rebased branch passed the full Linux build and typecheck. Repository tests
|
||||
were run by project and serialized shard. All 143 serialized server suites passed.
|
||||
The runner TypeScript suite passed 1,599 tests with two platform skips. Rust
|
||||
release tests, conformance and replay checks passed. The required API authority
|
||||
check passed 814 tests, including the real runnerd/PRP/HTTP integration.
|
||||
|
||||
Retained verification logs record the initial environment failures and targeted
|
||||
reruns: missing `jq`, an overlay-filesystem identity test that passed on tmpfs,
|
||||
and parallel Rust linking that passed with one build worker. The macOS full
|
||||
runner suite has platform-specific failures; Linux is the qualified full-check
|
||||
platform. Latest-head CI remains the final release gate.
|
||||
|
||||
Review fixes also block issue reopen/resume/interrupt intents, require explicit
|
||||
controller credential injection, and add recoverable ledger stop/reconciliation
|
||||
transitions. These changes have provider-free evidence. They do not have new
|
||||
paid-model results after the campaign time limit.
|
||||
|
||||
## Release gates
|
||||
|
||||
1. Preserve the completed paired comparison and its investigated threshold flags.
|
||||
2. Record final costs, latency, source revisions, failures and unrun operations.
|
||||
3. Require green current-head CI and completed security/code review before merging.
|
||||
4. Keep API tools disabled until an operator selects the first rollout companies.
|
||||
5. Inspect task correctness, fallback frequency, cost, latency, denials and unknown
|
||||
mutation outcomes before expanding access.
|
||||
|
||||
The catalog-wide operation cases are authored, but most have not had paid model
|
||||
execution. Generated cases that need additional fixtures do not establish working
|
||||
coverage. The coverage matrix must continue to show those gaps. The original $300
|
||||
budget and 90-minute active paid-campaign limit apply to all stages and retries.
|
||||
|
||||
Implementation review: https://github.com/paperclipai/paperclip/pull/13003
|
||||
|
||||
Eval suite and evidence: https://github.com/paperclipai/paperclip-evals/pull/20
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# 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;
|
||||
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.
|
||||
|
||||
## Controlled rollout
|
||||
|
||||
The escape hatch is disabled by default. Set
|
||||
`PAPERCLIP_RUNNER_API_TOOLS_ENABLED=true` on the server to enable it. For an
|
||||
initial company rollout, also set `PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS` to a
|
||||
comma-separated list of company UUIDs. An unset list allows every company;
|
||||
an explicitly empty list allows none. IDs must match exactly.
|
||||
|
||||
The server always requires the explicit `true` flag, including for server-owned
|
||||
bindings. A binding can disable these tools for a baseline eval but cannot enable
|
||||
them without operator opt-in. Setting the flag to `false` disables them. The server checks this switch when advertising tools,
|
||||
when accepting a call, and immediately before HTTP dispatch after preparing any
|
||||
files. Existing dedicated tools remain available. Operators must update the
|
||||
environment of each server process and restart it for deployment-level changes;
|
||||
this environment switch is not a live settings API.
|
||||
|
||||
Evaluate selected companies first. Compare success, unnecessary fallback calls,
|
||||
cost, and latency against the dedicated-tool baseline before widening. Keep the
|
||||
switch disabled if authorization, replay, or cost accounting fails.
|
||||
|
||||
## Discovery and requests
|
||||
|
||||
```json
|
||||
{"query":"create project","limit":5}
|
||||
```
|
||||
|
||||
Search is deterministic lexical ranking over OpenAPI paths, summaries and the
|
||||
old skill reference. It supports task/issue and other terminology, exact
|
||||
`METHOD /api/path/{parameter}` lookup, and opaque query/catalog-bound pagination.
|
||||
Results include resolved request schemas, response descriptions, authorization
|
||||
metadata, work modes, examples where available, and relevant dedicated tools
|
||||
with their supported parameters. `limit` defaults to five and is capped at ten.
|
||||
|
||||
```json
|
||||
{"operationId":"PATCH /api/projects/{id}","pathParams":{"id":"PROJECT_UUID"},"body":{"description":"Updated project description"}}
|
||||
```
|
||||
|
||||
The catalog determines method and path. `companyId` is filled from the active
|
||||
binding. Scalars and arrays are accepted in `query`. `body` defaults to JSON;
|
||||
`contentType` supports text and raw uploads. `files` accepts entries containing
|
||||
exactly one authorized `artifactId` or task-workspace `path`, and an optional
|
||||
multipart `field`. No arbitrary URL, headers, authentication, or remote file URL
|
||||
can be supplied. Routes still validate payloads and enforce permissions.
|
||||
|
||||
Requests have a 30-second HTTP timeout, 16 KiB URL limit and 10 MiB payload/response
|
||||
transfer limit. Responses above 24 KiB and binary responses become company-owned
|
||||
assets with retrievable references; text previews are limited to 2,000 bytes.
|
||||
All redirects are refused. Oversized or interrupted mutation responses have an
|
||||
unknown outcome, requiring inspection before another mutation.
|
||||
Mutation responses with HTTP 5xx, HTTP 408, redirects, or malformed JSON also
|
||||
retain an unknown outcome. A server may have committed the write before it
|
||||
failed to return a valid response.
|
||||
|
||||
## Authority and replay
|
||||
|
||||
The server revalidates the active native run, assigned task and actor, then
|
||||
creates a server-held agent JWT bound to that company and run. Requests go
|
||||
through the actual HTTP router with its authorization, validation and domain
|
||||
audit behavior. An additional `runner.api_called` receipt attributes mutations
|
||||
to the run even where older route audit events omit that field.
|
||||
The run and work mode are checked again after asynchronous file preparation, so
|
||||
a stopped run cannot dispatch an upload prepared under its earlier binding.
|
||||
|
||||
Ask and pre-acceptance Plan permit reads through the escape hatch. Existing
|
||||
dedicated-tool exceptions are unchanged. Runner-owned checkout, completion,
|
||||
status/assignment transitions, approval decisions and execution-control actions
|
||||
cannot be bypassed through generic calls. Routine creation, schedule/trigger
|
||||
changes and manual/public routine execution require the existing scheduling
|
||||
clients. Direct workspace runtime commands, runtime-slot stop/restart, case
|
||||
automation retries and skill test-run controls also require their existing
|
||||
execution clients. Gateway session credentials cannot enter generic results.
|
||||
Routine metadata remains readable; annotation threads, comments and thread
|
||||
resolution remain available through the fallback. API-only ordinary fields, such as a
|
||||
task's `billingCode`, remain accessible even when a dedicated tool covers other
|
||||
fields on that endpoint.
|
||||
|
||||
Mutation call IDs reserve a durable receipt in the run's existing `resultJson`
|
||||
before dispatch. Replays return the recorded result. Reusing an ID with different
|
||||
arguments is rejected. A crash after reservation leaves an unknown outcome and
|
||||
never automatically resends the mutation. The limit is 512 mutation receipts per
|
||||
run. No database migration is needed.
|
||||
|
||||
Workspace uploads use the existing workspace resource containment checks,
|
||||
no-symlink file opens covering every path component, and bounded descriptor reads.
|
||||
Local uploads require Linux or macOS; authorized artifacts work on other hosts.
|
||||
Lifecycle-sensitive endpoints require an inline JSON object, so a raw uploaded
|
||||
JSON file cannot hide protected fields from policy checks. Artifacts must belong
|
||||
to the bound company. Secret-value access, credential management, secret proposals
|
||||
and company exports require their existing secure clients. Search describes these
|
||||
operations as restricted. `call_api` rejects them before creating a replay receipt
|
||||
or making an HTTP request. Safe secret metadata listing remains available.
|
||||
Agent credentials are never returned to the model. Streaming, WebSocket, MCP and authentication
|
||||
handshakes are documented as protocol operations requiring their existing clients.
|
||||
|
||||
## Catalog maintenance
|
||||
|
||||
`runner-api-catalog.ts` builds from the server OpenAPI registry. Experimental
|
||||
pipeline, Cases and smoke-lab routes now share their validators with discovery.
|
||||
Seven Cases/pipeline route shapes are multiplexed by resource identity: the Cases
|
||||
router intentionally forwards unknown resources to the pipeline router. Their
|
||||
separate catalog entries explain which resource identifier is required. Registry
|
||||
authorization descriptions are documentation; actual route checks are authoritative.
|
||||
|
||||
Regenerate old-skill enrichment after editing its API reference:
|
||||
|
||||
```sh
|
||||
node scripts/generate-runner-api-reference.mjs
|
||||
node scripts/generate-runner-api-reference.mjs --check
|
||||
node scripts/generate-runner-experimental-api-metadata.mjs
|
||||
node scripts/generate-runner-experimental-api-metadata.mjs --check
|
||||
```
|
||||
|
||||
Mounted-route coverage tests include experimental routes. Three WebSocket mounts
|
||||
are explicitly classified in the catalog. Shared protocol-action catalogs,
|
||||
provider projections and generated compatibility checks include both tools.
|
||||
|
||||
## Verification and paid evals
|
||||
|
||||
The companion `paperclip-evals` worktree contains `evals/runner-api-tools`.
|
||||
Its README documents explicit case/model selectors, the cumulative budget ledger,
|
||||
fixture reset, progressive batches, and Evalbook generation. No command defaults
|
||||
to running the entire paid suite. Capability, forced operation contracts and
|
||||
paired common-operation regressions are reported separately.
|
||||
|
||||
Provider-free integration tests exercise real runnerd → PRP → authority → HTTP,
|
||||
route validation and audit, stale bindings, Ask/Plan restrictions, identity
|
||||
spoofing, file containment, uncertain mutation receipts and fixture isolation.
|
||||
|
||||
The Evalbook viewer uses the existing shared viewer and stylesheet on master.
|
||||
The report retains actual persisted-state summaries for private local inspection;
|
||||
public replay continues to withhold company-state details.
|
||||
|
||||
The ACPX sidecar includes the upstream terminal-usage accounting correction from
|
||||
`origin/codex/evalbook-default-chat-sept6`. Its qualified Claude executable requires
|
||||
Linux x64. The first macOS stage records a zero-cost ACPX admission failure. A later user-authorized
|
||||
OpenCode/OpenRouter Sonnet profile reached a real HTTP read, but the attempt failed
|
||||
on a missing harness completion contract and incomplete terminal accounting. The
|
||||
harness contract is corrected. The missing fourth request was subsequently
|
||||
recovered from the matching OpenRouter session and generation billing record;
|
||||
the original failed attempt remains immutable. New attempts retain an append-only,
|
||||
flushed event journal and bounded provider trace outside disposable runtime files.
|
||||
Provider-free startup succeeds for OpenRouter Sonnet and DeepSeek. See
|
||||
`doc/plans/2026-09-07-runner-api-production-readiness.md` for remaining release gates.
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssh from "./ssh.js";
|
||||
import * as serverUtils from "./server-utils.js";
|
||||
import {
|
||||
cleanupGitHubOperationLaunchers,
|
||||
prepareGitHubOperationLaunchers,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
|
|
@ -401,3 +405,35 @@ describe("resolveAdapterExecutionTargetCwd", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("GitHub launcher lifecycle", () => {
|
||||
it("removes only the completed run's launchers and leaves concurrent runs usable", async () => {
|
||||
const first = { runId: randomUUID(), target: null };
|
||||
const second = { runId: randomUUID(), target: null };
|
||||
try {
|
||||
const a = await prepareGitHubOperationLaunchers({ ...first, cwd: "/tmp", env: {} });
|
||||
const b = await prepareGitHubOperationLaunchers({ ...second, cwd: "/tmp", env: {} });
|
||||
await cleanupGitHubOperationLaunchers(first);
|
||||
await expect(access(a.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(`${b.PAPERCLIP_GITHUB_LAUNCHER_DIR}/git`, "utf8")).toContain("PAPERCLIP_GITHUB_BROKER_URL");
|
||||
await cleanupGitHubOperationLaunchers(first); // teardown replay is harmless
|
||||
} finally {
|
||||
await cleanupGitHubOperationLaunchers(first);
|
||||
await cleanupGitHubOperationLaunchers(second);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds remote cleanup to one run and rejects traversal", async () => {
|
||||
const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false,
|
||||
stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() })) };
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const,
|
||||
providerKey: "e2b", remoteCwd: "/remote/workspace", runner };
|
||||
await cleanupGitHubOperationLaunchers({ runId: "finished-run", target });
|
||||
expect(runner.execute).toHaveBeenCalledWith({ command: "sh",
|
||||
args: ["-c", "rm -rf -- '/remote/workspace/.paperclip-runtime/github/finished-run'"],
|
||||
cwd: "/remote/workspace", timeoutMs: 5_000 });
|
||||
await expect(cleanupGitHubOperationLaunchers({ runId: "../other", target })).rejects.toThrow("Invalid GitHub launcher run ID");
|
||||
expect(runner.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import net from "node:net";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { githubLauncherSource } from "./github-launcher.js";
|
||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import {
|
||||
prepareCommandManagedRuntime,
|
||||
|
|
@ -1499,6 +1500,70 @@ export function runtimeAssetDir(
|
|||
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
||||
}
|
||||
|
||||
type GitHubLauncherLocation = {
|
||||
runId: string; target: AdapterExecutionTarget | null | undefined;
|
||||
};
|
||||
|
||||
function githubOperationLauncherDirectory(input: GitHubLauncherLocation): string {
|
||||
// Only controller-generated run IDs may name a removable directory.
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID");
|
||||
return input.target?.kind === "remote"
|
||||
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", input.runId)
|
||||
: path.join(os.tmpdir(), "paperclip-github-runtime", input.runId);
|
||||
}
|
||||
|
||||
/** Call only after execution settles, before releasing its remote environment lease. */
|
||||
export async function cleanupGitHubOperationLaunchers(input: GitHubLauncherLocation): Promise<void> {
|
||||
const directory = githubOperationLauncherDirectory(input);
|
||||
if (input.target?.kind === "remote") {
|
||||
const result = await adapterExecutionTargetCommandRunner(input.target).execute({
|
||||
command: "sh", args: ["-c", `rm -rf -- ${shellQuote(directory)}`],
|
||||
cwd: input.target.remoteCwd, timeoutMs: 5_000,
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error("Could not clean managed GitHub launchers");
|
||||
} else {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage token-free launchers next to the execution, not in shared global Git config. */
|
||||
export async function prepareGitHubOperationLaunchers(input: {
|
||||
runId: string; target: AdapterExecutionTarget | null | undefined; cwd: string; env: Record<string, string>;
|
||||
}): Promise<Record<string, string>> {
|
||||
const remote = input.target?.kind === "remote" ? input.target : null;
|
||||
const directory = githubOperationLauncherDirectory(input);
|
||||
const configDirectory = path.posix.join(directory, "gh-config");
|
||||
const basePath = input.env.PATH || (remote ? "/usr/local/bin:/usr/bin:/bin" : process.env.PATH) || "/usr/bin:/bin";
|
||||
const managedPath = `${directory}:${basePath}`;
|
||||
// Login shells may reorder PATH through /etc/profile or path_helper. Restore
|
||||
// the managed launchers after startup without loading a host user's profile.
|
||||
const profile = `export PATH=${shellQuote(managedPath)}\n`;
|
||||
const files: Record<string, string> = Object.fromEntries([
|
||||
...["git", "gh"].map((name) => [name, githubLauncherSource()] as const),
|
||||
...[".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map((name) => [name, profile] as const),
|
||||
]);
|
||||
if (remote) {
|
||||
const runner = adapterExecutionTargetCommandRunner(remote);
|
||||
for (const [program, body] of Object.entries(files)) {
|
||||
await syncRemoteTextFileWithHashSkip({
|
||||
runner, remoteCwd: remote.remoteCwd, remoteDir: directory,
|
||||
remotePath: path.posix.join(directory, program), body,
|
||||
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
|
||||
lockDir: path.posix.join(directory, `.${program}.lock`),
|
||||
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
|
||||
});
|
||||
}
|
||||
const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 });
|
||||
if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers");
|
||||
} else {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await fs.mkdir(configDirectory, { recursive: true, mode: 0o700 });
|
||||
for (const [program, body] of Object.entries(files)) await fs.writeFile(path.join(directory, program), body, { mode: 0o700 });
|
||||
}
|
||||
return { ...input.env, PATH: managedPath, ZDOTDIR: directory, BASH_ENV: `${directory}/.bashrc`,
|
||||
GH_CONFIG_DIR: configDirectory, PAPERCLIP_GITHUB_LAUNCHER_DIR: directory };
|
||||
}
|
||||
|
||||
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
// Keep `x-paperclip-bridge-outcome` in this list. The host marks a
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { githubBrokerEnvironment, githubLauncherSource } from "./github-launcher.js";
|
||||
const exec = promisify(execFile);
|
||||
const cleanups: Array<() => Promise<unknown>> = [];
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
|
||||
|
||||
describe("managed GitHub launchers", () => {
|
||||
it("captures each command's identity and clears host credentials when the next person has none", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-launcher-test-"));
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, "managed"), realBin = path.join(root, "real"), repo = path.join(root, "repo");
|
||||
for (const dir of [bin, realBin, repo, path.join(bin, "gh-config")]) await mkdir(dir, { recursive: true });
|
||||
for (const name of ["git", "gh"]) await writeFile(path.join(bin, name), githubLauncherSource(), { mode: 0o700 });
|
||||
await writeFile(path.join(realBin, "gh"), `#!/usr/bin/env node
|
||||
const {execFileSync}=require('node:child_process');
|
||||
const identity=execFileSync('git',['var','GIT_AUTHOR_IDENT'],{encoding:'utf8'}).trim();
|
||||
process.stdout.write(JSON.stringify({identity, token:process.env.GH_TOKEN ?? null, global:process.env.GIT_CONFIG_GLOBAL, config:process.env.GH_CONFIG_DIR}));
|
||||
`, { mode: 0o700 });
|
||||
let user: string | null = "A", captures = 0;
|
||||
let heldCapture: (() => void) | null = null;
|
||||
let releaseCapture: (() => void) | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
captures++;
|
||||
expect(req.headers.authorization).toBe("Bearer run-capability");
|
||||
const selected = user;
|
||||
res.setHeader("content-type", "application/json");
|
||||
const finish = () => res.end(JSON.stringify(selected ? { status: "available", env: {
|
||||
GH_TOKEN: `credential-${selected}`, GITHUB_TOKEN: `credential-${selected}`,
|
||||
GIT_AUTHOR_NAME: selected, GIT_AUTHOR_EMAIL: `${selected}@example.test`,
|
||||
GIT_COMMITTER_NAME: selected, GIT_COMMITTER_EMAIL: `${selected}@example.test`,
|
||||
} } : { status: "absent", env: {} }));
|
||||
if (heldCapture) { const captured = heldCapture; heldCapture = null; releaseCapture = finish; captured(); }
|
||||
else finish();
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
cleanups.push(() => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())));
|
||||
const address = server.address() as { port: number };
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, ...githubBrokerEnvironment({
|
||||
GH_TOKEN: "ambient-host-token", GIT_AUTHOR_NAME: "Host", GIT_AUTHOR_EMAIL: "host@example.test",
|
||||
}, { url: `http://127.0.0.1:${address.port}`, token: "run-capability" }), PATH: `${bin}:${realBin}:${process.env.PATH}` };
|
||||
const git = async (...args: string[]) => (await exec(path.join(bin, "git"), args, { cwd: repo, env })).stdout.trim();
|
||||
await git("init");
|
||||
await git("commit", "--allow-empty", "-m", "A");
|
||||
user = "B";
|
||||
await git("commit", "--allow-empty", "-m", "B");
|
||||
user = "A";
|
||||
await git("commit", "--allow-empty", "-m", "A again");
|
||||
expect(await git("log", "--format=%an <%ae>|%cn <%ce>" )).toBe("A <A@example.test>|A <A@example.test>\nB <B@example.test>|B <B@example.test>\nA <A@example.test>|A <A@example.test>");
|
||||
const before = captures;
|
||||
const gh = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
|
||||
expect(gh.identity).toContain("A <A@example.test>");
|
||||
expect(gh.token).toBe("credential-A");
|
||||
expect(captures - before).toBe(1); // gh's child Git retains the same capture.
|
||||
const captured = new Promise<void>(resolve => { heldCapture = resolve; });
|
||||
const operationA = exec(path.join(bin, "gh"), [], { cwd: repo, env });
|
||||
await captured;
|
||||
user = "B";
|
||||
const operationB = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
|
||||
releaseCapture!();
|
||||
const completedA = JSON.parse((await operationA).stdout);
|
||||
expect(completedA.token).toBe("credential-A");
|
||||
expect(operationB.token).toBe("credential-B");
|
||||
expect(completedA.config).not.toBe(operationB.config);
|
||||
user = null;
|
||||
await expect(git("var", "GIT_AUTHOR_IDENT")).rejects.toThrow();
|
||||
expect(await git("status", "--porcelain")).toBe(""); // unrelated public/local Git still works
|
||||
expect(env.GH_TOKEN).toBe("");
|
||||
expect(env.GIT_AUTHOR_NAME).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */
|
||||
export function githubLauncherSource(): string {
|
||||
return String.raw`#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawn } = require('node:child_process');
|
||||
const directory = path.dirname(fs.realpathSync(process.argv[1]));
|
||||
const program = path.basename(process.argv[1]);
|
||||
const originalPath = (process.env.PATH || '').split(path.delimiter).filter(p => {
|
||||
try { return fs.realpathSync(p) !== directory; } catch { return true; }
|
||||
});
|
||||
const executable = originalPath.map(p => path.join(p, program)).find(p => {
|
||||
try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!['git', 'gh'].includes(program) || !executable) {
|
||||
process.stderr.write('Paperclip: requested GitHub command is not installed.\n');
|
||||
process.exit(127);
|
||||
}
|
||||
async function main() {
|
||||
let env = { ...process.env };
|
||||
const configRoot = env.GH_CONFIG_DIR || os.tmpdir();
|
||||
fs.mkdirSync(configRoot, { recursive: true, mode: 0o700 });
|
||||
const configDirectory = fs.mkdtempSync(path.join(configRoot, 'paperclip-github-operation-'));
|
||||
fs.chmodSync(configDirectory, 0o700);
|
||||
const cleanup = () => fs.rmSync(configDirectory, { recursive: true, force: true });
|
||||
process.once('exit', cleanup);
|
||||
{
|
||||
for (const key of Object.keys(env)) {
|
||||
if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_AUTHOR_.*|GIT_COMMITTER_.*|GIT_CONFIG_.*|GIT_ASKPASS|SSH_ASKPASS|GIT_SSH.*)$/.test(key)) delete env[key];
|
||||
}
|
||||
Object.assign(env, {
|
||||
GH_CONFIG_DIR: configDirectory,
|
||||
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_AUTHOR_NAME: '', GIT_AUTHOR_EMAIL: '', GIT_COMMITTER_NAME: '', GIT_COMMITTER_EMAIL: '',
|
||||
GIT_CONFIG_COUNT: '4', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '',
|
||||
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_1: 'git@github.com:',
|
||||
GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'ssh://git@github.com/',
|
||||
GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '',
|
||||
});
|
||||
const base = env.PAPERCLIP_API_URL || env.PAPERCLIP_GITHUB_BROKER_URL;
|
||||
let response;
|
||||
if (base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) {
|
||||
const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials';
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
response = await fetch(url, {
|
||||
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
||||
headers: { authorization: 'Bearer ' + (env.PAPERCLIP_GITHUB_BRIDGE_TOKEN || env.PAPERCLIP_API_KEY || env.PAPERCLIP_GITHUB_BROKER_TOKEN),
|
||||
'x-paperclip-github-capability': env.PAPERCLIP_GITHUB_BROKER_TOKEN, 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
if (response.status !== 409) break;
|
||||
await response.arrayBuffer();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
if (!response.ok) throw new Error('GitHub credential context unavailable; retry this operation');
|
||||
const result = await response.json();
|
||||
if (result.status === 'available') {
|
||||
for (const [key, value] of Object.entries(result.env || {})) {
|
||||
if (/^(GH_TOKEN|GITHUB_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_TERMINAL_PROMPT|GIT_AUTHOR_(NAME|EMAIL)|GIT_COMMITTER_(NAME|EMAIL)|GIT_CONFIG_COUNT|GIT_CONFIG_(KEY|VALUE)_\d+)$/.test(key) && typeof value === 'string') env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only this invocation and its children inherit the captured credential.
|
||||
// Its Git children use the real binary, so steering cannot split a gh operation.
|
||||
env.PATH = originalPath.join(path.delimiter);
|
||||
// Nested shell aliases must not reload the parent launcher profile and
|
||||
// recapture a newer identity. All ordinary descendants stay in this operation.
|
||||
env.ZDOTDIR = configDirectory;
|
||||
env.BASH_ENV = '/dev/null';
|
||||
env.GIT_SSH_COMMAND = 'ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes';
|
||||
const child = spawn(executable, process.argv.slice(2), { env, stdio: 'inherit' });
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
||||
child.once('error', () => { process.stderr.write('Paperclip: GitHub command could not start.\n'); process.exitCode = 1; });
|
||||
child.once('exit', (code, signal) => { process.exitCode = code === null ? 128 : code; });
|
||||
}
|
||||
main().catch(() => { process.stderr.write('Paperclip: GitHub credential context unavailable; retry this operation.\n'); process.exitCode = 1; });
|
||||
`;
|
||||
}
|
||||
|
||||
/** Override inherited credentials even when adapters merge the host environment later. */
|
||||
export function githubBrokerEnvironment(input: Record<string, unknown>, broker: { url: string; token: string }): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) if (typeof value === "string") env[key] = value;
|
||||
for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_CONFIG_COUNT", "PAPERCLIP_GITHUB_OPERATION_ACTIVE"]) env[key] = "";
|
||||
for (const key of Object.keys(env)) {
|
||||
if (/^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) env[key] = "";
|
||||
}
|
||||
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
env.GIT_CONFIG_SYSTEM = "/dev/null";
|
||||
env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
env.GIT_ASKPASS = "";
|
||||
env.SSH_ASKPASS = "";
|
||||
env.GIT_SSH_COMMAND = "ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes";
|
||||
env.SSH_AUTH_SOCK = "";
|
||||
env.PAPERCLIP_GITHUB_BROKER_URL = broker.url;
|
||||
env.PAPERCLIP_GITHUB_BROKER_TOKEN = broker.token;
|
||||
return env;
|
||||
}
|
||||
|
|
@ -31,6 +31,13 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe("local process sandbox", () => {
|
||||
it.runIf(process.platform !== "linux")("rejects sandbox scopes on unsupported hosts", async () => {
|
||||
await expect(buildLocalProcessSandboxSpawnTarget({
|
||||
executable: process.execPath, args: ["-e", "process.exit(0)"], cwd: process.cwd(),
|
||||
options: { workspaceDir: process.cwd(), networkScope: "deny" },
|
||||
})).rejects.toThrow("supported only on Linux");
|
||||
});
|
||||
|
||||
it("parses read-only and writable extra paths", () => {
|
||||
expect(parseLocalProcessSandboxExtraPaths(["/opt/cache", { path: "/var/lib/tool", access: "rw" }])).toEqual([
|
||||
{ path: "/opt/cache", access: "ro" },
|
||||
|
|
@ -52,7 +59,7 @@ describe("local process sandbox", () => {
|
|||
expect(() => parseLocalProcessNetworkScope("public")).toThrow('"deny" or "allowlist"');
|
||||
});
|
||||
|
||||
it("describes every valid allowlist input when no proxy rules remain", async () => {
|
||||
it.runIf(process.platform === "linux")("describes every valid allowlist input when no proxy rules remain", async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-rules-"));
|
||||
cleanup.push(workspace);
|
||||
|
||||
|
|
@ -69,7 +76,7 @@ describe("local process sandbox", () => {
|
|||
})).rejects.toThrow("valid networkAllowlist hostname or HTTP(S) networkTrustedUrl");
|
||||
});
|
||||
|
||||
it("builds a fresh-root bubblewrap command with workspace access", async () => {
|
||||
it.runIf(process.platform === "linux")("builds a fresh-root bubblewrap command with workspace access", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-"));
|
||||
cleanup.push(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
@ -96,7 +103,7 @@ describe("local process sandbox", () => {
|
|||
expect(target.args.slice(-3)).toEqual([process.execPath, "-e", "console.log('ok')"]);
|
||||
});
|
||||
|
||||
it("binds a confined absolute alias to the synchronized workspace", async () => {
|
||||
it.runIf(process.platform === "linux")("binds a confined absolute alias to the synchronized workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-alias-"));
|
||||
cleanup.push(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
@ -116,7 +123,7 @@ describe("local process sandbox", () => {
|
|||
expect(target.args).toEqual(expect.arrayContaining(["--bind", workspace, "/app"]));
|
||||
});
|
||||
|
||||
it("rejects writable out-of-tree paths without an outbound restore mapping", async () => {
|
||||
it.runIf(process.platform === "linux")("rejects writable out-of-tree paths without an outbound restore mapping", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-outbound-"));
|
||||
cleanup.push(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
@ -136,7 +143,7 @@ describe("local process sandbox", () => {
|
|||
})).rejects.toThrow("has no outbound restore mapping");
|
||||
});
|
||||
|
||||
it("builds a network-only namespace without changing filesystem visibility", async () => {
|
||||
it.runIf(process.platform === "linux")("builds a network-only namespace without changing filesystem visibility", async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-"));
|
||||
cleanup.push(workspace);
|
||||
const target = await buildLocalProcessSandboxSpawnTarget({
|
||||
|
|
@ -152,7 +159,7 @@ describe("local process sandbox", () => {
|
|||
expect(target.env?.HTTP_PROXY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards allowed proxy targets with a deep TMPDIR and rejects other hosts", async () => {
|
||||
it.runIf(process.platform === "linux")("forwards allowed proxy targets with a deep TMPDIR and rejects other hosts", async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-"));
|
||||
cleanup.push(workspace);
|
||||
const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment"));
|
||||
|
|
@ -228,7 +235,7 @@ describe("local process sandbox", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("always permits trusted Paperclip control-plane URLs", async () => {
|
||||
it.runIf(process.platform === "linux")("always permits trusted Paperclip control-plane URLs", async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-trusted-"));
|
||||
cleanup.push(workspace);
|
||||
const server = http.createServer((_request, response) => response.end("control-plane-response"));
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ describe("sandbox callback bridge", () => {
|
|||
client: createFileSystemSandboxCallbackBridgeQueueClient(),
|
||||
queueDir,
|
||||
authorizeRequest: async (request) =>
|
||||
request.path === "/api/agents/me" ? null : `Route not allowed: ${request.method} ${request.path}`,
|
||||
["/api/agents/me", "/runtime-tools/github/credentials"].includes(request.path) ? null : `Route not allowed: ${request.method} ${request.path}`,
|
||||
handleRequest: async (request) => {
|
||||
seenRequests.push({
|
||||
method: request.method,
|
||||
|
|
@ -265,6 +265,23 @@ describe("sandbox callback bridge", () => {
|
|||
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
|
||||
expect(seenRequests[0]?.headers["x-paperclip-run-id"]).toBeUndefined();
|
||||
|
||||
const githubResponse = await fetch(`${bridge.baseUrl}/runtime-tools/github/credentials`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${bridgeToken}`,
|
||||
"content-type": "application/json",
|
||||
"x-paperclip-github-capability": "test-run-scoped-capability",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
expect(githubResponse.status).toBe(200);
|
||||
await githubResponse.arrayBuffer();
|
||||
expect(seenRequests[1]).toMatchObject({
|
||||
method: "POST", path: "/runtime-tools/github/credentials", body: "{}",
|
||||
headers: { "x-paperclip-github-capability": "test-run-scoped-capability" },
|
||||
});
|
||||
expect(seenRequests[1]?.headers.authorization).toBeUndefined();
|
||||
|
||||
});
|
||||
|
||||
it("denies non-allowlisted requests by default", async () => {
|
||||
|
|
@ -1301,6 +1318,7 @@ describe("sandbox callback bridge", () => {
|
|||
|
||||
it("permits the documented heartbeat surface and denies unrelated routes", () => {
|
||||
const allowed: Array<{ method: string; path: string }> = [
|
||||
{ method: "POST", path: "/runtime-tools/github/credentials" },
|
||||
{ method: "GET", path: "/api/agents/me" },
|
||||
{ method: "GET", path: "/api/agents/me/inbox-lite" },
|
||||
{ method: "GET", path: "/api/agents/me/inbox/mine" },
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export interface SandboxCallbackBridgeRouteRule {
|
|||
// reverse bridge. Keep this in sync with the Paperclip skill in
|
||||
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
|
||||
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
|
||||
// Runtime capability authentication is independently checked by the controller.
|
||||
{ method: "POST", path: /^\/runtime-tools\/github\/credentials$/ },
|
||||
// Identity, inbox, agent self-management
|
||||
{ method: "GET", path: /^\/api\/agents\/me$/ },
|
||||
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
|
||||
|
|
@ -186,6 +188,7 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
|
|||
"content-type",
|
||||
"if-match",
|
||||
"if-none-match",
|
||||
"x-paperclip-github-capability",
|
||||
] as const;
|
||||
|
||||
export interface SandboxCallbackBridgeRequest {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
CREATE TABLE IF NOT EXISTS "run_identity_contexts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"revision" integer NOT NULL,
|
||||
"responsible_user_id" text,
|
||||
"message_id" uuid,
|
||||
"parent_context_id" uuid,
|
||||
"cause" text NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"status" text DEFAULT 'accepted' NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"github" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "active_identity_context_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_identity_contexts_company_id_companies_id_fk' AND conrelid = 'public.run_identity_contexts'::regclass) THEN
|
||||
ALTER TABLE "run_identity_contexts" ADD CONSTRAINT "run_identity_contexts_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
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "run_identity_contexts_run_revision_idx" ON "run_identity_contexts" USING btree ("run_id","revision");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "run_identity_contexts_run_correlation_idx" ON "run_identity_contexts" USING btree ("run_id","correlation_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "run_identity_contexts_company_run_idx" ON "run_identity_contexts" USING btree ("company_id","run_id");
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "origin_identity_context_id" uuid;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "source_identity_context_id" uuid;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "continuation_identity_context_id" uuid;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "run_identity_contexts" DROP CONSTRAINT IF EXISTS "run_identity_contexts_run_id_heartbeat_runs_id_fk";
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "run_identity_contexts" DROP CONSTRAINT IF EXISTS "run_identity_contexts_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "run_identity_contexts" ADD CONSTRAINT "run_identity_contexts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "f72ac80a-8900-419f-937a-4aa9a376fe47",
|
||||
"id": "d7f6fa91-15eb-49d3-9e04-1b26eecef6c4",
|
||||
"prevId": "96d549ec-9cf2-4544-a47f-ed9c2b5e6db3",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -17759,6 +17759,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_identity_context_id": {
|
||||
"name": "active_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
|
@ -33502,6 +33508,191 @@
|
|||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.run_identity_contexts": {
|
||||
"name": "run_identity_contexts",
|
||||
"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
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responsible_user_id": {
|
||||
"name": "responsible_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_context_id": {
|
||||
"name": "parent_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cause": {
|
||||
"name": "cause",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"correlation_id": {
|
||||
"name": "correlation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'accepted'"
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"github": {
|
||||
"name": "github",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"run_identity_contexts_run_revision_idx": {
|
||||
"name": "run_identity_contexts_run_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_run_correlation_idx": {
|
||||
"name": "run_identity_contexts_run_correlation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "correlation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_company_run_idx": {
|
||||
"name": "run_identity_contexts_company_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"run_identity_contexts_company_id_companies_id_fk": {
|
||||
"name": "run_identity_contexts_company_id_companies_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"run_identity_contexts_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "run_identity_contexts_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
|
|
@ -42851,563 +43042,6 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.task_repository_bindings": {
|
||||
"name": "task_repository_bindings",
|
||||
"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
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repo_url": {
|
||||
"name": "repo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repo_ref": {
|
||||
"name": "repo_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"setup_complete": {
|
||||
"name": "setup_complete",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_key": {
|
||||
"name": "checkpoint_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_sha256": {
|
||||
"name": "checkpoint_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_at": {
|
||||
"name": "checkpoint_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_repository_bindings_company_id_companies_id_fk": {
|
||||
"name": "task_repository_bindings_company_id_companies_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_repository_bindings_task_id_issues_id_fk": {
|
||||
"name": "task_repository_bindings_task_id_issues_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"task_repository_bindings_workspace_uq": {
|
||||
"name": "task_repository_bindings_workspace_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"workspace_id"
|
||||
]
|
||||
},
|
||||
"task_repository_bindings_name_uq": {
|
||||
"name": "task_repository_bindings_name_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_file_operations": {
|
||||
"name": "work_file_operations",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation_id": {
|
||||
"name": "operation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_file_operations",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_file_operations_receipt_uq": {
|
||||
"name": "work_file_operations_receipt_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"folder_id",
|
||||
"operation_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_files": {
|
||||
"name": "work_files",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'file'"
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'application/octet-stream'"
|
||||
},
|
||||
"executable": {
|
||||
"name": "executable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_files_folder_path_uq": {
|
||||
"name": "work_files_folder_path_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"work_files\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"work_files_company_folder_idx": {
|
||||
"name": "work_files_company_folder_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_files",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_runs": {
|
||||
"name": "work_folder_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'starting'"
|
||||
},
|
||||
"last_saved_at": {
|
||||
"name": "last_saved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_requested": {
|
||||
"name": "refresh_requested",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_runs_company_idx": {
|
||||
"name": "work_folder_runs_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"work_folder_runs_company_id_companies_id_fk": {
|
||||
"name": "work_folder_runs_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folders": {
|
||||
"name": "work_folders",
|
||||
"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
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imported_at": {
|
||||
"name": "imported_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_folders_company_id_companies_id_fk": {
|
||||
"name": "work_folders_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folders",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_folders_owner_uq": {
|
||||
"name": "work_folders_owner_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"scope",
|
||||
"owner_id"
|
||||
]
|
||||
},
|
||||
"work_folders_company_id_uq": {
|
||||
"name": "work_folders_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "d24a86b1-7dc7-40f0-bdfd-77dab991d9e9",
|
||||
"prevId": "f72ac80a-8900-419f-937a-4aa9a376fe47",
|
||||
"id": "46b78d2a-bb6b-454c-820d-fd51dcf65885",
|
||||
"prevId": "d7f6fa91-15eb-49d3-9e04-1b26eecef6c4",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
|
@ -17759,6 +17759,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_identity_context_id": {
|
||||
"name": "active_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
|
@ -24778,6 +24784,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_identity_context_id": {
|
||||
"name": "origin_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_fingerprint": {
|
||||
"name": "origin_fingerprint",
|
||||
"type": "text",
|
||||
|
|
@ -33502,6 +33514,191 @@
|
|||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.run_identity_contexts": {
|
||||
"name": "run_identity_contexts",
|
||||
"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
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responsible_user_id": {
|
||||
"name": "responsible_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_context_id": {
|
||||
"name": "parent_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cause": {
|
||||
"name": "cause",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"correlation_id": {
|
||||
"name": "correlation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'accepted'"
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"github": {
|
||||
"name": "github",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"run_identity_contexts_run_revision_idx": {
|
||||
"name": "run_identity_contexts_run_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_run_correlation_idx": {
|
||||
"name": "run_identity_contexts_run_correlation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "correlation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_company_run_idx": {
|
||||
"name": "run_identity_contexts_company_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"run_identity_contexts_company_id_companies_id_fk": {
|
||||
"name": "run_identity_contexts_company_id_companies_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"run_identity_contexts_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "run_identity_contexts_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
|
|
@ -42851,570 +43048,6 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.task_repository_bindings": {
|
||||
"name": "task_repository_bindings",
|
||||
"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
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repo_url": {
|
||||
"name": "repo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repo_ref": {
|
||||
"name": "repo_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"setup_complete": {
|
||||
"name": "setup_complete",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_key": {
|
||||
"name": "checkpoint_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_sha256": {
|
||||
"name": "checkpoint_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_at": {
|
||||
"name": "checkpoint_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_repository_bindings_company_id_companies_id_fk": {
|
||||
"name": "task_repository_bindings_company_id_companies_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_repository_bindings_task_id_issues_id_fk": {
|
||||
"name": "task_repository_bindings_task_id_issues_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"task_repository_bindings_workspace_uq": {
|
||||
"name": "task_repository_bindings_workspace_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"workspace_id"
|
||||
]
|
||||
},
|
||||
"task_repository_bindings_name_uq": {
|
||||
"name": "task_repository_bindings_name_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_file_operations": {
|
||||
"name": "work_file_operations",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation_id": {
|
||||
"name": "operation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_file_operations",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_file_operations_receipt_uq": {
|
||||
"name": "work_file_operations_receipt_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"folder_id",
|
||||
"operation_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_files": {
|
||||
"name": "work_files",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'file'"
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'application/octet-stream'"
|
||||
},
|
||||
"executable": {
|
||||
"name": "executable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_files_folder_path_uq": {
|
||||
"name": "work_files_folder_path_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"work_files\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"work_files_company_folder_idx": {
|
||||
"name": "work_files_company_folder_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_files",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_runs": {
|
||||
"name": "work_folder_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"baselines": {
|
||||
"name": "baselines",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'starting'"
|
||||
},
|
||||
"last_saved_at": {
|
||||
"name": "last_saved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_requested": {
|
||||
"name": "refresh_requested",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_runs_company_idx": {
|
||||
"name": "work_folder_runs_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"work_folder_runs_company_id_companies_id_fk": {
|
||||
"name": "work_folder_runs_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folders": {
|
||||
"name": "work_folders",
|
||||
"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
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imported_at": {
|
||||
"name": "imported_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_folders_company_id_companies_id_fk": {
|
||||
"name": "work_folders_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folders",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_folders_owner_uq": {
|
||||
"name": "work_folders_owner_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"scope",
|
||||
"owner_id"
|
||||
]
|
||||
},
|
||||
"work_folders_company_id_uq": {
|
||||
"name": "work_folders_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "c31550bc-131a-466e-ba43-af4cfce04ecd",
|
||||
"prevId": "d24a86b1-7dc7-40f0-bdfd-77dab991d9e9",
|
||||
"id": "ab855f2b-4847-427f-bad6-320e9a988a76",
|
||||
"prevId": "46b78d2a-bb6b-454c-820d-fd51dcf65885",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
|
@ -17759,6 +17759,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_identity_context_id": {
|
||||
"name": "active_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
|
@ -23049,6 +23055,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_identity_context_id": {
|
||||
"name": "source_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_run_id": {
|
||||
"name": "source_run_id",
|
||||
"type": "uuid",
|
||||
|
|
@ -24778,6 +24790,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_identity_context_id": {
|
||||
"name": "origin_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_fingerprint": {
|
||||
"name": "origin_fingerprint",
|
||||
"type": "text",
|
||||
|
|
@ -33502,6 +33520,191 @@
|
|||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.run_identity_contexts": {
|
||||
"name": "run_identity_contexts",
|
||||
"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
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responsible_user_id": {
|
||||
"name": "responsible_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_context_id": {
|
||||
"name": "parent_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cause": {
|
||||
"name": "cause",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"correlation_id": {
|
||||
"name": "correlation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'accepted'"
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"github": {
|
||||
"name": "github",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"run_identity_contexts_run_revision_idx": {
|
||||
"name": "run_identity_contexts_run_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_run_correlation_idx": {
|
||||
"name": "run_identity_contexts_run_correlation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "correlation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_company_run_idx": {
|
||||
"name": "run_identity_contexts_company_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"run_identity_contexts_company_id_companies_id_fk": {
|
||||
"name": "run_identity_contexts_company_id_companies_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"run_identity_contexts_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "run_identity_contexts_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
|
|
@ -42851,577 +43054,6 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.task_repository_bindings": {
|
||||
"name": "task_repository_bindings",
|
||||
"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
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repo_url": {
|
||||
"name": "repo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repo_ref": {
|
||||
"name": "repo_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"setup_complete": {
|
||||
"name": "setup_complete",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_key": {
|
||||
"name": "checkpoint_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_sha256": {
|
||||
"name": "checkpoint_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_at": {
|
||||
"name": "checkpoint_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_repository_bindings_company_id_companies_id_fk": {
|
||||
"name": "task_repository_bindings_company_id_companies_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_repository_bindings_task_id_issues_id_fk": {
|
||||
"name": "task_repository_bindings_task_id_issues_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"task_repository_bindings_workspace_uq": {
|
||||
"name": "task_repository_bindings_workspace_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"workspace_id"
|
||||
]
|
||||
},
|
||||
"task_repository_bindings_name_uq": {
|
||||
"name": "task_repository_bindings_name_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_file_operations": {
|
||||
"name": "work_file_operations",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation_id": {
|
||||
"name": "operation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_file_operations",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_file_operations_receipt_uq": {
|
||||
"name": "work_file_operations_receipt_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"folder_id",
|
||||
"operation_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_files": {
|
||||
"name": "work_files",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'file'"
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'application/octet-stream'"
|
||||
},
|
||||
"executable": {
|
||||
"name": "executable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_files_folder_path_uq": {
|
||||
"name": "work_files_folder_path_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"work_files\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"work_files_company_folder_idx": {
|
||||
"name": "work_files_company_folder_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_files",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_runs": {
|
||||
"name": "work_folder_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"baselines": {
|
||||
"name": "baselines",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"pending_operations": {
|
||||
"name": "pending_operations",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'starting'"
|
||||
},
|
||||
"last_saved_at": {
|
||||
"name": "last_saved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_requested": {
|
||||
"name": "refresh_requested",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_runs_company_idx": {
|
||||
"name": "work_folder_runs_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"work_folder_runs_company_id_companies_id_fk": {
|
||||
"name": "work_folder_runs_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folders": {
|
||||
"name": "work_folders",
|
||||
"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
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imported_at": {
|
||||
"name": "imported_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_folders_company_id_companies_id_fk": {
|
||||
"name": "work_folders_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folders",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_folders_owner_uq": {
|
||||
"name": "work_folders_owner_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"scope",
|
||||
"owner_id"
|
||||
]
|
||||
},
|
||||
"work_folders_company_id_uq": {
|
||||
"name": "work_folders_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "8a47b243-59af-467d-a42a-028569f46b7b",
|
||||
"prevId": "c31550bc-131a-466e-ba43-af4cfce04ecd",
|
||||
"id": "1e06c040-9de8-4fb0-974a-7487f64abd00",
|
||||
"prevId": "ab855f2b-4847-427f-bad6-320e9a988a76",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
|
@ -17759,6 +17759,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"active_identity_context_id": {
|
||||
"name": "active_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
|
@ -23049,6 +23055,12 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_identity_context_id": {
|
||||
"name": "source_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_run_id": {
|
||||
"name": "source_run_id",
|
||||
"type": "uuid",
|
||||
|
|
@ -24778,6 +24790,18 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_identity_context_id": {
|
||||
"name": "origin_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"continuation_identity_context_id": {
|
||||
"name": "continuation_identity_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_fingerprint": {
|
||||
"name": "origin_fingerprint",
|
||||
"type": "text",
|
||||
|
|
@ -33502,6 +33526,191 @@
|
|||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.run_identity_contexts": {
|
||||
"name": "run_identity_contexts",
|
||||
"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
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"responsible_user_id": {
|
||||
"name": "responsible_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"parent_context_id": {
|
||||
"name": "parent_context_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cause": {
|
||||
"name": "cause",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"correlation_id": {
|
||||
"name": "correlation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'accepted'"
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"github": {
|
||||
"name": "github",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"run_identity_contexts_run_revision_idx": {
|
||||
"name": "run_identity_contexts_run_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_run_correlation_idx": {
|
||||
"name": "run_identity_contexts_run_correlation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "correlation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"run_identity_contexts_company_run_idx": {
|
||||
"name": "run_identity_contexts_company_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"run_identity_contexts_company_id_companies_id_fk": {
|
||||
"name": "run_identity_contexts_company_id_companies_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"run_identity_contexts_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "run_identity_contexts_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "run_identity_contexts",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
|
|
@ -42851,655 +43060,6 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.task_repository_bindings": {
|
||||
"name": "task_repository_bindings",
|
||||
"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
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repo_url": {
|
||||
"name": "repo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repo_ref": {
|
||||
"name": "repo_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"setup_complete": {
|
||||
"name": "setup_complete",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_key": {
|
||||
"name": "checkpoint_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_sha256": {
|
||||
"name": "checkpoint_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_at": {
|
||||
"name": "checkpoint_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_repository_bindings_company_id_companies_id_fk": {
|
||||
"name": "task_repository_bindings_company_id_companies_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_repository_bindings_task_id_issues_id_fk": {
|
||||
"name": "task_repository_bindings_task_id_issues_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"task_repository_bindings_workspace_uq": {
|
||||
"name": "task_repository_bindings_workspace_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"workspace_id"
|
||||
]
|
||||
},
|
||||
"task_repository_bindings_name_uq": {
|
||||
"name": "task_repository_bindings_name_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_file_operations": {
|
||||
"name": "work_file_operations",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation_id": {
|
||||
"name": "operation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_file_operations",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_file_operations_receipt_uq": {
|
||||
"name": "work_file_operations_receipt_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"folder_id",
|
||||
"operation_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_files": {
|
||||
"name": "work_files",
|
||||
"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
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'file'"
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'application/octet-stream'"
|
||||
},
|
||||
"executable": {
|
||||
"name": "executable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_files_folder_path_uq": {
|
||||
"name": "work_files_folder_path_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"work_files\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"work_files_company_folder_idx": {
|
||||
"name": "work_files_company_folder_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_files",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_objects": {
|
||||
"name": "work_folder_objects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repository_binding_id": {
|
||||
"name": "repository_binding_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"delete_after": {
|
||||
"name": "delete_after",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_objects_cleanup_idx": {
|
||||
"name": "work_folder_objects_cleanup_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "delete_after",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_runs": {
|
||||
"name": "work_folder_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"baselines": {
|
||||
"name": "baselines",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"pending_operations": {
|
||||
"name": "pending_operations",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'starting'"
|
||||
},
|
||||
"last_saved_at": {
|
||||
"name": "last_saved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_requested": {
|
||||
"name": "refresh_requested",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_runs_company_idx": {
|
||||
"name": "work_folder_runs_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"work_folder_runs_company_id_companies_id_fk": {
|
||||
"name": "work_folder_runs_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folders": {
|
||||
"name": "work_folders",
|
||||
"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
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imported_at": {
|
||||
"name": "imported_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_folders_company_id_companies_id_fk": {
|
||||
"name": "work_folders_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folders",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_folders_owner_uq": {
|
||||
"name": "work_folders_owner_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"scope",
|
||||
"owner_id"
|
||||
]
|
||||
},
|
||||
"work_folders_company_id_uq": {
|
||||
"name": "work_folders_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1671,30 +1671,72 @@
|
|||
{
|
||||
"idx": 240,
|
||||
"version": "7",
|
||||
"when": 1788797570316,
|
||||
"tag": "0240_real_virginia_dare",
|
||||
"when": 1788796987307,
|
||||
"tag": "0240_pink_fantastic_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 241,
|
||||
"version": "7",
|
||||
"when": 1788798215713,
|
||||
"tag": "0241_mixed_crystal",
|
||||
"when": 1788797555060,
|
||||
"tag": "0241_conscious_adam_destine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 242,
|
||||
"version": "7",
|
||||
"when": 1788798605168,
|
||||
"tag": "0242_sudden_bedlam",
|
||||
"when": 1788798139203,
|
||||
"tag": "0242_wide_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 243,
|
||||
"version": "7",
|
||||
"when": 1788799543754,
|
||||
"tag": "0243_ordinary_lucky_pierre",
|
||||
"when": 1788798652047,
|
||||
"tag": "0243_sleepy_metal_master",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 244,
|
||||
"version": "7",
|
||||
"when": 1788804288225,
|
||||
"tag": "0244_organic_meltdown",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 245,
|
||||
"version": "7",
|
||||
"when": 1788804969749,
|
||||
"tag": "0245_misty_nightshade",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 246,
|
||||
"version": "7",
|
||||
"when": 1788823039818,
|
||||
"tag": "0246_real_virginia_dare",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 247,
|
||||
"version": "7",
|
||||
"when": 1788823039819,
|
||||
"tag": "0247_mixed_crystal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 248,
|
||||
"version": "7",
|
||||
"when": 1788823039820,
|
||||
"tag": "0248_sudden_bedlam",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 249,
|
||||
"version": "7",
|
||||
"when": 1788823039821,
|
||||
"tag": "0249_ordinary_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const migrations = [
|
||||
"0240_pink_fantastic_four.sql", "0241_conscious_adam_destine.sql",
|
||||
"0242_wide_lightspeed.sql", "0243_sleepy_metal_master.sql", "0244_organic_meltdown.sql", "0245_misty_nightshade.sql",
|
||||
].map((name) => readFileSync(new URL(`./migrations/${name}`, import.meta.url), "utf8"));
|
||||
|
||||
(support.supported ? describe : describe.skip)("execution identity migration", () => {
|
||||
it("can replay without inventing historical authorship or losing accepted contexts", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-identity-migration-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), historicalRunId = randomUUID(), runId = randomUUID(), contextId = randomUUID();
|
||||
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Identity migration', 'MIG')`;
|
||||
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Shared', 'engineer', 'codex_local')`;
|
||||
await sql`INSERT INTO heartbeat_runs (id, company_id, agent_id, status) VALUES
|
||||
(${historicalRunId}, ${companyId}, ${agentId}, 'succeeded'), (${runId}, ${companyId}, ${agentId}, 'running')`;
|
||||
await sql`INSERT INTO run_identity_contexts (id, company_id, run_id, revision, responsible_user_id, cause, correlation_id, accepted_at)
|
||||
VALUES (${contextId}, ${companyId}, ${runId}, 1, 'person-a', 'instruction', 'dispatch', now())`;
|
||||
await sql`UPDATE heartbeat_runs SET active_identity_context_id = ${contextId}, responsible_user_id = 'person-a' WHERE id = ${runId}`;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (const migration of migrations) {
|
||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await sql.unsafe(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
const [historical] = await sql`SELECT active_identity_context_id, responsible_user_id FROM heartbeat_runs WHERE id = ${historicalRunId}`;
|
||||
expect(historical).toEqual({ active_identity_context_id: null, responsible_user_id: null });
|
||||
const contexts = await sql`SELECT id, responsible_user_id FROM run_identity_contexts WHERE company_id = ${companyId}`;
|
||||
expect(contexts).toEqual([{ id: contextId, responsible_user_id: 'person-a' }]);
|
||||
const [active] = await sql`SELECT active_identity_context_id FROM heartbeat_runs WHERE id = ${runId}`;
|
||||
expect(active.active_identity_context_id).toBe(contextId);
|
||||
// Agent removal deletes its run rows, but surviving task continuations
|
||||
// must retain attribution. Even replaying the migration set must be safe.
|
||||
await sql`DELETE FROM heartbeat_runs WHERE id = ${runId}`;
|
||||
for (const migration of migrations) {
|
||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await sql.unsafe(statement);
|
||||
}
|
||||
}
|
||||
const [retained] = await sql`SELECT run_id, responsible_user_id FROM run_identity_contexts WHERE id = ${contextId}`;
|
||||
expect(retained).toEqual({ run_id: runId, responsible_user_id: 'person-a' });
|
||||
await sql`DELETE FROM heartbeat_runs WHERE company_id = ${companyId}`;
|
||||
await sql`DELETE FROM agents WHERE company_id = ${companyId}`;
|
||||
await sql`DELETE FROM companies WHERE id = ${companyId}`;
|
||||
expect(await sql`SELECT id FROM run_identity_contexts WHERE id = ${contextId}`).toHaveLength(0);
|
||||
} finally {
|
||||
await sql.end();
|
||||
await database.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -26,6 +26,8 @@ export const heartbeatRuns = pgTable(
|
|||
triggerDetail: text("trigger_detail"),
|
||||
status: text("status").notNull().default("queued"),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
// The service validates the company/run boundary; avoid a cyclic schema import.
|
||||
activeIdentityContextId: uuid("active_identity_context_id"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
|
|
|
|||
|
|
@ -184,3 +184,4 @@ export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
|||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
export * from "./work_folders.js";
|
||||
export { runIdentityContexts } from "./run_identity_contexts.js";
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const issueThreadInteractions = pgTable(
|
|||
.default("requested"),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
|
||||
sourceIdentityContextId: uuid("source_identity_context_id"),
|
||||
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
title: text("title"),
|
||||
summary: text("summary"),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export const issues = pgTable(
|
|||
originKind: text("origin_kind").notNull().default("manual"),
|
||||
originId: text("origin_id"),
|
||||
originRunId: text("origin_run_id"),
|
||||
originIdentityContextId: uuid("origin_identity_context_id"),
|
||||
continuationIdentityContextId: uuid("continuation_identity_context_id"),
|
||||
originFingerprint: text("origin_fingerprint").notNull().default("default"),
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { pgTable, uuid, text, integer, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
/** Immutable attribution records. Only acceptance state and redacted diagnostics advance. */
|
||||
export const runIdentityContexts = pgTable("run_identity_contexts", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
// Retain attribution after an agent and its runs are deleted: surviving tasks
|
||||
// and approvals still reference these contexts. The original run UUID is archival.
|
||||
runId: uuid("run_id").notNull(),
|
||||
revision: integer("revision").notNull(),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
messageId: uuid("message_id"),
|
||||
parentContextId: uuid("parent_context_id"),
|
||||
cause: text("cause").notNull(),
|
||||
correlationId: text("correlation_id").notNull(),
|
||||
status: text("status").notNull().default("accepted"),
|
||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||
github: jsonb("github").$type<{ status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string }>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
revisionIdx: uniqueIndex("run_identity_contexts_run_revision_idx").on(t.runId, t.revision),
|
||||
correlationIdx: uniqueIndex("run_identity_contexts_run_correlation_idx").on(t.runId, t.correlationId),
|
||||
companyRunIdx: index("run_identity_contexts_company_run_idx").on(t.companyId, t.runId),
|
||||
}));
|
||||
|
|
@ -106,6 +106,7 @@ interface EmbeddedEvalReport {
|
|||
durationMs: number | null;
|
||||
initialRevision: number;
|
||||
finalRevision: number;
|
||||
finalStateSummary?: string;
|
||||
usage: {
|
||||
agentTurns: number;
|
||||
providerRequests: number | null;
|
||||
|
|
@ -1408,7 +1409,7 @@ export function App() {
|
|||
{embeddedEval !== null ? (
|
||||
<div className="pit-eval-boundary" data-phase="post-run">
|
||||
<strong>Post-run state</strong>
|
||||
<span>{embeddedEval.publication ? "Company-state details withheld from public replay" : `Final mock control-plane revision ${embeddedEval.run.finalRevision}`}</span>
|
||||
<span>{embeddedEval.publication ? "Company-state details withheld from public replay" : embeddedEval.run.finalStateSummary ?? `Final mock control-plane revision ${embeddedEval.run.finalRevision}`}</span>
|
||||
<EvalAssertions assertions={embeddedEval.checks.filter((check) => check.anchor.kind === "run")} />
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,164 @@
|
|||
[
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"default": 5,
|
||||
"maximum": 10,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "search_api",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [
|
||||
"api:discover"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Search the Paperclip API",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"body": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."
|
||||
},
|
||||
"contentType": {
|
||||
"maxLength": 120,
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"artifactId": {}
|
||||
},
|
||||
"required": [
|
||||
"artifactId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"artifactId": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File relative to the active issue workspace. Remote files must first be uploaded as an artifact.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"maxItems": 10,
|
||||
"type": "array"
|
||||
},
|
||||
"operationId": {
|
||||
"description": "Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"pathParams": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"query": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operationId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "call_api",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [
|
||||
"api:call"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Call the Paperclip API",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
|
|
|
|||
|
|
@ -96,7 +96,8 @@
|
|||
"check:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs --check",
|
||||
"check:protocol": "pnpm run typecheck:typescript && pnpm run check:protocol-manifest && pnpm run test:typescript && pnpm run check:replay-goldens",
|
||||
"check:runner": "pnpm run typecheck:rust && pnpm run test:rust && pnpm run check:conformance-parity && pnpm run check:replay-parity",
|
||||
"check:all": "pnpm run check:eval-kernel && pnpm run check:protocol && pnpm run check:runner",
|
||||
"check:all": "pnpm run check:eval-kernel && pnpm run check:protocol && pnpm run check:runner && pnpm run check:api-authority",
|
||||
"check:api-authority": "node scripts/check-api-authority.mjs",
|
||||
"generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs",
|
||||
"check:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check",
|
||||
"check:browser-tokens": "node scripts/check-browser-tokens.mjs",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:e5a90079e1641e50f2f07a320856bbef622c04810384ffdd1895576cc16c4c5c",
|
||||
"catalogSha256": "sha256:842a1515a5b549fcc5df7675f3a96471b2f1ca33f4699cc5dd2ecf6c4235f2ec",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
|
@ -136,7 +136,11 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"evidence": [{ "receiptId": "receipt_evals_seeded" }],
|
||||
"evidence": [
|
||||
{
|
||||
"receiptId": "receipt_evals_seeded"
|
||||
}
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"commandOrCheck": "rejected tool effect stays denied",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "3a10ca2da2dfd1e945eae8af4518f1cd4ae5c1100adc269ca808144a78cdbcfa",
|
||||
"sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -116,7 +116,13 @@ fn matches_task_context_result(result: &Value, expected_canonical: Option<&Value
|
|||
};
|
||||
if result.get("ok") != Some(&json!(true))
|
||||
|| result.get("operationId").and_then(Value::as_str) != Some("get_task_context")
|
||||
|| result.get("callId").and_then(Value::as_str) != Some("semantic-call-1")
|
||||
|| result.get("callId").and_then(Value::as_str)
|
||||
!= Some(
|
||||
expected
|
||||
.get("callId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("semantic-call-1"),
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -533,6 +539,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.iter()
|
||||
.any(|value| value == "--require-codex-home-auth");
|
||||
let durable_turn_ids = args.iter().any(|value| value == "--durable-turn-ids");
|
||||
let durable_tool_ids = args.iter().any(|value| value == "--durable-tool-ids");
|
||||
let expected_canonical_task_context_file =
|
||||
argument(&args, "--expected-canonical-task-context-file");
|
||||
let emit_tool_call = args.iter().any(|value| value == "--emit-tool-call");
|
||||
let replay_completed_tool_call = args
|
||||
.iter()
|
||||
|
|
@ -782,7 +791,19 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.and_then(Value::as_str)
|
||||
.ok_or("semantic tool response omitted content text")?;
|
||||
let result: Value = serde_json::from_str(text)?;
|
||||
if !matches_task_context_result(&result, expected_canonical_task_context.as_ref()) {
|
||||
let expected_from_file = if let Some(path) = &expected_canonical_task_context_file {
|
||||
Some(serde_json::from_str::<Value>(&std::fs::read_to_string(
|
||||
path,
|
||||
)?)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !matches_task_context_result(
|
||||
&result,
|
||||
expected_from_file
|
||||
.as_ref()
|
||||
.or(expected_canonical_task_context.as_ref()),
|
||||
) {
|
||||
return Err("semantic tool response changed the operation result".into());
|
||||
}
|
||||
log_call(call_log.as_deref(), &format!("tool-response:{text}"))?;
|
||||
|
|
@ -1175,7 +1196,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
"params": {
|
||||
"threadId": state.thread_id,
|
||||
"turnId": provider_turn_id,
|
||||
"callId": "semantic-call-1",
|
||||
"callId": if durable_tool_ids { format!("semantic-call-{}", state.next_turn) } else { "semantic-call-1".to_owned() },
|
||||
"tool": "get_task_context",
|
||||
"arguments": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,6 +551,22 @@ pub struct CodexProvider {
|
|||
// entry from this static ceiling, but cannot introduce another environment
|
||||
// variable by changing GIT_CONFIG_COUNT.
|
||||
const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
"ZDOTDIR",
|
||||
"BASH_ENV",
|
||||
"PAPERCLIP_GITHUB_BROKER_URL",
|
||||
"PAPERCLIP_GITHUB_BROKER_TOKEN",
|
||||
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
|
||||
"GH_CONFIG_DIR",
|
||||
"GH_ENTERPRISE_TOKEN",
|
||||
"GITHUB_ENTERPRISE_TOKEN",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_CONFIG_NOSYSTEM",
|
||||
"GIT_ASKPASS",
|
||||
"SSH_ASKPASS",
|
||||
"SSH_AUTH_SOCK",
|
||||
"GIT_SSH_COMMAND",
|
||||
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"PAPERCLIP_GIT_TOKEN",
|
||||
|
|
@ -3128,8 +3144,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn github_credentials_cross_only_the_bounded_provider_environment() {
|
||||
assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 73);
|
||||
assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 89);
|
||||
for key in [
|
||||
"PAPERCLIP_GITHUB_BROKER_URL",
|
||||
"PAPERCLIP_GITHUB_BROKER_TOKEN",
|
||||
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
|
||||
"GH_CONFIG_DIR",
|
||||
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"PAPERCLIP_GIT_TOKEN",
|
||||
|
|
|
|||
|
|
@ -1726,6 +1726,36 @@ impl CodexCommandExecutor {
|
|||
.ok_or_else(|| DurableRunnerError::invalid("Codex provider is unavailable"))
|
||||
}
|
||||
|
||||
// Only the authenticated controller can rebind these run-scoped launch
|
||||
// settings. Executable, model, instructions, approval mode, and all other
|
||||
// flags remain part of the immutable durable provider profile.
|
||||
fn stable_launch_args(args: &[String]) -> Vec<String> {
|
||||
let mut stable = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
if args[index] == "-c" && index + 1 < args.len() {
|
||||
let key = args[index + 1].split('=').next().unwrap_or("");
|
||||
if matches!(
|
||||
key,
|
||||
"permissions.paperclip-runner-workspace-only.filesystem"
|
||||
| "permissions.paperclip-runner-workspace-read-only.filesystem"
|
||||
| "permissions.paperclip-runner-workspace-only.network.enabled"
|
||||
| "permissions.paperclip-runner-workspace-read-only.network.enabled"
|
||||
| "shell_environment_policy.inherit"
|
||||
| "shell_environment_policy.ignore_default_excludes"
|
||||
| "shell_environment_policy.include_only"
|
||||
| "shell_environment_policy.set"
|
||||
) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stable.push(args[index].clone());
|
||||
index += 1;
|
||||
}
|
||||
stable
|
||||
}
|
||||
|
||||
fn attach_run(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
|
||||
let mut next_state = self
|
||||
.state
|
||||
|
|
@ -1752,20 +1782,53 @@ impl CodexCommandExecutor {
|
|||
"run.attach requires a settled Codex provider session with no pending events",
|
||||
));
|
||||
}
|
||||
let runtime_launch_args: Option<Vec<String>> = payload
|
||||
.get("runtimeLaunchArgs")
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
.transpose()
|
||||
.map_err(|_| {
|
||||
DurableRunnerError::invalid("run.attach runtime launch arguments are invalid")
|
||||
})?;
|
||||
if let Some(provider) = payload.get("provider") {
|
||||
let config: CodexProviderConfig =
|
||||
serde_json::from_value(provider.clone()).map_err(|error| {
|
||||
let mut config: CodexProviderConfig = serde_json::from_value(provider.clone())
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("run.attach provider is invalid: {error}"))
|
||||
})?;
|
||||
config
|
||||
.validate()
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
if runtime_launch_args.is_some()
|
||||
&& config.provider == "codex"
|
||||
&& Self::stable_launch_args(&config.args)
|
||||
== Self::stable_launch_args(&next_state.config.args)
|
||||
{
|
||||
config.args = next_state.config.args.clone();
|
||||
}
|
||||
if config != next_state.config {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"run.attach cannot change the durable Codex provider profile",
|
||||
));
|
||||
}
|
||||
}
|
||||
let runtime_launch_changed = if let Some(args) = runtime_launch_args {
|
||||
if next_state.config.provider != "codex"
|
||||
|| Self::stable_launch_args(&args)
|
||||
!= Self::stable_launch_args(&next_state.config.args)
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"run.attach cannot change protected launch arguments",
|
||||
));
|
||||
}
|
||||
let changed = args != next_state.config.args;
|
||||
next_state.config.args = args;
|
||||
next_state
|
||||
.config
|
||||
.validate()
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let completion_contract = completion_contract(payload)?;
|
||||
let tool_set = authorized_tool_set(payload)?;
|
||||
next_state
|
||||
|
|
@ -1791,21 +1854,22 @@ impl CodexCommandExecutor {
|
|||
let provider = self.provider.as_mut().ok_or_else(|| {
|
||||
DurableRunnerError::invalid("run.attach requires the restored Codex provider process")
|
||||
})?;
|
||||
let retained_provider = provider
|
||||
.attach_run_in_place(
|
||||
next_state.tool_bridge.authorized_tools().cloned(),
|
||||
next_state.completion_contract.as_ref().map(|contract| {
|
||||
(
|
||||
contract.revision.as_str(),
|
||||
contract.criterion_ids.as_slice(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to retain Codex for warm run attachment: {error}"
|
||||
))
|
||||
})?;
|
||||
let retained_provider = !runtime_launch_changed
|
||||
&& provider
|
||||
.attach_run_in_place(
|
||||
next_state.tool_bridge.authorized_tools().cloned(),
|
||||
next_state.completion_contract.as_ref().map(|contract| {
|
||||
(
|
||||
contract.revision.as_str(),
|
||||
contract.criterion_ids.as_slice(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to retain Codex for warm run attachment: {error}"
|
||||
))
|
||||
})?;
|
||||
if !retained_provider {
|
||||
provider.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
|
|
@ -3260,6 +3324,46 @@ impl CommandExecutor for CodexCommandExecutor {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn runtime_launch_rebinding_preserves_protected_arguments() {
|
||||
let before: Vec<String> = vec![
|
||||
"-c",
|
||||
"default_permissions=\"paperclip-runner-workspace-only\"",
|
||||
"-c",
|
||||
"shell_environment_policy.set={PATH=\"/run/a\"}",
|
||||
"--disable",
|
||||
"image_generation",
|
||||
"app-server",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let after: Vec<String> = vec![
|
||||
"-c",
|
||||
"default_permissions=\"paperclip-runner-workspace-only\"",
|
||||
"-c",
|
||||
"shell_environment_policy.set={PATH=\"/run/b\"}",
|
||||
"-c",
|
||||
"shell_environment_policy.include_only=[\"PAPERCLIP_GITHUB_BROKER_TOKEN\"]",
|
||||
"--disable",
|
||||
"image_generation",
|
||||
"app-server",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
CodexCommandExecutor::stable_launch_args(&before),
|
||||
CodexCommandExecutor::stable_launch_args(&after)
|
||||
);
|
||||
let mut unsafe_args = after;
|
||||
unsafe_args.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
|
||||
assert_ne!(
|
||||
CodexCommandExecutor::stable_launch_args(&before),
|
||||
CodexCommandExecutor::stable_launch_args(&unsafe_args)
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn opencode_result_state() -> CodexProviderState {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
// The general server test job has no Rust binary. This check builds and stages
|
||||
// its own binary, then requires the complete runnerd → PRP → authority → HTTP test.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
function run(args, cwd, env = process.env) {
|
||||
const result = spawnSync(pnpm, args, { cwd, env, stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
run(["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"], fileURLToPath(new URL("../../../", import.meta.url)));
|
||||
run(["run", "build:binary"], fileURLToPath(new URL("../", import.meta.url)));
|
||||
run([
|
||||
"exec", "vitest", "run",
|
||||
"server/src/services/native-runtime/runner-api.test.ts",
|
||||
"server/src/services/native-runtime/runner-api-rollout.test.ts",
|
||||
"server/src/services/native-runtime/runner-api.integration.test.ts",
|
||||
], fileURLToPath(new URL("../../../", import.meta.url)), {
|
||||
...process.env, PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION: "1",
|
||||
});
|
||||
|
|
@ -7,10 +7,48 @@
|
|||
"src/scenarios/scenario-plan.ts"
|
||||
],
|
||||
"counts": {
|
||||
"actions": 41,
|
||||
"actions": 43,
|
||||
"legacyRequirements": 106
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "search_api",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:search_api",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::search_api has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:search_api"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::search_api has a schema-valid canonical example and every declared projection",
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "call_api",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:call_api",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::call_api has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:call_api"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::call_api has a schema-valid canonical example and every declared projection",
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "administer_company",
|
||||
"ownership": "optional_agent_tool",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,14 @@
|
|||
"id": "test_escape_hatch",
|
||||
"description": "Controlled skill-test transport only; never product coverage.",
|
||||
"operationIds": ["generic_api_request"]
|
||||
},
|
||||
{
|
||||
"id": "api_fallback",
|
||||
"description": "Discover and invoke HTTP API operations unsupported by available dedicated tools. Production authority and route authorization remain required.",
|
||||
"operationIds": [
|
||||
"search_api",
|
||||
"call_api"
|
||||
]
|
||||
}
|
||||
],
|
||||
"behaviorGroups": [
|
||||
|
|
@ -251,12 +259,12 @@
|
|||
"legacyGroup": 13,
|
||||
"name": "Reference files",
|
||||
"owner": "optional domain tools + test-only escape hatch",
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request"],
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api"],
|
||||
"controlPlaneOperationIds": ["append_audit_record"],
|
||||
"realSurface": "case, routine, company-skill, secret, portability, and administration services",
|
||||
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
|
||||
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
|
||||
"gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage."
|
||||
"gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
|
||||
},
|
||||
{
|
||||
"id": "mh",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
|||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **43 reconciled semantic operations** (14 always, 29 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ Placement has exactly three outcomes:
|
|||
|
||||
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
|
||||
|
||||
### Optional operations (27) and grant groups (11)
|
||||
### Optional operations (29) and grant groups (12)
|
||||
|
||||
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
|
||||
|
||||
|
|
@ -104,6 +104,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `secrets` | `list_secret_metadata`<br>`read_secret_value` | `secrets:metadata:read`<br>`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. |
|
||||
| `portability_admin` | `export_company`<br>`administer_company` | `company:admin`<br>`portability:export` | Export portable company state; broad administration remains deferred until split into governed operations. |
|
||||
| `test_escape_hatch` | `generic_api_request` | `test:generic_api_request` | Controlled skill-test transport only; never product coverage. |
|
||||
| `api_fallback` | `search_api`<br>`call_api` | `api:call`<br>`api:discover` | Discover and invoke HTTP API operations unsupported by available dedicated tools. Production authority and route authorization remain required. |
|
||||
|
||||
### Reconciled semantic-operation ledger
|
||||
|
||||
|
|
@ -114,6 +115,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `administer_company` | `optional_agent_tool` | `company:admin` | `standard`<br>`skill_test`<br>roles: `board`<br>`ceo`<br>`admin` | `admin` | `required` | no | `mock_extension:company.admin` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `answer_status_question` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `block_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:block_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `call_api` | `optional_agent_tool` | `api:call` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `company_write` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
|
||||
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`<br>`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`<br>`live_codex` | `unbound`<br>workspace service lifecycle event<br>catalog PRP status: `audit_pending` |
|
||||
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
|
|
@ -147,6 +149,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`ask`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `request_review` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
|
||||
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
|
|
@ -173,7 +176,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
|||
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
|
||||
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
|
||||
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
|
||||
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
|
||||
|
|
@ -450,10 +453,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
|
|||
### Catalog split and deliberate replacement
|
||||
|
||||
- Scenario/eval catalog: **37** operations.
|
||||
- Live dispatcher catalog: **28** operations.
|
||||
- Shared: **24**; union/canonical authority: **41**.
|
||||
- Live dispatcher catalog: **30** operations.
|
||||
- Shared: **24**; union/canonical authority: **43**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_projects`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`.
|
||||
- Live-only: `call_api`, `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`, `search_api`.
|
||||
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
|
||||
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperat
|
|||
.sort((left, right) => left.operationId.localeCompare(right.operationId)),
|
||||
);
|
||||
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
|
||||
if (byId.size !== 41) throw new Error(`expected 41 canonical semantic operations, found ${byId.size}`);
|
||||
if (byId.size !== 43) throw new Error(`expected 43 canonical semantic operations, found ${byId.size}`);
|
||||
export function capabilityCanonicalOperation(operationId: string): CapabilityCanonicalOperation | undefined { return byId.get(operationId); }
|
||||
export function capabilityCanonicalOperationsForSurface(surface: CapabilityCatalogSurface): readonly CapabilityCanonicalOperation[] { return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); }
|
||||
export function capabilityCanonicalOperationIds(): readonly string[] { return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); }
|
||||
|
|
|
|||
|
|
@ -20,16 +20,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
it("pins the reconciled op-set relationship between the two catalogs", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.scenarioCount).toBe(37);
|
||||
expect(summary.liveCount).toBe(28);
|
||||
expect(summary.liveCount).toBe(30);
|
||||
expect(summary.sharedCount).toBe(24);
|
||||
expect(summary.unionCount).toBe(41);
|
||||
expect(summary.unionCount).toBe(43);
|
||||
// Any operation added to or removed from either catalog without a
|
||||
// reconciliation decision changes these exact sets and fails the gate.
|
||||
expect(summary.liveOnly).toEqual([
|
||||
"call_api",
|
||||
"get_agent",
|
||||
"get_approval",
|
||||
"get_approval_context",
|
||||
"schedule_wake",
|
||||
"search_api",
|
||||
]);
|
||||
expect(summary.scenarioOnly).toEqual([
|
||||
"administer_company",
|
||||
|
|
@ -49,7 +51,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
});
|
||||
|
||||
it("is the single source both catalogs derive their operation set from", () => {
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(41);
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(43);
|
||||
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
|
||||
// Neither catalog may contain an operation absent from the canonical source.
|
||||
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
|
||||
|
|
@ -83,7 +85,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
});
|
||||
|
||||
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(41);
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(43);
|
||||
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
|
||||
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
|
||||
expect(Array.isArray(operation.requiredClaims)).toBe(true);
|
||||
|
|
@ -109,7 +111,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
it("classifies real binding status so generic_api_request is never product coverage", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.byRealBindingStatus).toEqual({
|
||||
live_codex: 27,
|
||||
live_codex: 29,
|
||||
scenario_mock: 13,
|
||||
test_only: 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ describe("semantic action catalog", () => {
|
|||
(action) => action.operationId,
|
||||
);
|
||||
|
||||
expect(operationIds).toHaveLength(27);
|
||||
expect(operationIds).toHaveLength(29);
|
||||
expect(new Set(operationIds).size).toBe(operationIds.length);
|
||||
expect(operationIds).not.toContain("generic_api_request");
|
||||
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
PaperclipSemanticActionId,
|
||||
PaperclipSemanticActionMode,
|
||||
} from "./semantic-action-types.js";
|
||||
import { searchApiAction } from "../protocol-actions/search-api.js";
|
||||
import { callApiAction } from "../protocol-actions/call-api.js";
|
||||
|
||||
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
|
||||
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
|
||||
|
|
@ -105,6 +107,17 @@ function descriptor(input: DescriptorInput): PaperclipSemanticActionDescriptor {
|
|||
}
|
||||
|
||||
const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
||||
...[searchApiAction, callApiAction].map(action => descriptor({
|
||||
operationId: action.id,
|
||||
title: action.live.descriptor.title,
|
||||
description: action.live.descriptor.description,
|
||||
placement: "optional",
|
||||
effect: action.id === "search_api" ? "read" : "write",
|
||||
requiredClaims: action.live.descriptor.requiredClaims,
|
||||
allowedModes: action.live.descriptor.allowedModes,
|
||||
inputSchema: action.live.descriptor.inputSchema,
|
||||
outputSchema: action.live.descriptor.outputSchema,
|
||||
})),
|
||||
descriptor({
|
||||
operationId: "get_task_context",
|
||||
title: "Get active task context",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export type PaperclipSemanticActionId =
|
||||
| "search_api"
|
||||
| "call_api"
|
||||
| "get_task_context"
|
||||
| "get_task_history"
|
||||
| "list_documents"
|
||||
|
|
|
|||
|
|
@ -114,6 +114,47 @@ describe("Codex security configuration", () => {
|
|||
expect(localAllowed.sort()).toEqual(["GITHUB_TOKEN", "PATH"]);
|
||||
});
|
||||
|
||||
it("isolates managed launcher profiles and never serializes broker capabilities or host API credentials", () => {
|
||||
const serialized = createIsolatedCodexAppServerArgs({
|
||||
HOME: "/isolated/provider", CODEX_HOME: "/isolated/provider",
|
||||
PATH: "/runtime/run-B:/safe/bin",
|
||||
PAPERCLIP_GITHUB_LAUNCHER_DIR: "/runtime/run-B",
|
||||
PAPERCLIP_GITHUB_BROKER_TOKEN: "private-run-capability",
|
||||
PAPERCLIP_GITHUB_BRIDGE_TOKEN: "private-bridge-capability",
|
||||
PAPERCLIP_API_KEY: "forbidden-agent-token",
|
||||
GH_CONFIG_DIR: "/runtime/run-B/gh-config",
|
||||
}).join("\n");
|
||||
expect(serialized).toContain('"/isolated/provider"="none"');
|
||||
expect(serialized).toContain('"/runtime/run-B"="read"');
|
||||
expect(serialized).toContain('"/runtime/run-B/gh-config"="write"');
|
||||
expect(serialized).toContain('HOME="/runtime/run-B"');
|
||||
expect(serialized).toContain('ZDOTDIR="/runtime/run-B"');
|
||||
expect(serialized).toContain('BASH_ENV="/runtime/run-B/.bashrc"');
|
||||
expect(serialized).toContain('"PAPERCLIP_GITHUB_BRIDGE_TOKEN"');
|
||||
expect(serialized).not.toContain("PAPERCLIP_API_KEY");
|
||||
expect(serialized).not.toContain("private-run-capability");
|
||||
expect(serialized).not.toContain("private-bridge-capability");
|
||||
expect(serialized).not.toContain("forbidden-agent-token");
|
||||
});
|
||||
|
||||
it("keeps the sandbox home while loading managed GitHub shell profiles", () => {
|
||||
const serialized = createIsolatedCodexAppServerArgs({
|
||||
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
|
||||
HOME: "/home/daytona",
|
||||
PAPERCLIP_TASK_DIR: "/home/daytona/task",
|
||||
PAPERCLIP_AGENT_DIR: "/home/daytona/agent",
|
||||
PAPERCLIP_USER_DIR: "/home/daytona/user",
|
||||
PAPERCLIP_PROJECT_DIR: "/home/daytona/project",
|
||||
PAPERCLIP_REPOS_DIR: "/home/daytona/repos",
|
||||
PAPERCLIP_PRIMARY_REPO: "/home/daytona/repos/project",
|
||||
PAPERCLIP_GITHUB_LAUNCHER_DIR: "/runtime/run-B",
|
||||
}).join("\n");
|
||||
expect(serialized).toContain('HOME="/home/daytona"');
|
||||
expect(serialized).not.toContain('HOME="/runtime/run-B"');
|
||||
expect(serialized).toContain('ZDOTDIR="/runtime/run-B"');
|
||||
expect(serialized).toContain('BASH_ENV="/runtime/run-B/.bashrc"');
|
||||
});
|
||||
|
||||
it("uses a read-only permission profile for plan mode", () => {
|
||||
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
|
||||
cwd: "/workspace",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export function codexCommandEnvironment(
|
|||
for (const [key, value] of Object.entries(externalWorkFolderEnvironment(source))) {
|
||||
if (value !== undefined) environment[key] = value;
|
||||
}
|
||||
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) {
|
||||
environment.HOME ??= source.PAPERCLIP_GITHUB_LAUNCHER_DIR;
|
||||
environment.ZDOTDIR = source.PAPERCLIP_GITHUB_LAUNCHER_DIR;
|
||||
environment.BASH_ENV = `${source.PAPERCLIP_GITHUB_LAUNCHER_DIR}/.bashrc`;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +89,7 @@ export function createIsolatedCodexAppServerArgs(
|
|||
const hasGitHubCredential = hasGitHubCredentialEnvironment(source);
|
||||
const externalRunnerSandbox = usesExternalRunnerSandbox(source);
|
||||
const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source);
|
||||
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
|
||||
const deniedHostRoots = [
|
||||
...new Set(
|
||||
[source.HOME, source.CODEX_HOME]
|
||||
|
|
@ -100,6 +106,8 @@ export function createIsolatedCodexAppServerArgs(
|
|||
`":tmpdir"="none"`,
|
||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
|
||||
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
|
||||
`":workspace_roots"={"."="write"}`,
|
||||
].join(",");
|
||||
const planningFilesystemRules = [
|
||||
|
|
@ -108,6 +116,8 @@ export function createIsolatedCodexAppServerArgs(
|
|||
`":tmpdir"="none"`,
|
||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
|
||||
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
|
||||
`":workspace_roots"={"."="read"}`,
|
||||
].join(",");
|
||||
const commandEnvironment = codexCommandEnvironment(source);
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ describe("runtime context materialization", () => {
|
|||
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||
expect(config).toContain("shell_snapshot = false");
|
||||
expect(config).toContain("paperclip-assigned");
|
||||
expect(config).toContain('default_tools_approval_mode = "approve"');
|
||||
expect(config).toContain("Bearer ");
|
||||
expect(config).not.toContain("unassigned");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -331,6 +331,10 @@ export async function prepareIsolatedCodexHome(input: {
|
|||
? [
|
||||
`[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`,
|
||||
`url = ${JSON.stringify(input.nativeMcp.url)}`,
|
||||
// This endpoint is Paperclip's authenticated policy gateway, not an
|
||||
// upstream server. It enforces connection grants and human approvals
|
||||
// for every operation; the provider must deliver calls to that gate.
|
||||
'default_tools_approval_mode = "approve"',
|
||||
`http_headers = { Authorization = ${JSON.stringify(`Bearer ${input.nativeMcp.token}`)} }`,
|
||||
"",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
|
|||
candidateFailures: 36,
|
||||
});
|
||||
expect(report.coverage).toMatchObject({
|
||||
canonicalOperations: 41,
|
||||
canonicalOperations: 43,
|
||||
capabilityCases: 106,
|
||||
workflows: 12,
|
||||
stressFindings: 44,
|
||||
stressExclusions: 1,
|
||||
});
|
||||
expect(report.coverage.operations).toHaveLength(41);
|
||||
expect(report.coverage.operations).toHaveLength(43);
|
||||
expect(report.coverage.composedWorkflows).toHaveLength(12);
|
||||
expect(
|
||||
report.coverage.operations.find(
|
||||
|
|
@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
|
|||
)?.workflowIds.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(renderRunnerWorkflowMarkdown(report)).toContain(
|
||||
"41 operations · 106 capability cases · 12 workflows",
|
||||
"43 operations · 106 capability cases · 12 workflows",
|
||||
);
|
||||
expect(renderRunnerWorkflowJUnit(report)).toContain(
|
||||
'tests="36" failures="36" skipped="0"',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AttemptJournal } from "./attempt-journal.js";
|
||||
|
||||
describe("attempt journal", () => {
|
||||
it("retains acknowledged usage before completion and rejects replacement", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "eval-journal-"));
|
||||
const path = join(root, "journal.jsonl");
|
||||
const journal = new AttemptJournal(path);
|
||||
try {
|
||||
journal.append({ kind: "dispatch", id: "attempt" });
|
||||
journal.append({ kind: "usage", requests: 1, costUsd: 0.02 });
|
||||
expect(readFileSync(path, "utf8").trim().split("\n").map(line => JSON.parse(line))).toEqual([
|
||||
{ kind: "dispatch", id: "attempt" }, { kind: "usage", requests: 1, costUsd: 0.02 },
|
||||
]);
|
||||
expect(() => new AttemptJournal(path)).toThrow();
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
journal.close();
|
||||
expect(() => journal.append({})).toThrow("closed");
|
||||
} finally { journal.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("fails before writing an over-limit record without discarding earlier evidence", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "eval-journal-"));
|
||||
const path = join(root, "journal.jsonl");
|
||||
const journal = new AttemptJournal(path, 16);
|
||||
try {
|
||||
journal.append({ a: 1 });
|
||||
expect(() => journal.append({ text: "too large" })).toThrow("limit");
|
||||
expect(readFileSync(path, "utf8")).toBe('{"a":1}\n');
|
||||
} finally { journal.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { closeSync, constants, fsyncSync, openSync, writeSync } from "node:fs";
|
||||
|
||||
/** Controller-owned evidence outside disposable server storage. No credentials. */
|
||||
export class AttemptJournal {
|
||||
#fd: number | null;
|
||||
#bytes = 0;
|
||||
constructor(path: string, readonly maxBytes = 64 * 1024 * 1024) {
|
||||
this.#fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
||||
}
|
||||
append(value: unknown): void {
|
||||
if (this.#fd === null) throw new Error("Attempt journal is closed");
|
||||
const bytes = Buffer.from(JSON.stringify(value) + "\n");
|
||||
if (this.#bytes + bytes.length > this.maxBytes) throw new Error("Attempt journal limit reached; stop provider dispatch");
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) offset += writeSync(this.#fd, bytes, offset, bytes.length - offset);
|
||||
fsyncSync(this.#fd);
|
||||
this.#bytes += bytes.length;
|
||||
}
|
||||
close(): void {
|
||||
if (this.#fd !== null) closeSync(this.#fd);
|
||||
this.#fd = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,13 +31,15 @@ describe("model pricing", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["gpt-5.6-luna", 0.2, 0.02, 1.2],
|
||||
["claude-sonnet-5", 2, 0.2, 10],
|
||||
["openrouter/anthropic/claude-sonnet-5", 2, 0.2, 10],
|
||||
["openrouter/qwen/qwen3.8-max-0902", 2, 0.25, 6],
|
||||
["openrouter/google/gemini-3.8-flash", 0.75, 0.075, 3.75],
|
||||
["openrouter/z-ai/glm-5.3", 1.4, 0.14, 4.4],
|
||||
["openrouter/deepseek/deepseek-v4-flash-0731", 0.065, 0.016, 0.18],
|
||||
["openrouter/deepseek/deepseek-v4-flash-0731", 0.14, 0.028, 0.28],
|
||||
["openrouter/openai/gpt-6-astra", 10, 1, 50],
|
||||
])("pins the OpenRouter breadth price for %s", (model, input, cachedInput, output) => {
|
||||
])("pins the provider list price for %s", (model, input, cachedInput, output) => {
|
||||
expect(
|
||||
estimateModelCostNanodollars(model, {
|
||||
inputTokens: 1_000,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const MODEL_PRICING_VERSION = "provider-list-prices-2026-09-05" as const;
|
||||
export const MODEL_PRICING_VERSION = "provider-list-prices-2026-09-07-openrouter" as const;
|
||||
|
||||
interface TokenRatesUsdPerMillion {
|
||||
input: number;
|
||||
|
|
@ -15,15 +15,16 @@ const RATES: Readonly<Record<string, TokenRatesUsdPerMillion>> = Object.freeze({
|
|||
"gpt-5.6-terra": { input: 2, cachedInput: 0.2, output: 12 },
|
||||
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
|
||||
// Qualified Anthropic direct profile. Actual invoice discounts are intentionally excluded.
|
||||
"claude-sonnet-5": { input: 3, cachedInput: 0.3, output: 15 },
|
||||
"claude-sonnet-5": { input: 2, cachedInput: 0.2, output: 10 },
|
||||
// Amazon Bedrock global cross-region list price for Claude Sonnet 4.6.
|
||||
"global.anthropic.claude-sonnet-4-6": { input: 3, cachedInput: 0.3, output: 15 },
|
||||
// OpenRouter list-price snapshot used by the qualified OpenCode breadth lane.
|
||||
// OpenRouter catalog snapshot: https://openrouter.ai/api/v1/models (2026-09-07).
|
||||
// Routing can change the invoice rate; callers retain provider-reported cost too.
|
||||
"openrouter/anthropic/claude-sonnet-5": { input: 2, cachedInput: 0.2, output: 10 },
|
||||
"openrouter/qwen/qwen3.8-max-0902": { input: 2, cachedInput: 0.25, output: 6 },
|
||||
"openrouter/google/gemini-3.8-flash": { input: 0.75, cachedInput: 0.075, output: 3.75 },
|
||||
"openrouter/z-ai/glm-5.3": { input: 1.4, cachedInput: 0.14, output: 4.4 },
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.065, cachedInput: 0.016, output: 0.18 },
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.14, cachedInput: 0.028, output: 0.28 },
|
||||
"openrouter/openai/gpt-6-astra": { input: 10, cachedInput: 1, output: 50 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [
|
||||
"ZDOTDIR",
|
||||
"BASH_ENV",
|
||||
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
|
||||
"PAPERCLIP_GITHUB_BROKER_URL",
|
||||
"PAPERCLIP_GITHUB_BROKER_TOKEN",
|
||||
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
|
||||
"GH_CONFIG_DIR",
|
||||
"GH_ENTERPRISE_TOKEN",
|
||||
"GITHUB_ENTERPRISE_TOKEN",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_CONFIG_NOSYSTEM",
|
||||
"GIT_ASKPASS",
|
||||
"SSH_ASKPASS",
|
||||
"SSH_AUTH_SOCK",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"PAPERCLIP_GIT_TOKEN",
|
||||
|
|
@ -61,6 +77,7 @@ export function hasGitHubCredentialEnvironment(
|
|||
source: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
return [
|
||||
source.PAPERCLIP_GITHUB_BROKER_TOKEN,
|
||||
source.GH_TOKEN,
|
||||
source.GITHUB_TOKEN,
|
||||
source.PAPERCLIP_GIT_TOKEN,
|
||||
|
|
|
|||
|
|
@ -2736,9 +2736,12 @@ it("cold-restores a suspended provider session under its durable run binding", a
|
|||
stateDirectory,
|
||||
"--include-skill-instructions",
|
||||
"--durable-turn-ids",
|
||||
"-c",
|
||||
'shell_environment_policy.set={PATH="/run/A"}',
|
||||
),
|
||||
stateDirectory,
|
||||
environment: {
|
||||
PAPERCLIP_GITHUB_BROKER_TOKEN: "test-run-A-capability",
|
||||
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
||||
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024),
|
||||
},
|
||||
|
|
@ -2811,6 +2814,8 @@ it("cold-restores a suspended provider session under its durable run binding", a
|
|||
};
|
||||
const rotated = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
environment: { ...options.environment, PAPERCLIP_GITHUB_BROKER_TOKEN: "test-run-B-capability" },
|
||||
codexArgs: options.codexArgs.map((arg) => arg.replace('/run/A', '/run/B')),
|
||||
resumeDynamicTools: dynamicTools,
|
||||
resumeCompletionContract: {
|
||||
revision: "contract-second",
|
||||
|
|
@ -2824,6 +2829,9 @@ it("cold-restores a suspended provider session under its durable run binding", a
|
|||
}));
|
||||
try {
|
||||
const read = await rotated.transport.request("thread/read", {});
|
||||
const persistedProvider = JSON.parse(await readFile(join(stateDirectory, "runner", "codex-provider-state.json"), "utf8"));
|
||||
expect(persistedProvider.config.args.join("\n")).toContain('/run/B');
|
||||
expect(JSON.stringify(persistedProvider)).not.toContain("test-run-B-capability");
|
||||
expect(read.thread).toMatchObject({
|
||||
id: firstProviderThread.id,
|
||||
sessionId: firstProviderThread.sessionId,
|
||||
|
|
|
|||
|
|
@ -3562,6 +3562,22 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
this.#authorizedTools,
|
||||
this.options.resumeCompletionContract,
|
||||
);
|
||||
if (provider === "codex" && this.options.environment?.PAPERCLIP_GITHUB_BROKER_TOKEN) {
|
||||
// These controller-owned, token-free paths belong to the new run.
|
||||
// Keep the durable provider profile and thread identity unchanged.
|
||||
runAttachTemplate.runtimeLaunchArgs = this.options.codexArgs ?? createRunnerdCodexAppServerArgs({
|
||||
environment: this.options.environment,
|
||||
codexHome,
|
||||
readOnlyRoots: [
|
||||
...trustedRuntimeReadOnlyRoots(this.options.environment),
|
||||
...(runtimeContext ? [
|
||||
resolve(codexHome, "skills"),
|
||||
runtimeContext.instructions.bundle.rootPath,
|
||||
...runtimeContext.skills.map((skill) => skill.bundle.rootPath),
|
||||
] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
this.#runAttachTemplate = structuredClone(runAttachTemplate);
|
||||
core.queueCommand("run.attach", runAttachTemplate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
/** Canonical production API fallback contract. */
|
||||
export const callApiAction = {
|
||||
"id": "call_api",
|
||||
"canonical": {
|
||||
"operationId": "call_api",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "discovery",
|
||||
"requiredClaims": [
|
||||
"api:call"
|
||||
],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "company_write",
|
||||
"idempotency": "none",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "PaperclipRunnerToolAuthority",
|
||||
"prpEvidence": "Authenticated PRP tool input/result and existing HTTP route authorization/activity records.",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": []
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Call the Paperclip API",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
"operationId": "call_api",
|
||||
"input": {
|
||||
"operationId": "GET /api/companies/{companyId}/projects"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"ok": true,
|
||||
"operationId": "call_api",
|
||||
"result": {}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"order": 41,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "call_api",
|
||||
"version": 1,
|
||||
"title": "Call the Paperclip API",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"api:call"
|
||||
],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": {
|
||||
"description": "Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"pathParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"query": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"body": {
|
||||
"description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string.",
|
||||
"anyOf": [
|
||||
{ "type": "object", "additionalProperties": true },
|
||||
{ "type": "array", "items": {} },
|
||||
{ "type": "string" },
|
||||
{ "type": "number" },
|
||||
{ "type": "boolean" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
},
|
||||
"contentType": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"artifactId": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File relative to the active issue workspace. Remote files must first be uploaded as an artifact."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": { "artifactId": {} },
|
||||
"required": [
|
||||
"artifactId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"properties": { "path": {} },
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operationId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario": null
|
||||
} as const;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { searchApiAction } from "./search-api.js";
|
||||
import { callApiAction } from "./call-api.js";
|
||||
import { administerCompanyAction } from "./administer-company.js";
|
||||
import { answerStatusQuestionAction } from "./answer-status-question.js";
|
||||
import { blockTaskAction } from "./block-task.js";
|
||||
|
|
@ -42,6 +44,8 @@ import { writeDocumentAction } from "./write-document.js";
|
|||
import { deepFreezeProtocolAction } from "./freeze.js";
|
||||
|
||||
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
|
||||
searchApiAction,
|
||||
callApiAction,
|
||||
administerCompanyAction,
|
||||
answerStatusQuestionAction,
|
||||
blockTaskAction,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
/** Canonical production API fallback contract. */
|
||||
export const searchApiAction = {
|
||||
"id": "search_api",
|
||||
"canonical": {
|
||||
"operationId": "search_api",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "discovery",
|
||||
"requiredClaims": [
|
||||
"api:discover"
|
||||
],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "read",
|
||||
"idempotency": "none",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "PaperclipRunnerToolAuthority",
|
||||
"prpEvidence": "Authenticated PRP tool input/result and existing HTTP route authorization/activity records.",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": []
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Search the Paperclip API",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
"operationId": "search_api",
|
||||
"input": {
|
||||
"query": "create project"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"ok": true,
|
||||
"operationId": "search_api",
|
||||
"result": {}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"order": 40,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "search_api",
|
||||
"version": 1,
|
||||
"title": "Search the Paperclip API",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"api:discover"
|
||||
],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario": null
|
||||
} as const;
|
||||
|
|
@ -20,6 +20,8 @@ export interface CapabilityDiscoveryResult {
|
|||
const MAX_DISCOVERY_RESULTS = 10;
|
||||
|
||||
const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Object.freeze({
|
||||
search_api: "api_fallback",
|
||||
call_api: "api_fallback",
|
||||
get_task_context: "active_task", get_task_history: "active_task",
|
||||
list_documents: "documents", read_document: "documents", list_document_revisions: "documents",
|
||||
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
|
||||
|
|
@ -34,6 +36,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
|
|||
});
|
||||
|
||||
export const CAPABILITY_DISCOVERY_NAMESPACES = Object.freeze([
|
||||
{ name: "api_fallback", description: "API escape hatch for work unsupported by available dedicated tools." },
|
||||
{ name: "discovery", description: "Find company tasks and agents." },
|
||||
{ name: "delegation", description: "Create bounded child work and task dependencies." },
|
||||
{ name: "governance", description: "Read, request, discuss, and decide approvals." },
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
|
||||
const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
|
||||
Object.freeze({
|
||||
search_api: "api_fallback",
|
||||
call_api: "api_fallback",
|
||||
get_task_context: "active_task",
|
||||
get_task_history: "active_task",
|
||||
list_documents: "documents",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe("Capability semantic catalog and authorization", () => {
|
|||
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
|
||||
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toHaveLength(28);
|
||||
expect(names).toHaveLength(30);
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("finish_task");
|
||||
expect(names).not.toContain("checkout_task");
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js";
|
|||
export type CapabilitySemanticToolExposure = "always" | "optional";
|
||||
|
||||
export type CapabilitySemanticOperationId =
|
||||
| "search_api"
|
||||
| "call_api"
|
||||
| "get_task_context"
|
||||
| "get_task_history"
|
||||
| "list_documents"
|
||||
|
|
|
|||
|
|
@ -166,6 +166,12 @@ export interface HeartbeatRun {
|
|||
triggerDetail: WakeupTriggerDetail | null;
|
||||
status: HeartbeatRunStatus;
|
||||
responsibleUserId: string | null;
|
||||
activeIdentityContextId?: string | null;
|
||||
identityHistory?: Array<{
|
||||
id: string; revision: number; responsibleUserId: string | null; messageId: string | null;
|
||||
parentContextId: string | null; cause: string; status: string; acceptedAt: Date | string | null;
|
||||
github: { status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string } | null;
|
||||
}>;
|
||||
startedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
error: string | null;
|
||||
|
|
|
|||
|
|
@ -811,6 +811,8 @@ export interface Issue {
|
|||
originKind?: IssueOriginKind;
|
||||
originId?: string | null;
|
||||
originRunId?: string | null;
|
||||
originIdentityContextId?: string | null;
|
||||
continuationIdentityContextId?: string | null;
|
||||
originFingerprint?: string | null;
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
|
|
@ -1058,6 +1060,7 @@ export interface IssueCommentMetadataSection {
|
|||
export interface IssueCommentMetadata {
|
||||
version: 1;
|
||||
sourceRunId?: string | null;
|
||||
sourceIdentityContextId?: string | null;
|
||||
authorizationReason?: string | null;
|
||||
sections: IssueCommentMetadataSection[];
|
||||
}
|
||||
|
|
@ -1471,6 +1474,7 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
|
|||
idempotencyKey?: string | null;
|
||||
sourceCommentId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
sourceIdentityContextId?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
addresseeUserId?: string | null;
|
||||
title?: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const source = readFileSync(resolve(root, "skills/paperclip/references/api-reference.md"), "utf8");
|
||||
const key = (method, path) => `${method} ${path.replace(/:[A-Za-z][A-Za-z0-9_]*|\{[^}]+\}/g, "{}")}`;
|
||||
const entries = {};
|
||||
let section = "Paperclip API Reference";
|
||||
for (const line of source.split("\n")) {
|
||||
if (/^#{2,5} /.test(line)) section = line.replace(/^#+ /, "");
|
||||
const table = /^\|\s*(GET|POST|PATCH|PUT|DELETE)\s*\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|/.exec(line);
|
||||
if (table) entries[key(table[1], table[2])] = { section, description: table[3] };
|
||||
}
|
||||
for (const match of source.matchAll(/^(GET|POST|PATCH|PUT|DELETE) (\/api\/[^\s]+)\n(\{[\s\S]*?\n\})/gm)) {
|
||||
try {
|
||||
const body = JSON.parse(match[3]);
|
||||
const id = key(match[1], match[2]);
|
||||
entries[id] ??= { section: "Worked example" };
|
||||
(entries[id].examples ??= []).push({ body });
|
||||
entries[id].examples = entries[id].examples.slice(0, 2);
|
||||
} catch { /* Narrative/pseudocode blocks are not executable examples. */ }
|
||||
}
|
||||
const destination = resolve(root, "server/src/services/native-runtime/runner-api-reference.ts");
|
||||
const output = `// Generated by scripts/generate-runner-api-reference.mjs from the legacy skill reference.\nexport const runnerApiReference: Record<string, { section: string; description?: string; examples?: { body: unknown }[] }> = ${JSON.stringify(entries, null, 2)};\n`;
|
||||
if (process.argv.includes("--check")) {
|
||||
if (readFileSync(destination, "utf8") !== output) throw new Error("Runner API skill enrichment is stale");
|
||||
} else writeFileSync(destination, output);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const metadata = {};
|
||||
for (const name of ["pipelines", "cases", "smoke-lab"]) {
|
||||
const source = await readFile(new URL(`server/src/routes/${name}.ts`, root), "utf8");
|
||||
const routes = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*"([^"]+)"/g)];
|
||||
for (const [index, route] of routes.entries()) {
|
||||
const handler = source.slice(route.index, routes[index + 1]?.index ?? source.length);
|
||||
const statuses = new Set([...handler.matchAll(/res\.status\((2\d\d)\)/g)].map(match => Number(match[1])));
|
||||
if (/res\.json\(/.test(handler) || statuses.size === 0) statuses.add(200);
|
||||
const path = "/api" + route[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}");
|
||||
metadata[`${route[1].toUpperCase()} ${path}`] = { successStatuses: [...statuses].sort(), boardOnly: /assertBoard\(req\)/.test(handler), source: `server/src/routes/${name}.ts` };
|
||||
}
|
||||
}
|
||||
const output = "// Generated by scripts/generate-runner-experimental-api-metadata.mjs.\nexport const experimentalApiMetadata: Record<string, { successStatuses: number[]; boardOnly: boolean; source: string }> = " + JSON.stringify(metadata, null, 2) + ";\n";
|
||||
const target = new URL("server/src/routes/experimental-api-metadata.ts", root);
|
||||
if (process.argv.includes("--check")) {
|
||||
if (await readFile(target, "utf8") !== output) throw new Error(`Stale experimental route metadata: ${fileURLToPath(target)}`);
|
||||
} else await writeFile(target, output);
|
||||
|
|
@ -275,8 +275,7 @@ function runVitest(args, label) {
|
|||
console.log(`\n[test:run] ${label}`);
|
||||
invocationIndex += 1;
|
||||
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
// /tmp is a symlink on macOS. Workspace confinement intentionally rejects
|
||||
// aliases, so fixtures must receive the canonical root just as Linux does.
|
||||
// Canonical roots keep security fixtures valid on macOS, where /tmp is a symlink.
|
||||
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)));
|
||||
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
|
||||
const env = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
/** JSONL worker for the companion paperclip-evals API suite. Never selects cases or retries. */
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, realpathSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { homedir } from "node:os";
|
||||
import { runnerApiCatalog } from "../server/src/services/native-runtime/runner-api-catalog.js";
|
||||
import { startRunnerApiTestServer } from "../server/src/__tests__/helpers/runner-api-server.js";
|
||||
import { registerRunnerPrpAuthority } from "../server/src/realtime/runner-prp-ws.js";
|
||||
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../packages/paperclip-runner/src/live/runnerd-codex-transport.js";
|
||||
import { createSkilllessCodexThreadConfig } from "../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.js";
|
||||
import { AttemptJournal } from "../packages/paperclip-runner/src/evals/attempt-journal.js";
|
||||
import { estimateModelCostNanodollars } from "../packages/paperclip-runner/src/evals/model-pricing.js";
|
||||
|
||||
if (process.argv.includes("--catalog")) {
|
||||
process.stdout.write(JSON.stringify(runnerApiCatalog()) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (!process.argv.includes("--jsonl")) throw new Error("Use --catalog or --jsonl; there is no default campaign");
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID() + randomUUID();
|
||||
const output = (value: unknown) => process.stdout.write("RUNNER_API_EVAL " + JSON.stringify(value) + "\n");
|
||||
const OPENROUTER_MODELS = new Set(["openrouter/anthropic/claude-sonnet-5", "openrouter/deepseek/deepseek-v4-flash-0731", "openrouter/google/gemini-3.8-flash"]);
|
||||
// The controller selects and injects one provider credential. The worker never
|
||||
// reads ambient home credential files or desktop keychains.
|
||||
function openRouterEnvironment() {
|
||||
const key = process.env.OPENROUTER_API_KEY;
|
||||
if (!key || /\s/.test(key)) throw new Error("Controller must inject OPENROUTER_API_KEY");
|
||||
return { PATH: process.env.PATH, OPENROUTER_API_KEY: key };
|
||||
}
|
||||
const server = await startRunnerApiTestServer();
|
||||
const fileDigests = new Map<string, Promise<string | null>>();
|
||||
function fileDigest(path: string): Promise<string | null> {
|
||||
let digest = fileDigests.get(path);
|
||||
if (!digest) {
|
||||
digest = (async () => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
})().catch(() => null);
|
||||
fileDigests.set(path, digest);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
const runtimeBuild = {
|
||||
runnerBinarySha256: await fileDigest(defaultCapabilityRunnerdBinary()),
|
||||
workerSourceSha256: await fileDigest(resolve("scripts/runner-api-eval-worker.ts")),
|
||||
lockfileSha256: await fileDigest(resolve("pnpm-lock.yaml")),
|
||||
};
|
||||
output({ ready: true });
|
||||
const record = (value: unknown): Record<string, any> => value && typeof value === "object" ? value as Record<string, any> : {};
|
||||
|
||||
try {
|
||||
for await (const line of createInterface({ input: process.stdin })) {
|
||||
const request = JSON.parse(line);
|
||||
if (request.shutdown) break;
|
||||
const directory = resolve(request.outputDirectory);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const journal = new AttemptJournal(join(directory, "journal.jsonl"));
|
||||
const startedAt = new Date().toISOString();
|
||||
journal.append({ kind: "attempt_started", attemptId: request.attemptId, model: request.model, startedAt });
|
||||
const started = performance.now();
|
||||
const isOpenRouter = OPENROUTER_MODELS.has(request.model);
|
||||
const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
|
||||
let providerVersion: string | null = null;
|
||||
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true });
|
||||
const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
|
||||
const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
|
||||
const calls: Record<string, any>[] = [], notifications: unknown[] = [];
|
||||
const observedToolCallIds = new Set<string>();
|
||||
const definitions = await fixture.authority.definitions();
|
||||
const prompt = expand(request.prompt ?? "");
|
||||
let error: string | null = null, evidence: unknown = null, thread: Record<string, any> = {}, usage: Record<string, any> | null = null;
|
||||
let usageUpdates = 0, lastUsageFingerprint = "", terminalSeen = false, providerTurnStarted = false;
|
||||
let diagnosticTail: string | null = null;
|
||||
let bundle: ReturnType<typeof createRunnerdCodexTransport> | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const invoke = async (tool: string, args: unknown, callId: string) => {
|
||||
observedToolCallIds.add(callId);
|
||||
if (observedToolCallIds.size > 12) throw new Error("Attempt tool-call ceiling reached");
|
||||
const call: Record<string, any> = { tool, arguments: args, callId, startedMs: performance.now() - started };
|
||||
calls.push(call);
|
||||
journal.append({ kind: "tool_dispatch", ...call });
|
||||
try { call.result = await fixture.authority.execute({ tool, arguments: args, callId }); }
|
||||
catch (caught) { call.error = caught instanceof Error ? caught.message : String(caught); call.status = record(caught).status; }
|
||||
call.durationMs = performance.now() - started - call.startedMs;
|
||||
journal.append({ kind: "tool_result", ...call });
|
||||
return call;
|
||||
};
|
||||
try {
|
||||
if (request.calls) {
|
||||
for (const [i, call] of request.calls.entries()) await invoke(call.tool, expand(call.arguments), call.callId ?? `direct-${i}`);
|
||||
} else {
|
||||
if (request.model === "claude-sonnet-5" && (process.platform !== "linux" || process.arch !== "x64")) throw new Error("Qualified ACPX Claude requires Linux x64; no provider turn was dispatched");
|
||||
if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
|
||||
if (isOpenRouter) {
|
||||
providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim();
|
||||
if (providerVersion !== "1.18.29") throw new Error("OpenCode profile requires version 1.18.29");
|
||||
}
|
||||
const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => {
|
||||
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
if (!token) throw new Error("Controller must inject CLAUDE_CODE_OAUTH_TOKEN");
|
||||
return { PATH: process.env.PATH, CLAUDE_CODE_OAUTH_TOKEN: token };
|
||||
})() : undefined;
|
||||
bundle = createRunnerdCodexTransport({
|
||||
provider, acpxAgent: "claude", acpxPermissionMode: "approve-reads",
|
||||
environment: { ...providerEnvironment, PAPERCLIP_PROVIDER_TRACE_PATH: join(directory, "provider-trace.jsonl"), PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(32 * 1024 * 1024) },
|
||||
codexCommand: request.model === "gpt-5.6-luna" ? realpathSync(execFileSync("which", ["codex"], { encoding: "utf8" }).trim()) : undefined,
|
||||
sourceCodexHome: process.env.CODEX_HOME ?? join(homedir(), ".codex"),
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: join(server.root, `runner-${request.attemptId}`),
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: { runnerInstanceId: `runner-${fixture.runId}`, environmentLeaseId: `lease-${fixture.runId}`, runId: fixture.runId, normalizedSessionId: `session-${fixture.runId}`, turnId: `turn-${fixture.runId}`, itemId: `item-${fixture.runId}` },
|
||||
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
|
||||
});
|
||||
const active = bundle;
|
||||
timer = setTimeout(() => { error = "Attempt exceeded 120 seconds"; void active.transport.close(error); }, 120_000);
|
||||
bundle.transport.setServerRequestHandler(async (request) => {
|
||||
const params = record(request.params);
|
||||
const result = await invoke(String(params.tool), params.arguments, String(params.callId));
|
||||
return { success: !result.error, contentItems: [{ type: "inputText", text: JSON.stringify(result.error ? { ok: false, error: result.error, status: result.status } : { ok: true, result: result.result }) }] };
|
||||
});
|
||||
await bundle.transport.request("initialize", {});
|
||||
thread = await bundle.transport.request("thread/start", {
|
||||
cwd: fixture.workspace, model: request.model,
|
||||
completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
|
||||
config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
|
||||
permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
|
||||
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use."),
|
||||
dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
|
||||
});
|
||||
if (request.preflight) {
|
||||
terminalSeen = true;
|
||||
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, preflight: true };
|
||||
} else {
|
||||
journal.append({ kind: "provider_turn_dispatch", thread, at: new Date().toISOString() });
|
||||
providerTurnStarted = true;
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: prompt }] });
|
||||
for await (const notification of bundle.transport.notifications()) {
|
||||
const retained = { ...notification, observedAt: new Date().toISOString() };
|
||||
journal.append({ kind: "notification", ...retained });
|
||||
notifications.push(retained);
|
||||
const item = record(record(notification.params).item);
|
||||
if (["dynamicToolCall", "tool_call", "tool_result"].includes(item.type) && typeof item.id === "string") {
|
||||
observedToolCallIds.add(item.id);
|
||||
if (observedToolCallIds.size > 12) { error = "Attempt tool-call ceiling reached, including provider-rejected calls"; break; }
|
||||
}
|
||||
if (notification.method === "thread/tokenUsage/updated") {
|
||||
const params = record(notification.params);
|
||||
const tokenUsage = record(params.tokenUsage);
|
||||
const cumulative = record(tokenUsage.total ?? tokenUsage.totalTokenUsage ?? tokenUsage.total_token_usage ?? tokenUsage);
|
||||
if (request.model === "claude-sonnet-5" && params.runDeltaAvailable !== true) continue;
|
||||
const total = request.model === "claude-sonnet-5" ? record(tokenUsage.runDelta) : cumulative;
|
||||
const cacheWriteTokens = total.cacheWriteTokens ?? 0;
|
||||
const inputTokens = (total.inputTokens ?? total.input_tokens) + (request.model === "claude-sonnet-5" ? (total.cacheReadTokens ?? 0) : isOpenRouter ? (total.cacheReadTokens ?? total.cachedInputTokens ?? 0) : 0);
|
||||
const outputTokens = total.outputTokens ?? total.output_tokens;
|
||||
const cachedInputTokens = total.cacheReadTokens ?? total.cachedInputTokens ?? total.cache_read_input_tokens ?? 0;
|
||||
if ([inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens].every(value => Number.isSafeInteger(value) && value >= 0)) {
|
||||
const fingerprint = `${inputTokens}:${outputTokens}:${cachedInputTokens}`;
|
||||
if (fingerprint !== lastUsageFingerprint) { usageUpdates++; lastUsageFingerprint = fingerprint; }
|
||||
usage = { inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens, providerRequests: (total.requests ?? total.providerRequests ?? 0) > 0 ? total.requests ?? total.providerRequests : null, providerUsageUpdateCount: usageUpdates, providerRequestCountSource: (total.requests ?? total.providerRequests ?? 0) > 0 ? "provider" : "not reported; providerUsageUpdateCount is a proxy", providerReportedCostNanodollars: cumulative.providerCostUsd > 0 ? Math.round(cumulative.providerCostUsd * 1e9) : null, ...estimateModelCostNanodollars(request.model, { inputTokens, outputTokens, cachedInputTokens }) };
|
||||
// A conservative one-hour cache-write ceiling (2× standard input)
|
||||
// covers either cache duration when the runtime omits its TTL.
|
||||
if (request.model === "claude-sonnet-5" || request.model === "openrouter/anthropic/claude-sonnet-5") {
|
||||
usage.estimatedCostNanodollars += cacheWriteTokens * 4_000;
|
||||
usage.cacheWriteUsdPerMillionTokens = 4;
|
||||
usage.cacheWritePricing = "Conservative one-hour cache-write rate; TTL not exposed";
|
||||
}
|
||||
if (Math.max(usage.estimatedCostNanodollars, usage.providerReportedCostNanodollars ?? 0) >= 500_000_000) { error = "Attempt cost ceiling reached"; break; }
|
||||
}
|
||||
}
|
||||
if (notification.method === "turn/completed") {
|
||||
terminalSeen = true;
|
||||
const params = record(notification.params);
|
||||
const terminal = record(params.turn);
|
||||
if ((params.status ?? terminal.status) === "failed") error = String(record(params.error ?? terminal.error).message ?? "Provider turn failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
evidence = bundle.evidence();
|
||||
if (!request.preflight && (!usage || usage.inputTokens + usage.outputTokens === 0)) {
|
||||
usage = null;
|
||||
error ??= "Missing provider accounting; block further paid attempts";
|
||||
}
|
||||
}
|
||||
} catch (caught) { error ??= caught instanceof Error ? caught.message : String(caught); }
|
||||
finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (bundle) {
|
||||
evidence = bundle.evidence();
|
||||
diagnosticTail = await readFile(join(server.root, `runner-${request.attemptId}`, "diagnostics", "runnerd.stderr.log"), "utf8").then(text => text.slice(-16000).replace(/sk-(?:ant-|or-v1-)[A-Za-z0-9_-]+/g, "[redacted]").replace(/Bearer\s+[^\s"']+/gi, "Bearer [redacted]").replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted JWT]")).catch(() => null);
|
||||
}
|
||||
await bundle?.transport.close();
|
||||
}
|
||||
if (!providerTurnStarted && !request.calls) {
|
||||
usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, accountingProvenance: "No provider turn/start was dispatched" };
|
||||
}
|
||||
const artifact = {
|
||||
schema: "paperclip-runner/eval-session-artifact/v1", attemptId: request.attemptId,
|
||||
requestedModel: request.model ?? "provider-free", provider, driver: "real-server-api-tools",
|
||||
providerSessionId: record(thread.thread).id ?? null, effectiveModel: record(thread.thread).model ?? null,
|
||||
providerVersion: request.model === "gpt-5.6-luna" ? execFileSync("codex", ["--version"], { encoding: "utf8" }).trim() : providerVersion ?? record(evidence).providerVersion ?? null,
|
||||
runtimeVersions: { node: process.versions.node, acpx: record(evidence).providerVersion, agentServer: record(evidence).agentServerVersion, agentRuntime: record(evidence).agentRuntimeVersion },
|
||||
runtimeBuild,
|
||||
timing: { startedAt, finishedAt: new Date().toISOString(), durationMs: performance.now() - started },
|
||||
usage, accountingComplete: Boolean(request.calls) || !providerTurnStarted || terminalSeen, providerTurnStarted, observedProviderToolCalls: observedToolCallIds.size, diagnosticTail, error, calls, notifications, evidence, thread, prompt, arm: request.arm ?? "treatment",
|
||||
fixture: substitutions, state: await fixture.snapshot(),
|
||||
toolSchemaBytes: Buffer.byteLength(JSON.stringify(definitions)), definitions,
|
||||
};
|
||||
// The controller supplies a unique retained attempt directory; never overwrite evidence.
|
||||
journal.append({ kind: "attempt_finished", error, usage, terminalSeen, at: new Date().toISOString() });
|
||||
journal.close();
|
||||
await writeFile(join(directory, "artifact.json"), JSON.stringify(artifact, null, 2), { flag: "wx", mode: 0o600 });
|
||||
output({ attemptId: request.attemptId, artifactPath: join(directory, "artifact.json"), error, usage });
|
||||
}
|
||||
} finally { await server.close(); }
|
||||
|
|
@ -771,11 +771,8 @@ describe("agent live run routes", () => {
|
|||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
|
||||
// caller actually supplied so empty-body callers (e.g. e2e suites) match
|
||||
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
|
||||
// caller supplies reason / payload / forceFreshSession those are
|
||||
// forwarded; idempotencyKey is omitted unless explicitly set.
|
||||
// Optional wake fields retain their existing shape; execution identity
|
||||
// always comes from the authenticated caller.
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
|
|
@ -790,6 +787,8 @@ describe("agent live run routes", () => {
|
|||
contextSnapshot: {
|
||||
triggeredBy: "board",
|
||||
actorId: "local-board",
|
||||
responsibleUserId: "local-board",
|
||||
originIdentityContextId: null,
|
||||
forceFreshSession: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -813,6 +812,8 @@ describe("agent live run routes", () => {
|
|||
contextSnapshot: {
|
||||
triggeredBy: "board",
|
||||
actorId: "local-board",
|
||||
responsibleUserId: "local-board",
|
||||
originIdentityContextId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { runtimeConnectionIntentRoutes } from "../routes/connection-intents.js";
|
||||
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb, heartbeatRuns, issueComments, issues, runIdentityContexts, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../services/run-identity.js";
|
||||
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
|
||||
|
||||
const vault = vi.hoisted(() => ({
|
||||
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
|
||||
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
|
||||
}));
|
||||
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("operation-time GitHub credential resolution", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-github-broker-signing-secret"); database = await startEmbeddedPostgresTestDatabase("paperclip-github-operation-"); db = createDb(database.connectionString); }, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); }, 60_000);
|
||||
async function seed() {
|
||||
const companyId=randomUUID(), agentId=randomUUID(), runId=randomUUID(), issueId=randomUUID();
|
||||
await db.insert(companies).values({ id:companyId, name:companyId, issuePrefix:companyId.slice(0,8) });
|
||||
await db.insert(agents).values({ id:agentId, companyId, name:"Shared", role:"engineer", adapterType:"codex_local" });
|
||||
await db.insert(issues).values({ id:issueId, companyId, title:"Identity test" });
|
||||
await db.insert(heartbeatRuns).values({ id:runId, companyId, agentId, status:"running", contextSnapshot:{issueId} });
|
||||
await db.insert(companyMemberships).values(["A","B"].map(principalId => ({companyId, principalType:"user", principalId, status:"active", membershipRole:"member"})));
|
||||
await initializeRunIdentity(db,{companyId,runId,responsibleUserId:"A",cause:"instruction"});
|
||||
return {companyId,agentId,runId,issueId};
|
||||
}
|
||||
async function grant(input: Awaited<ReturnType<typeof seed>>, user: string, dedicated=false) {
|
||||
const applicationId=randomUUID(), connectionId=randomUUID(), secretId=randomUUID(), definitionId=randomUUID(), id=randomUUID();
|
||||
await db.insert(toolApplications).values({id:applicationId, companyId:input.companyId, name:applicationId,type:"mcp_http"});
|
||||
await db.insert(toolConnections).values({id:connectionId,companyId:input.companyId,applicationId,name:connectionId,uid:connectionId,transport:"mcp_remote",status:"active",enabled:true,credentialPolicy:dedicated?"per_agent":"per_user",config:{sourceTemplateKey:"github"}});
|
||||
await db.insert(toolConnectionInstalls).values({companyId:input.companyId,connectionId,targetType:"agent",targetId:input.agentId});
|
||||
if (!dedicated) await db.insert(userSecretDefinitions).values({id:definitionId,companyId:input.companyId,key:definitionId,name:"Test GitHub"});
|
||||
await db.insert(companySecrets).values({id:secretId,companyId:input.companyId,key:secretId,name:"Test token",scope:dedicated?"company":"user",ownerUserId:dedicated?null:user,userSecretDefinitionId:dedicated?null:definitionId});
|
||||
await db.insert(connectionGrants).values({id,companyId:input.companyId,connectionId,kind:dedicated?"agent":"user",subjectUserId:dedicated?null:user,subjectAgentId:dedicated?input.agentId:null,status:"active",credentialSecretRefs:[{secretId,configPath:"oauth.access_token",versionSelector:"latest"}],providerTenant:{github:{userId:user,login:user,installationCount:1,repositoryCount:1,repositorySelection:"selected",installationIds:["1"],installationOwnerLogins:[user]}}});
|
||||
return {id,connectionId};
|
||||
}
|
||||
async function switchTo(input: Awaited<ReturnType<typeof seed>>, user:string) {
|
||||
const id=randomUUID();
|
||||
await db.insert(issueComments).values({id,companyId:input.companyId,issueId:input.issueId,authorUserId:user,body:"Next instruction"});
|
||||
const context=await reserveSteeredIdentity(db,{...input,messageId:id});
|
||||
await acceptSteeredIdentity(db,context!);
|
||||
}
|
||||
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
|
||||
const input=await seed(); await grant(input,"A"); await grant(input,"B");
|
||||
for (const user of ["A","B","A"]) {
|
||||
await switchTo(input,user);
|
||||
const result=await resolveGitHubOperationCredentials(db,input);
|
||||
expect(result).toMatchObject({status:"available",login:user,source:"personal"});
|
||||
expect(result.env.GH_TOKEN).toBe(`test-token-${user}`);
|
||||
expect(result.env.GIT_AUTHOR_EMAIL).toBe(`${user}+${user}@users.noreply.github.com`);
|
||||
}
|
||||
const history=await db.select().from(runIdentityContexts).where(eq(runIdentityContexts.runId,input.runId));
|
||||
expect(JSON.stringify(history)).not.toContain("test-token-");
|
||||
});
|
||||
it("returns no credential for unconnected users, removed membership, or ambiguous personal accounts", async () => {
|
||||
const input=await seed(); await grant(input,"A");
|
||||
await switchTo(input,"B");
|
||||
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
|
||||
await switchTo(input,"A");
|
||||
await db.update(companyMemberships).set({status:"inactive"}).where(eq(companyMemberships.companyId,input.companyId));
|
||||
expect((await resolveGitHubOperationCredentials(db,input)).status).toBe("unavailable");
|
||||
await db.update(companyMemberships).set({status:"active"}).where(eq(companyMemberships.companyId,input.companyId));
|
||||
await grant(input,"A");
|
||||
expect((await resolveGitHubOperationCredentials(db,input)).reason).toMatch(/More than one/);
|
||||
await expect(resolveGitHubOperationCredentials(db,{...input,companyId:randomUUID()})).rejects.toThrow();
|
||||
});
|
||||
it("honors dedicated overrides and never substitutes personal credentials when revoked or disabled", async () => {
|
||||
const input=await seed(); await grant(input,"A"); const dedicated=await grant(input,"robot",true);
|
||||
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"available",source:"dedicated",login:"robot"});
|
||||
await db.update(connectionGrants).set({status:"revoked"}).where(eq(connectionGrants.id,dedicated.id));
|
||||
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
|
||||
await db.update(connectionGrants).set({status:"active"}).where(eq(connectionGrants.id,dedicated.id));
|
||||
await db.update(toolConnections).set({enabled:false}).where(eq(toolConnections.id,dedicated.connectionId));
|
||||
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
|
||||
});
|
||||
it("does not resolve the company default person's GitHub", async () => {
|
||||
const input=await seed(); await grant(input,"A");
|
||||
await db.update(runIdentityContexts).set({cause:"company_default"}).where(eq(runIdentityContexts.runId,input.runId));
|
||||
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
|
||||
});
|
||||
it("requires a run-scoped runtime capability and never accepts browser authentication or supplied identities", async () => {
|
||||
const input = await seed(); await grant(input, "A");
|
||||
const app = express(); app.use(express.json()); app.use(runtimeConnectionIntentRoutes(db)); app.use(errorHandler);
|
||||
const tokenInput = {...input, responsibleUserId: "A", scope: "github_credentials" as const};
|
||||
const token = createRuntimeToolsToken(tokenInput)!.token;
|
||||
const post = () => request(app).post("/runtime-tools/github/credentials");
|
||||
const a = await post().set("Authorization", `Bearer ${token}`).send({responsibleUserId: "B"});
|
||||
expect((await post().set("Authorization", `Bearer ${token}`).set("Sec-Fetch-Mode", "cors")).status).toBe(200);
|
||||
expect(a.status).toBe(200); expect(a.body.login).toBe("A"); expect(a.headers["cache-control"]).toBe("no-store");
|
||||
for (const [header, value] of [["Origin", "http://127.0.0.1"], ["Cookie", "session=test"], ["Sec-Fetch-Site", "same-origin"]]) {
|
||||
expect((await post().set("Authorization", `Bearer ${token}`).set(header!,value!)).status).toBe(403);
|
||||
}
|
||||
const wrongScope = createRuntimeToolsToken({...tokenInput, scope: "connection_intents"})!.token;
|
||||
expect((await post().set("Authorization", `Bearer ${wrongScope}`)).status).toBe(401);
|
||||
const wrongAgent = createRuntimeToolsToken({...tokenInput, agentId: randomUUID()})!.token;
|
||||
expect((await post().set("Authorization", `Bearer ${wrongAgent}`)).status).toBe(403);
|
||||
// The runner bridge replaces Authorization, but forwards the separate run capability.
|
||||
expect((await post().set("Authorization", "Bearer bridge-host-token").set("x-paperclip-github-capability",token)).status).toBe(200);
|
||||
await switchTo(input,"B");
|
||||
const b = await post().set("Authorization", `Bearer ${token}`);
|
||||
expect(b.status).toBe(200); expect(b.body.env).toEqual({});
|
||||
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id,input.runId));
|
||||
expect((await post().set("Authorization", `Bearer ${token}`)).status).toBe(403);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -21,6 +21,32 @@ import type { AuthorizationActor, AuthorizationDecision } from "../services/auth
|
|||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
|
||||
|
||||
describe("resolveExecutionRunAdapterConfig", () => {
|
||||
it("does not preflight or resolve legacy GitHub token bindings for managed executions", async () => {
|
||||
const assertNoGitHubBinding = (env: Record<string, unknown>) => {
|
||||
for (const key of Object.keys(env)) if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN)$/.test(key)) {
|
||||
throw new Error("Unavailable legacy GitHub secret must not be resolved at startup");
|
||||
}
|
||||
};
|
||||
const missing = { type: "user_secret_ref", key: "old-github-token", required: true };
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1", agentId: "agent-1", environmentId: "environment-1",
|
||||
projectId: "project-1", routineId: "routine-1", managedGitHubCredentials: true,
|
||||
executionRunConfig: { env: { GH_TOKEN: missing, AGENT_VALUE: "ok" } },
|
||||
environmentEnv: { GITHUB_TOKEN: missing, ENVIRONMENT_VALUE: "ok" },
|
||||
projectEnv: { GH_ENTERPRISE_TOKEN: missing, PROJECT_VALUE: "ok" },
|
||||
routineEnv: { GITHUB_ENTERPRISE_TOKEN: missing, PAPERCLIP_GIT_TOKEN: missing, ROUTINE_VALUE: "ok" },
|
||||
secretsSvc: {
|
||||
collectMissingRuntimeBindings: vi.fn(async (_companyId, env) => { assertNoGitHubBinding(env); return []; }),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId, config) => {
|
||||
assertNoGitHubBinding(config.env); return { config, secretKeys: new Set(), manifest: [] };
|
||||
}),
|
||||
resolveEnvBindings: vi.fn(async (_companyId, env) => {
|
||||
assertNoGitHubBinding(env); return { env, secretKeys: new Set(), manifest: [] };
|
||||
}),
|
||||
} as any,
|
||||
});
|
||||
expect(result.resolvedConfig.env).toEqual({ AGENT_VALUE: "ok", ENVIRONMENT_VALUE: "ok", PROJECT_VALUE: "ok", ROUTINE_VALUE: "ok" });
|
||||
});
|
||||
it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, issueRelations } from "@paperclipai/db";
|
||||
import { documentService } from "../../services/documents.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./embedded-postgres.js";
|
||||
import { createApp } from "../../app.js";
|
||||
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
import { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } from "../../realtime/runner-prp-ws.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
|
||||
/** Disposable real routes, database and storage. A fresh company isolates each attempt. */
|
||||
export async function startRunnerApiTestServer() {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-"));
|
||||
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
|
||||
const db = createDb(temporary.connectionString);
|
||||
const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage")));
|
||||
const app = await createApp(db, {
|
||||
uiMode: "none", serverPort: 0, storageService: storage,
|
||||
deploymentMode: "authenticated", deploymentExposure: "private",
|
||||
allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true,
|
||||
companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`,
|
||||
localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [],
|
||||
decisionServiceOptions: { wakeOriginAgent: async () => undefined },
|
||||
});
|
||||
const http = createServer(app);
|
||||
const sockets = new Set<import("node:net").Socket>();
|
||||
http.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
||||
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve));
|
||||
const address = http.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing eval listener");
|
||||
const apiUrl = `http://127.0.0.1:${address.port}`;
|
||||
setupRunnerPrpWebSocketServer(http, { apiUrl });
|
||||
return {
|
||||
db, root, apiUrl, storage,
|
||||
async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean } = {}) {
|
||||
// This DB is created inside this helper, never supplied by a caller. Paid
|
||||
// paired runs reset it between attempts so modeled IDs and data match.
|
||||
if (options.reset) await db.execute(sql`TRUNCATE companies CASCADE`);
|
||||
const id = (key: string) => {
|
||||
if (!options.reset) return randomUUID();
|
||||
const hex = createHash("sha256").update(`runner-api-fixture:${key}`).digest("hex");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
};
|
||||
const companyId = id("company"), agentId = id("agent"), issueId = id("issue"), runId = id("run"), projectId = id("project");
|
||||
const foreignCompanyId = id("foreign-company"), foreignProjectId = id("foreign-project");
|
||||
const projectWorkspaceId = id("workspace"), artifactId = id("artifact"), binaryArtifactId = id("binary-artifact"), goalId = id("goal");
|
||||
const blockerId = id("blocker"), approvalId = id("approval");
|
||||
const workspace = await mkdtemp(join(root, "workspace-"));
|
||||
await writeFile(join(workspace, "sample.txt"), "API escape hatch fixture\n");
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: "API eval", issueCounter: 2, issuePrefix: "E" + companyId.replaceAll("-", "").slice(0, 8) },
|
||||
{ id: foreignCompanyId, name: "Isolated foreign company", issuePrefix: "O" + foreignCompanyId.replaceAll("-", "").slice(0, 8) },
|
||||
]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "API eval agent", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", cwd: workspace }, runtimeConfig: { heartbeat: { enabled: false } }, status: "active" });
|
||||
await db.insert(projects).values([
|
||||
{ id: projectId, companyId, name: "Aurora", description: "The project verification code is violet-otter.", status: "in_progress" },
|
||||
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Private project", description: "foreign-data-must-not-leak" },
|
||||
]);
|
||||
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Fixture workspace", cwd: workspace, isPrimary: true });
|
||||
await db.insert(goals).values({ id: goalId, companyId, title: "Ship Aurora", level: "company", status: "active" });
|
||||
for (const [id, body, contentType, filename] of [[artifactId, Buffer.from("API escape hatch fixture\n"), "text/plain", "sample.txt"], [binaryArtifactId, Buffer.alloc(32_000, 65), "application/octet-stream", "large.bin"]] as const) {
|
||||
const saved = await storage.putFile({ companyId, namespace: "eval", originalFilename: filename, contentType, body });
|
||||
await db.insert(assets).values({ id, companyId, ...saved, createdByAgentId: agentId });
|
||||
}
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-1", title: "Verify runner API tools", description: "Fixture marker: amber-fox.", status: "in_progress", workMode: options.mode ?? "standard", assigneeAgentId: agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
await db.insert(issues).values({ id: blockerId, companyId, projectId, issueNumber: 2, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-2", title: "Dependency gate", description: "Complete before shipping.", status: "todo", assigneeAgentId: agentId });
|
||||
await db.insert(approvals).values({ id: approvalId, companyId, type: "runner_review", status: "pending", requestedByAgentId: agentId, payload: { title: "Launch review" } });
|
||||
await documentService(db).upsertIssueDocument({ issueId, key: "notes", title: "Fixture notes", format: "markdown", body: "Document verification code: silver-wren.", baseRevisionId: null, changeSummary: null, createdByAgentId: agentId, createdByRunId: runId });
|
||||
const binding = { companyId, agentId, issueId, runId, apiUrl, storage, apiToolsEnabled: options.apiToolsEnabled ?? true };
|
||||
return {
|
||||
...binding, projectId, projectWorkspaceId, artifactId, binaryArtifactId, goalId, blockerId, approvalId, foreignCompanyId, foreignProjectId, workspace,
|
||||
authority: new PaperclipRunnerToolAuthority(db, binding),
|
||||
async snapshot() {
|
||||
return {
|
||||
issues: await db.select().from(issues).where(eq(issues.companyId, companyId)),
|
||||
projects: await db.select().from(projects).where(eq(projects.companyId, companyId)),
|
||||
activity: await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)),
|
||||
comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)),
|
||||
assets: await db.select().from(assets).where(eq(assets.companyId, companyId)),
|
||||
goals: await db.select().from(goals).where(eq(goals.companyId, companyId)),
|
||||
approvals: await db.select().from(approvals).where(eq(approvals.companyId, companyId)),
|
||||
documents: await db.select().from(documents).where(eq(documents.companyId, companyId)),
|
||||
issueRelations: await db.select().from(issueRelations).where(eq(issueRelations.companyId, companyId)),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
await app.locals.paperclipShutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
http.closeAllConnections();
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
await temporary.cleanup();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||
const ROUTES_DIR = path.resolve(__dirname, "../routes");
|
||||
|
||||
const apiPrefixes: Record<string, string> = {
|
||||
"pipelines.ts": "/api",
|
||||
"cases.ts": "/api",
|
||||
"smoke-lab.ts": "/api",
|
||||
"access.ts": "/api",
|
||||
"activity.ts": "/api",
|
||||
"adapters.ts": "/api",
|
||||
|
|
@ -70,14 +73,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
const ROUTE_LITERAL_PATTERN = /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
|
||||
const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/;
|
||||
const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
|
||||
const explicitOpenApiCoverageExclusions = new Set([
|
||||
// Pipeline routes are experimental and not yet represented in the public OpenAPI document.
|
||||
"pipelines.ts",
|
||||
// Case routes are experimental (enableCases flag) and not yet in the public OpenAPI document.
|
||||
"cases.ts",
|
||||
// Smoke lab routes are experimental and not yet represented in the public OpenAPI document.
|
||||
"smoke-lab.ts",
|
||||
]);
|
||||
const explicitOpenApiCoverageExclusions = new Set<string>();
|
||||
|
||||
// The set of contract-first routes whose OpenAPI document leads the mounted
|
||||
// request handler. The company-and-environment Claude setup-token login routes
|
||||
|
|
@ -294,6 +290,7 @@ describe("openapi routes", () => {
|
|||
const { spec } = loadSpecRoutes();
|
||||
|
||||
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
|
||||
expect(spec.paths["/runtime-tools/github/credentials"].post.security).toEqual([{ RuntimeToolsBearerAuth: [] }]);
|
||||
expect(spec.paths["/api/plugins/install"].post.security).toEqual([
|
||||
{ BoardSessionAuth: [] },
|
||||
{ BoardApiKeyAuth: [] },
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ describe("opencode_local environment diagnostics", () => {
|
|||
|
||||
it("treats an empty OPENAI_API_KEY override as missing", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-empty-key-"));
|
||||
// This case tests environment precedence, not model-discovery retry delays.
|
||||
const fakeOpencode = path.join(cwd, "opencode");
|
||||
await fs.writeFile(fakeOpencode, "#!/bin/sh\necho openai/test-model\n", { mode: 0o755 });
|
||||
const originalOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
process.env.OPENAI_API_KEY = "sk-host-value";
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ describe("opencode_local environment diagnostics", () => {
|
|||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
command: fakeOpencode,
|
||||
cwd,
|
||||
env: {
|
||||
OPENAI_API_KEY: "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("durable execution identity", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-run-identity-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); }, 60_000);
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Identity tests", issuePrefix: companyId.slice(0, 8) });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Shared agent", role: "engineer", adapterType: "codex_local" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Shared task", responsibleUserId: "owner" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
|
||||
const messageIds: string[] = [];
|
||||
for (const author of ["A", "B", "A"]) {
|
||||
const id = randomUUID();
|
||||
await db.insert(issueComments).values({ id, companyId, issueId, authorUserId: author, body: "Instruction" });
|
||||
messageIds.push(id);
|
||||
}
|
||||
return { companyId, agentId, issueId, runId, messageIds };
|
||||
}
|
||||
it("retains mixed-author delivery order without rewriting task ownership", async () => {
|
||||
const input = await seed();
|
||||
await initializeRunIdentity(db, { ...input, responsibleUserId: "owner", cause: "queued" });
|
||||
const history = await listRunIdentityContexts(db, input.companyId, input.runId);
|
||||
expect(history.map((row) => row.responsibleUserId)).toEqual(["owner", "A", "B", "A"]);
|
||||
expect(history.map((row) => row.revision)).toEqual([1, 2, 3, 4]);
|
||||
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
|
||||
expect(issue.responsibleUserId).toBe("owner");
|
||||
await initializeRunIdentity(db, { ...input, responsibleUserId: "B", cause: "restart" });
|
||||
expect(await listRunIdentityContexts(db, input.companyId, input.runId)).toHaveLength(4);
|
||||
});
|
||||
it("holds acquisition during uncertain steering, preserves snapshots, and never rewinds on replay", async () => {
|
||||
const input = await seed();
|
||||
await initializeRunIdentity(db, { ...input, messageIds: [input.messageIds[0]], responsibleUserId: "A", cause: "instruction" });
|
||||
const startedUnderA = await captureRunIdentity(db, input);
|
||||
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
|
||||
expect(b).not.toBeNull();
|
||||
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
|
||||
await expect(reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] })).rejects.toThrow(/reconciled/);
|
||||
await db.transaction(async tx => {
|
||||
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)).for("update");
|
||||
await acceptSteeredIdentity(tx, b!);
|
||||
});
|
||||
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
|
||||
expect(startedUnderA.context?.responsibleUserId).toBe("A");
|
||||
const a = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] });
|
||||
await acceptSteeredIdentity(db, a!);
|
||||
await acceptSteeredIdentity(db, b!);
|
||||
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
|
||||
});
|
||||
it("keeps rejected steering unchanged and preserves the originating identity on retry", async () => {
|
||||
const input = await seed();
|
||||
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
|
||||
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
|
||||
await rejectSteeredIdentity(db, b!);
|
||||
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
|
||||
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "B", cause: "retry" });
|
||||
expect(retry.responsibleUserId).toBe("A");
|
||||
expect(retry.parentContextId).toBe((await captureRunIdentity(db, input)).context?.id);
|
||||
await expect(captureRunIdentity(db, { ...input, companyId: randomUUID() })).rejects.toThrow();
|
||||
await expect(captureRunIdentity(db, { ...input, agentId: randomUUID() })).rejects.toThrow();
|
||||
});
|
||||
it("preserves an explicitly absent identity through a continuation initiated by another person", async () => {
|
||||
const input = await seed();
|
||||
const origin = await initializeRunIdentity(db, {
|
||||
...input, messageIds: [], responsibleUserId: null, cause: "routine",
|
||||
});
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId, companyId: input.companyId, agentId: input.agentId,
|
||||
status: "running", responsibleUserId: "B",
|
||||
});
|
||||
const continued = await initializeRunIdentity(db, {
|
||||
companyId: input.companyId, runId, parentContextId: origin.id,
|
||||
responsibleUserId: "B", cause: "retry",
|
||||
});
|
||||
expect(continued.responsibleUserId).toBeNull();
|
||||
const captured = await captureRunIdentity(db, { ...input, runId });
|
||||
expect(captured.run.responsibleUserId).toBeNull();
|
||||
expect(captured.context?.parentContextId).toBe(origin.id);
|
||||
});
|
||||
it("recovers an uncertain acknowledgement from the authenticated native event journal", async () => {
|
||||
const input = await seed();
|
||||
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
|
||||
const pending = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
|
||||
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
|
||||
await db.insert(heartbeatRunEvents).values({ companyId: input.companyId, runId: input.runId,
|
||||
agentId: input.agentId, seq: 1, eventType: "item.completed", sourceEventId: randomUUID(),
|
||||
payload: { prpEvent: { turnId: "turn-1", itemId: `turn-1:steer:${pending!.messageId}`, payload: {kind: "steering_acknowledgement"} } },
|
||||
});
|
||||
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
|
||||
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id, input.runId));
|
||||
expect((await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] }))?.status).toBe("accepted");
|
||||
});
|
||||
it("preserves an operation's origin through approval and delegation after another person steers", async () => {
|
||||
const input = await seed();
|
||||
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
|
||||
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
|
||||
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
|
||||
});
|
||||
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
|
||||
await acceptSteeredIdentity(db, b!);
|
||||
for (const approval of [true, false]) {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
|
||||
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId, issueId: input.issueId,
|
||||
...(approval ? { interactionId } : { parentContextId: origin.id }),
|
||||
responsibleUserId: "B", cause: approval ? "approval" : "delegation",
|
||||
});
|
||||
expect(continued.responsibleUserId).toBe("A");
|
||||
expect(continued.parentContextId).toBe(origin.id);
|
||||
}
|
||||
});
|
||||
it("retains continuation and approval attribution after deleting the originating agent and runs", async () => {
|
||||
const input = await seed();
|
||||
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
|
||||
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
|
||||
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
|
||||
});
|
||||
await db.update(issues).set({ originIdentityContextId: origin.id }).where(eq(issues.id, input.issueId));
|
||||
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, input.agentId));
|
||||
await db.delete(agents).where(eq(agents.id, input.agentId));
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({ id: agentId, companyId: input.companyId, name: "Replacement", role: "engineer", adapterType: "codex_local" });
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, input.issueId));
|
||||
expect(task.continuationIdentityContextId).toBe(origin.id);
|
||||
for (const source of [{ parentContextId: task.originIdentityContextId }, { parentContextId: task.continuationIdentityContextId }, { interactionId }]) {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId, status: "running" });
|
||||
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId,
|
||||
issueId: input.issueId, ...source, responsibleUserId: "B", cause: "continuation" });
|
||||
expect(continued.responsibleUserId).toBe("A");
|
||||
expect(continued.parentContextId).toBe(origin.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not deadlock identity initialization against a task mutation that also updates the run", async () => {
|
||||
const input = await seed();
|
||||
let initialization!: ReturnType<typeof initializeRunIdentity>;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.select().from(issues).where(eq(issues.id, input.issueId)).for("update");
|
||||
const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
|
||||
initialization = initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
|
||||
// Wait until initialization is blocked by this task mutation, rather than
|
||||
// relying on timing to decide whether it has acquired its first lock.
|
||||
let waiting = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const [state] = await db.execute(sql`select exists (
|
||||
select 1 from pg_stat_activity where ${backend.pid} = any(pg_blocking_pids(pid))
|
||||
) as waiting`) as unknown as Array<{ waiting: boolean }>;
|
||||
if (state.waiting) { waiting = true; break; }
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(waiting).toBe(true);
|
||||
await tx.execute(sql`set local lock_timeout = '1s'`);
|
||||
await tx.update(heartbeatRuns).set({ updatedAt: new Date() }).where(eq(heartbeatRuns.id, input.runId));
|
||||
});
|
||||
await expect(initialization).resolves.toMatchObject({ responsibleUserId: "A" });
|
||||
});
|
||||
|
||||
it("does not turn a company-default fallback into personal consent on continuation", async () => {
|
||||
const input = await seed();
|
||||
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "company_default" });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
|
||||
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "A", cause: "retry" });
|
||||
expect(retry.cause).toBe("company_default");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -38,6 +38,8 @@ import {
|
|||
toolRuntimeMetricCounters,
|
||||
toolRuntimeSlots,
|
||||
toolStdioCommandTemplates,
|
||||
userSecretDefinitions,
|
||||
userSecretDeclarations,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
|
|
@ -5001,6 +5003,36 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual(["app_paused"]);
|
||||
});
|
||||
|
||||
it("preserves all active personal OAuth declarations through pause, resume, and metadata edits", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: company.id, name: "GitHub", type: "mcp_http" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId: company.id, applicationId: application.id, name: "GitHub", uid: randomUUID(),
|
||||
transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user",
|
||||
config: { url: "https://api.githubcopilot.com/mcp/", sourceTemplateKey: "github" },
|
||||
}).returning();
|
||||
const [sharedDefinition] = await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "OAuth access token" }).returning();
|
||||
const definitionIds = [sharedDefinition.id];
|
||||
for (const user of ["A", "B", "revoked"]) {
|
||||
const definition = user === "revoked"
|
||||
? (await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "Revoked identity" }).returning())[0]
|
||||
: sharedDefinition;
|
||||
const [secret] = await db.insert(companySecrets).values({ companyId: company.id, key: randomUUID(), name: user,
|
||||
scope: "user", ownerUserId: user, userSecretDefinitionId: definition.id }).returning();
|
||||
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "user",
|
||||
subjectUserId: user, status: user === "revoked" ? "revoked" : "active",
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token", versionSelector: "latest" }],
|
||||
});
|
||||
}
|
||||
for (const edit of [{ enabled: false }, { enabled: true }, { name: "Renamed GitHub" }]) {
|
||||
await service.updateConnection(connection.id, edit);
|
||||
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection.id));
|
||||
expect(declarations.map((row) => row.userSecretDefinitionId).sort()).toEqual([...definitionIds].sort());
|
||||
expect(declarations.every((row) => row.configPath === "oauth.access_token")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
companies,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companyMemberships,
|
||||
toolConnectionInstalls,
|
||||
issueComments,
|
||||
connectionGrants,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
|
|
@ -26,6 +29,7 @@ import {
|
|||
} from "@paperclipai/db";
|
||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import type { VercelConnectClient } from "../services/vercel-connect.js";
|
||||
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../services/run-identity.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
createToolGatewayService,
|
||||
|
|
@ -1203,6 +1207,53 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
|||
expect(storedGrant?.externalCredential).toMatchObject({ tokenId: "stk_fresh" });
|
||||
});
|
||||
|
||||
it("keeps managed GitHub personal identity even under a legacy shared policy", async () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
||||
await db.update(toolConnections).set({ authKind: "oauth", credentialSource: "paperclip_vault",
|
||||
config: { ...connection.config, sourceTemplateKey: "github" },
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
await db.insert(toolConnectionInstalls).values({ companyId: company.id,
|
||||
connectionId: connection.id, targetType: "agent", targetId: agent.id });
|
||||
await db.insert(companyMemberships).values(["A", "B"].map(principalId => ({ companyId: company.id,
|
||||
principalType: "user", principalId, status: "active", membershipRole: "member" })));
|
||||
const grants = await db.insert(connectionGrants).values(["A", "B"].map(subjectUserId => ({
|
||||
companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId,
|
||||
status: "active", credentialSecretRefs: [],
|
||||
}))).returning();
|
||||
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
|
||||
responsibleUserId: "A", cause: "instruction" });
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads",
|
||||
policyType: "allow", selectors: { riskLevel: "read" } });
|
||||
const resolvedGrants: string[] = [];
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
oauthGrantRefresher: async ({ grantId }) => {
|
||||
resolvedGrants.push(grantId);
|
||||
return db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId)).then(rows => rows[0]!);
|
||||
},
|
||||
remoteHttpRequest: async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0",
|
||||
id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } }),
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!;
|
||||
for (const [index, user] of ["A", "B", "A"].entries()) {
|
||||
if (index > 0) {
|
||||
const [message] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id,
|
||||
authorUserId: user, body: "Next instruction" }).returning();
|
||||
const pending = await reserveSteeredIdentity(db, { companyId: company.id, runId: run.id,
|
||||
issueId: issue.id, messageId: message.id });
|
||||
await reconcileSteeredIdentity(db, pending!);
|
||||
}
|
||||
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).status).toBe("completed");
|
||||
}
|
||||
expect(resolvedGrants).toEqual([grants[0].id, grants[1].id, grants[0].id]);
|
||||
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, company.id));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ reasonCode: "grant_owner_membership_inactive" });
|
||||
expect(resolvedGrants).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
||||
|
|
@ -1222,6 +1273,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
|||
await db.update(toolConnections).set({
|
||||
authKind: "oauth",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialRefs: [{ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", secretId: accessSecret.id, versionSelector: "latest" }],
|
||||
config: {
|
||||
url: "https://example.invalid/mcp",
|
||||
oauth: {
|
||||
|
|
|
|||
|
|
@ -7737,7 +7737,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
const reservePort = async () => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const probe = net.createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
// macOS can allocate an entire ephemeral range above 55535. Pick a
|
||||
// bounded candidate so the test's HMR companion remains a valid port.
|
||||
await new Promise<void>((resolve) => {
|
||||
probe.once("error", () => resolve());
|
||||
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
|
||||
});
|
||||
if (!probe.listening) continue;
|
||||
const address = probe.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
@ -8078,7 +8084,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
const reservePort = async () => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const probe = net.createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
// macOS can allocate an entire ephemeral range above 55535. Pick a
|
||||
// bounded candidate so the test's HMR companion remains a valid port.
|
||||
await new Promise<void>((resolve) => {
|
||||
probe.once("error", () => resolve());
|
||||
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
|
||||
});
|
||||
if (!probe.listening) continue;
|
||||
const address = probe.address();
|
||||
const candidate = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -981,6 +981,8 @@ export async function createApp(
|
|||
const shutdownAppServices = (): Promise<void> => {
|
||||
if (appServicesShutdown) return appServicesShutdown;
|
||||
appServicesShutdown = (async () => {
|
||||
scheduler.stop();
|
||||
jobCoordinator.stop();
|
||||
disableFeedbackExportFlushes();
|
||||
if (importTransferSweepTimer) {
|
||||
clearInterval(importTransferSweepTimer);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|||
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { captureRunIdentity } from "../services/run-identity.js";
|
||||
import { boardAuthService } from "../services/board-auth.js";
|
||||
|
||||
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
|
||||
|
|
@ -379,7 +380,18 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
|||
return;
|
||||
}
|
||||
|
||||
const onBehalfOfUserId = claims.responsible_user_id !== undefined
|
||||
const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
|
||||
));
|
||||
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
|
||||
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
|
||||
identityRun.activeIdentityContextId = captured.context?.id ?? null;
|
||||
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
|
||||
}
|
||||
const onBehalfOfUserId = identityRun?.activeIdentityContextId
|
||||
? identityRun.responsibleUserId
|
||||
: claims.responsible_user_id !== undefined
|
||||
? normalizeOptionalString(claims.responsible_user_id)
|
||||
: await resolveLegacyRunResponsibleUserId(db, {
|
||||
companyId: claims.company_id,
|
||||
|
|
@ -399,6 +411,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
|||
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
|
||||
runId: claims.run_id,
|
||||
onBehalfOfUserId,
|
||||
identityContextId: identityRun?.activeIdentityContextId ?? null,
|
||||
onBehalfOfMemberships,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5205,12 +5205,16 @@ export function agentRoutes(
|
|||
source: opts.source,
|
||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||
reason: req.body.reason ?? null,
|
||||
payload: req.body.payload ?? null,
|
||||
payload: req.actor.type === "agent" && req.body.payload
|
||||
? { ...req.body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
|
||||
: req.body.payload ?? null,
|
||||
idempotencyKey: req.body.idempotencyKey ?? null,
|
||||
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
contextSnapshot: {
|
||||
triggeredBy: req.actor.type,
|
||||
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
forceFreshSession: req.body.forceFreshSession === true,
|
||||
...(req.body.reason === "rerun_with_provider_trace" &&
|
||||
|
|
@ -5312,6 +5316,8 @@ export function agentRoutes(
|
|||
}>;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
triggeredBy: req.actor.type,
|
||||
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
};
|
||||
if (body.forceFreshSession === true) {
|
||||
|
|
@ -5336,7 +5342,9 @@ export function agentRoutes(
|
|||
wakeOpts.reason = body.reason;
|
||||
}
|
||||
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
|
||||
wakeOpts.payload = body.payload as Record<string, unknown>;
|
||||
wakeOpts.payload = req.actor.type === "agent"
|
||||
? { ...body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
|
||||
: body.payload as Record<string, unknown>;
|
||||
}
|
||||
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
|
||||
wakeOpts.idempotencyKey = body.idempotencyKey;
|
||||
|
|
@ -6036,7 +6044,7 @@ export function agentRoutes(
|
|||
run.companyId,
|
||||
run.id,
|
||||
redactCurrentUserValue(
|
||||
{ ...decoratedRun, retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
|
||||
{ ...decoratedRun, identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
|
||||
await getCurrentUserRedactionOptions(),
|
||||
),
|
||||
));
|
||||
|
|
@ -6673,3 +6681,4 @@ export function agentRoutes(
|
|||
|
||||
return router;
|
||||
}
|
||||
import { listRunIdentityContexts } from "../services/run-identity.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
|
||||
export const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
|
||||
export const DEFAULT_EVENTS_LIMIT = 100;
|
||||
export const MAX_EVENTS_LIMIT = 500;
|
||||
|
||||
export const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
export const caseStatusSchema = z.enum(CASE_STATUSES);
|
||||
export const caseTypeSchema = z.string().trim().min(1).max(120);
|
||||
export const caseKeySchema = z.string().trim().min(1).max(512);
|
||||
export const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
|
||||
|
||||
export const createCaseSchema = z.object({
|
||||
projectId: z.string().guid().nullable().optional(),
|
||||
caseType: caseTypeSchema,
|
||||
key: caseKeySchema.nullable().optional(),
|
||||
title: z.string().trim().min(1).max(500),
|
||||
summary: z.string().max(8_000).nullable().optional(),
|
||||
status: caseStatusSchema.optional(),
|
||||
fields: jsonObjectSchema.optional(),
|
||||
parentCaseId: z.string().guid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const patchCaseSchema = z.object({
|
||||
projectId: z.string().guid().nullable().optional(),
|
||||
title: z.string().trim().min(1).max(500).optional(),
|
||||
summary: z.string().max(8_000).nullable().optional(),
|
||||
status: caseStatusSchema.optional(),
|
||||
fields: jsonObjectSchema.optional(),
|
||||
parentCaseId: z.string().guid().nullable().optional(),
|
||||
labels: z.array(z.string().guid()).max(100).optional(),
|
||||
labelIds: z.array(z.string().guid()).max(100).optional(),
|
||||
}).strict();
|
||||
|
||||
export const createIssueLinkSchema = z.object({
|
||||
issueId: z.string().guid(),
|
||||
role: z.enum(CASE_LINK_ROLES),
|
||||
}).strict();
|
||||
|
||||
export const upsertCaseDocumentSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
format: z.string().trim().min(1).max(80).optional().default("markdown"),
|
||||
body: z.string().max(200_000),
|
||||
changeSummary: z.string().trim().max(1_000).nullable().optional(),
|
||||
baseRevisionId: z.string().guid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
|
||||
|
||||
export const listCasesQuerySchema = z.object({
|
||||
type: z.string().trim().min(1).max(120).optional(),
|
||||
types: queryListParamSchema,
|
||||
status: z.string().trim().min(1).max(120).optional(),
|
||||
statuses: queryListParamSchema,
|
||||
project: z.string().guid().optional(),
|
||||
projectId: z.string().guid().optional(),
|
||||
projectIds: queryListParamSchema,
|
||||
includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
label: z.string().guid().optional(),
|
||||
labelId: z.string().guid().optional(),
|
||||
parent: z.string().guid().optional(),
|
||||
q: z.string().trim().min(1).max(200).optional(),
|
||||
includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
}).strict();
|
||||
|
||||
export const listEventsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
|
||||
}).strict();
|
||||
|
||||
|
|
@ -1,3 +1,21 @@
|
|||
import {
|
||||
CASE_STATUSES,
|
||||
CASE_LINK_ROLES,
|
||||
DEFAULT_EVENTS_LIMIT,
|
||||
MAX_EVENTS_LIMIT,
|
||||
jsonObjectSchema,
|
||||
caseStatusSchema,
|
||||
caseTypeSchema,
|
||||
caseKeySchema,
|
||||
documentKeySchema,
|
||||
createCaseSchema,
|
||||
patchCaseSchema,
|
||||
createIssueLinkSchema,
|
||||
upsertCaseDocumentSchema,
|
||||
queryListParamSchema,
|
||||
listCasesQuerySchema,
|
||||
listEventsQuerySchema,
|
||||
} from "./cases-schemas.js";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
|
|
@ -36,75 +54,6 @@ import { assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"
|
|||
type CaseRouteDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type CaseActor = ReturnType<typeof getActorInfo>;
|
||||
|
||||
const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
|
||||
const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
|
||||
const DEFAULT_EVENTS_LIMIT = 100;
|
||||
const MAX_EVENTS_LIMIT = 500;
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
const caseStatusSchema = z.enum(CASE_STATUSES);
|
||||
const caseTypeSchema = z.string().trim().min(1).max(120);
|
||||
const caseKeySchema = z.string().trim().min(1).max(512);
|
||||
const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
|
||||
|
||||
const createCaseSchema = z.object({
|
||||
projectId: z.string().guid().nullable().optional(),
|
||||
caseType: caseTypeSchema,
|
||||
key: caseKeySchema.nullable().optional(),
|
||||
title: z.string().trim().min(1).max(500),
|
||||
summary: z.string().max(8_000).nullable().optional(),
|
||||
status: caseStatusSchema.optional(),
|
||||
fields: jsonObjectSchema.optional(),
|
||||
parentCaseId: z.string().guid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const patchCaseSchema = z.object({
|
||||
projectId: z.string().guid().nullable().optional(),
|
||||
title: z.string().trim().min(1).max(500).optional(),
|
||||
summary: z.string().max(8_000).nullable().optional(),
|
||||
status: caseStatusSchema.optional(),
|
||||
fields: jsonObjectSchema.optional(),
|
||||
parentCaseId: z.string().guid().nullable().optional(),
|
||||
labels: z.array(z.string().guid()).max(100).optional(),
|
||||
labelIds: z.array(z.string().guid()).max(100).optional(),
|
||||
}).strict();
|
||||
|
||||
const createIssueLinkSchema = z.object({
|
||||
issueId: z.string().guid(),
|
||||
role: z.enum(CASE_LINK_ROLES),
|
||||
}).strict();
|
||||
|
||||
const upsertCaseDocumentSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
format: z.string().trim().min(1).max(80).optional().default("markdown"),
|
||||
body: z.string().max(200_000),
|
||||
changeSummary: z.string().trim().max(1_000).nullable().optional(),
|
||||
baseRevisionId: z.string().guid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
|
||||
|
||||
const listCasesQuerySchema = z.object({
|
||||
type: z.string().trim().min(1).max(120).optional(),
|
||||
types: queryListParamSchema,
|
||||
status: z.string().trim().min(1).max(120).optional(),
|
||||
statuses: queryListParamSchema,
|
||||
project: z.string().guid().optional(),
|
||||
projectId: z.string().guid().optional(),
|
||||
projectIds: queryListParamSchema,
|
||||
includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
label: z.string().guid().optional(),
|
||||
labelId: z.string().guid().optional(),
|
||||
parent: z.string().guid().optional(),
|
||||
q: z.string().trim().min(1).max(200).optional(),
|
||||
includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
}).strict();
|
||||
|
||||
const listEventsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
|
||||
}).strict();
|
||||
|
||||
function eventActorValues(actor: CaseActor) {
|
||||
return {
|
||||
actorType: actor.actorType,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { logActivity } from "../services/activity-log.js";
|
|||
import { accessService } from "../services/access.js";
|
||||
import type { heartbeatService } from "../services/heartbeat.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
|
||||
|
||||
function bearer(req: Request) {
|
||||
const value = req.header("authorization") ?? "";
|
||||
|
|
@ -61,6 +62,19 @@ export function runtimeConnectionIntentRoutes(db: Db) {
|
|||
const router = Router();
|
||||
const service = connectionIntentService(db);
|
||||
|
||||
router.post("/runtime-tools/github/credentials", async (req, res) => {
|
||||
// This capability is never accepted as board/session authentication.
|
||||
// Node fetch sends Sec-Fetch-Mode too; browsers additionally send Origin or Sec-Fetch-Site.
|
||||
if (req.headers.origin || req.headers.cookie || req.headers["sec-fetch-site"]) throw forbidden("GitHub credentials require runtime authentication");
|
||||
const claims = verifyRuntimeToolsToken(typeof req.headers["x-paperclip-github-capability"] === "string"
|
||||
? req.headers["x-paperclip-github-capability"] : bearer(req), "github_credentials");
|
||||
if (!claims) throw unauthorized("Invalid GitHub runtime capability");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(await resolveGitHubOperationCredentials(db, {
|
||||
companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id,
|
||||
}));
|
||||
});
|
||||
|
||||
router.get("/mcp/runtime-tools", async (req, res) => {
|
||||
await service.validate(runtimeClaims(req));
|
||||
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,605 @@
|
|||
// Generated by scripts/generate-runner-experimental-api-metadata.mjs.
|
||||
export const experimentalApiMetadata: Record<string, { successStatuses: number[]; boardOnly: boolean; source: string }> = {
|
||||
"GET /api/companies/{companyId}/pipelines": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/pipelines-attention": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/case-events": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/pipelines": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/review-cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/review-cases/bulk": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}/health": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}/intake-form": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PATCH /api/pipelines/{pipelineId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/pipelines/{pipelineId}/stages": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PATCH /api/pipelines/{pipelineId}/stages/{stageId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PATCH /api/pipelines/{pipelineId}/stages/{stageId}/automation-env": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"DELETE /api/pipelines/{pipelineId}/stages/{stageId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PUT /api/pipelines/{pipelineId}/transitions": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PUT /api/pipelines/{pipelineId}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}/documents/{key}/revisions": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/pipelines/{pipelineId}/cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/pipelines/{pipelineId}/cases/batch": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/breakdown": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/pipelines/{pipelineId}/cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PUT /api/cases/{caseId}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/documents/{key}/revisions": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/children": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PATCH /api/cases/{caseId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/claim": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/release": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/transition": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/suggest-transition": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/resolve-suggestion": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/acknowledge-drift": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/review": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"PUT /api/cases/{caseId}/blockers": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/open-conversation": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/issue-links": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/outputs": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/issue-links": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"DELETE /api/cases/{caseId}/issue-links/{linkId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/events": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/children/tree": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/rollup": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/context-pack": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"GET /api/cases/{caseId}/automation/retry-plan": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/automation/retry": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/automations/{automationId}/retry": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/cases/{caseId}/automation/current-stage/rerun": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/pipelines.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}/documents/{key}/annotations": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}/documents/{key}/annotations/{threadId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/documents/{key}/annotations": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/documents/{key}/annotations/{threadId}/comments": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"PATCH /api/cases/{id}/documents/{key}/annotations/{threadId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"PUT /api/cases/{id}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/documents/{key}/lock": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/documents/{key}/unlock": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/documents/{key}/revisions/{revisionId}/restore": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"DELETE /api/cases/{id}/documents/{key}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/links": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"POST /api/cases/{id}/attachments": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}/events": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}/documents/{key}/revisions": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/issues/{issueId}/cases": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/cases/{id}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"PATCH /api/cases/{id}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/cases.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/smoke-lab/oauth/authorize": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/oauth/authorize": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/oauth/token": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/smoke-lab/oauth/userinfo": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/oauth/revoke": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/smoke-lab/services": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": true,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/services/start": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": true,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/services/stop": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": true,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/install-fixtures": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": true,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/smoke-lab/runs": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/runs": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"GET /api/companies/{companyId}/smoke-lab/runs/{runId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"PATCH /api/companies/{companyId}/smoke-lab/runs/{runId}": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/runs/{runId}/steps": {
|
||||
"successStatuses": [
|
||||
201
|
||||
],
|
||||
"boardOnly": false,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
},
|
||||
"POST /api/companies/{companyId}/smoke-lab/reset": {
|
||||
"successStatuses": [
|
||||
200
|
||||
],
|
||||
"boardOnly": true,
|
||||
"source": "server/src/routes/smoke-lab.ts"
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
// Experimental routes share their runtime validators with OpenAPI discovery.
|
||||
import { z } from "zod";
|
||||
import * as pipelineSchemas from "./pipelines-schemas.js";
|
||||
import * as caseSchemas from "./cases-schemas.js";
|
||||
import * as sharedSchemas from "@paperclipai/shared";
|
||||
|
||||
export const experimentalApiPaths: readonly [string, string, z.ZodTypeAny | undefined][] = [
|
||||
["get", "/api/companies/{companyId}/pipelines", undefined],
|
||||
["get", "/api/companies/{companyId}/pipelines-attention", undefined],
|
||||
["get", "/api/companies/{companyId}/case-events", undefined],
|
||||
["post", "/api/companies/{companyId}/pipelines", pipelineSchemas.createPipelineSchema],
|
||||
["get", "/api/companies/{companyId}/review-cases", undefined],
|
||||
["post", "/api/companies/{companyId}/review-cases/bulk", pipelineSchemas.bulkReviewSchema],
|
||||
["get", "/api/pipelines/{pipelineId}", undefined],
|
||||
["get", "/api/pipelines/{pipelineId}/health", undefined],
|
||||
["get", "/api/pipelines/{pipelineId}/intake-form", undefined],
|
||||
["patch", "/api/pipelines/{pipelineId}", pipelineSchemas.updatePipelineSchema],
|
||||
["post", "/api/pipelines/{pipelineId}/stages", pipelineSchemas.createStageSchema],
|
||||
["patch", "/api/pipelines/{pipelineId}/stages/{stageId}", pipelineSchemas.updateStageSchema],
|
||||
["patch", "/api/pipelines/{pipelineId}/stages/{stageId}/automation-env", pipelineSchemas.updateStageAutomationEnvSchema],
|
||||
["delete", "/api/pipelines/{pipelineId}/stages/{stageId}", undefined],
|
||||
["put", "/api/pipelines/{pipelineId}/transitions", pipelineSchemas.replaceTransitionsSchema],
|
||||
["get", "/api/pipelines/{pipelineId}/documents/{key}", undefined],
|
||||
["put", "/api/pipelines/{pipelineId}/documents/{key}", pipelineSchemas.upsertPipelineDocumentSchema],
|
||||
["get", "/api/pipelines/{pipelineId}/documents/{key}/revisions", undefined],
|
||||
["post", "/api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore", undefined],
|
||||
["post", "/api/pipelines/{pipelineId}/cases", pipelineSchemas.ingestCaseSchema],
|
||||
["post", "/api/pipelines/{pipelineId}/cases/batch", pipelineSchemas.batchIngestSchema],
|
||||
["post", "/api/cases/{caseId}/breakdown", pipelineSchemas.breakdownCaseSchema],
|
||||
["get", "/api/pipelines/{pipelineId}/cases", undefined],
|
||||
["get", "/api/cases/{caseId}", undefined],
|
||||
["get", "/api/cases/{caseId}/documents/{key}", undefined],
|
||||
["put", "/api/cases/{caseId}/documents/{key}", pipelineSchemas.upsertPipelineCaseDocumentSchema],
|
||||
["get", "/api/cases/{caseId}/documents/{key}/revisions", undefined],
|
||||
["post", "/api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore", undefined],
|
||||
["get", "/api/cases/{caseId}/children", undefined],
|
||||
["patch", "/api/cases/{caseId}", pipelineSchemas.casePatchSchema],
|
||||
["post", "/api/cases/{caseId}/claim", pipelineSchemas.claimCaseSchema],
|
||||
["post", "/api/cases/{caseId}/release", pipelineSchemas.releaseCaseSchema],
|
||||
["post", "/api/cases/{caseId}/transition", pipelineSchemas.transitionCaseSchema],
|
||||
["post", "/api/cases/{caseId}/suggest-transition", pipelineSchemas.suggestTransitionSchema],
|
||||
["post", "/api/cases/{caseId}/resolve-suggestion", pipelineSchemas.resolveSuggestionSchema],
|
||||
["post", "/api/cases/{caseId}/acknowledge-drift", pipelineSchemas.acknowledgeDriftSchema],
|
||||
["post", "/api/cases/{caseId}/review", pipelineSchemas.reviewCaseSchema],
|
||||
["put", "/api/cases/{caseId}/blockers", pipelineSchemas.blockersSchema],
|
||||
["post", "/api/cases/{caseId}/open-conversation", undefined],
|
||||
["get", "/api/cases/{caseId}/issue-links", undefined],
|
||||
["get", "/api/cases/{caseId}/outputs", undefined],
|
||||
["post", "/api/cases/{caseId}/issue-links", pipelineSchemas.createIssueLinkSchema],
|
||||
["delete", "/api/cases/{caseId}/issue-links/{linkId}", undefined],
|
||||
["get", "/api/cases/{caseId}/events", undefined],
|
||||
["get", "/api/cases/{caseId}/children/tree", undefined],
|
||||
["get", "/api/cases/{caseId}/rollup", undefined],
|
||||
["get", "/api/cases/{caseId}/context-pack", undefined],
|
||||
["get", "/api/cases/{caseId}/automation/retry-plan", undefined],
|
||||
["post", "/api/cases/{caseId}/automation/retry", sharedSchemas.pipelineAutomationRetryRequestSchema],
|
||||
["post", "/api/cases/{caseId}/automations/{automationId}/retry", undefined],
|
||||
["post", "/api/cases/{caseId}/automation/current-stage/rerun", undefined],
|
||||
["post", "/api/companies/{companyId}/cases", caseSchemas.createCaseSchema],
|
||||
["get", "/api/companies/{companyId}/cases", undefined],
|
||||
["get", "/api/cases/{id}/documents/{key}", undefined],
|
||||
["get", "/api/cases/{id}/documents/{key}/annotations", undefined],
|
||||
["get", "/api/cases/{id}/documents/{key}/annotations/{threadId}", undefined],
|
||||
["post", "/api/cases/{id}/documents/{key}/annotations", sharedSchemas.createDocumentAnnotationThreadSchema],
|
||||
["post", "/api/cases/{id}/documents/{key}/annotations/{threadId}/comments", sharedSchemas.createDocumentAnnotationCommentSchema],
|
||||
["patch", "/api/cases/{id}/documents/{key}/annotations/{threadId}", sharedSchemas.updateDocumentAnnotationThreadSchema],
|
||||
["put", "/api/cases/{id}/documents/{key}", caseSchemas.upsertCaseDocumentSchema],
|
||||
["post", "/api/cases/{id}/documents/{key}/lock", undefined],
|
||||
["post", "/api/cases/{id}/documents/{key}/unlock", undefined],
|
||||
["post", "/api/cases/{id}/documents/{key}/revisions/{revisionId}/restore", undefined],
|
||||
["delete", "/api/cases/{id}/documents/{key}", undefined],
|
||||
["post", "/api/cases/{id}/links", caseSchemas.createIssueLinkSchema],
|
||||
["post", "/api/cases/{id}/attachments", undefined],
|
||||
["get", "/api/cases/{id}/events", undefined],
|
||||
["get", "/api/cases/{id}/documents/{key}/revisions", undefined],
|
||||
["get", "/api/issues/{issueId}/cases", undefined],
|
||||
["get", "/api/cases/{id}", undefined],
|
||||
["patch", "/api/cases/{id}", caseSchemas.patchCaseSchema],
|
||||
["get", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/oauth/token", undefined],
|
||||
["get", "/api/companies/{companyId}/smoke-lab/oauth/userinfo", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/oauth/revoke", undefined],
|
||||
["get", "/api/companies/{companyId}/smoke-lab/services", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/services/start", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/services/stop", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/install-fixtures", undefined],
|
||||
["get", "/api/companies/{companyId}/smoke-lab/runs", undefined],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/runs", sharedSchemas.createSmokeRunSchema],
|
||||
["get", "/api/companies/{companyId}/smoke-lab/runs/{runId}", undefined],
|
||||
["patch", "/api/companies/{companyId}/smoke-lab/runs/{runId}", sharedSchemas.updateSmokeRunSchema],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/runs/{runId}/steps", sharedSchemas.recordSmokeRunStepSchema],
|
||||
["post", "/api/companies/{companyId}/smoke-lab/reset", undefined],
|
||||
];
|
||||
|
||||
// Query contracts shared with the runtime parser where one exists.
|
||||
export const experimentalApiQueries: Record<string, z.ZodObject<any>> = {
|
||||
"GET /api/companies/{companyId}/cases": caseSchemas.listCasesQuerySchema,
|
||||
"GET /api/cases/{id}/events": caseSchemas.listEventsQuerySchema,
|
||||
"GET /api/cases/{caseId}/automation/retry-plan": pipelineSchemas.retryAutomationQuerySchema,
|
||||
"GET /api/companies/{companyId}/pipelines-attention": z.object({ limit: z.coerce.number().int().positive().optional() }),
|
||||
"GET /api/companies/{companyId}/case-events": z.object({ types: z.string().optional(), limit: z.coerce.number().int().positive().optional(), offset: z.coerce.number().int().nonnegative().optional() }),
|
||||
"GET /api/companies/{companyId}/review-cases": z.object({ pipelineId: z.string().optional(), parentCaseId: z.string().optional() }),
|
||||
"GET /api/pipelines/{pipelineId}/cases": z.object({ stageKey: z.string().optional(), q: z.string().optional(), terminal: z.enum(["true", "false"]).optional(), includeRetired: z.enum(["true", "false"]).optional(), parentCaseId: z.string().optional() }),
|
||||
"DELETE /api/pipelines/{pipelineId}/stages/{stageId}": z.object({ moveCasesToStageId: z.string().optional() }),
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { storedSteeringAcknowledgement, reconcileSteeredIdentity, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity } from "../services/run-identity.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
|
|
@ -9227,6 +9228,7 @@ export function issueRoutes(
|
|||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
id: issueId,
|
||||
originRunId: createBody.originRunId ?? actor.runId,
|
||||
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||
executionPolicy,
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
|
|
@ -11963,6 +11965,10 @@ export function issueRoutes(
|
|||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
const actor = getActorInfo(req);
|
||||
const steeringIdentity = await reserveSteeredIdentity(db, {
|
||||
companyId: issue.companyId, runId: req.body.targetRunId, issueId: issue.id, messageId: commentId,
|
||||
});
|
||||
let steeringDeliveryAttempted = false;
|
||||
let acknowledgedTurnId: string | null = null;
|
||||
let duplicate = false;
|
||||
let queue: IssueQueuedCommentQueue;
|
||||
|
|
@ -12075,11 +12081,14 @@ export function issueRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
const acknowledgement = await steerNativeSession({
|
||||
steeringDeliveryAttempted = true;
|
||||
const acknowledgement = (steeringIdentity ? await storedSteeringAcknowledgement(tx, steeringIdentity) : null) ?? await steerNativeSession({
|
||||
runId: locked.activeRun.id,
|
||||
message: entry.comment.body,
|
||||
correlationId: commentId,
|
||||
onAcknowledged: steeringIdentity ? () => reconcileSteeredIdentity(db, steeringIdentity) : undefined,
|
||||
});
|
||||
if (steeringIdentity) await acceptSteeredIdentity(tx, steeringIdentity);
|
||||
acknowledgedTurnId = acknowledgement.turnId;
|
||||
const remainingIds = locked.queue.entries
|
||||
.map((candidate) => candidate.comment.id)
|
||||
|
|
@ -12130,6 +12139,10 @@ export function issueRoutes(
|
|||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const uncertain = steeringDeliveryAttempted && (!(error instanceof NativeSessionSteeringError)
|
||||
|| error.code === "steering_timeout");
|
||||
if (steeringIdentity && !uncertain) await rejectSteeredIdentity(db, steeringIdentity);
|
||||
|
||||
if (error instanceof NativeSessionSteeringError) {
|
||||
throw conflict(error.message, { code: error.code, retryable: true });
|
||||
}
|
||||
|
|
@ -12226,6 +12239,7 @@ export function issueRoutes(
|
|||
...req.body,
|
||||
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
|
||||
}, {
|
||||
identityContextId: req.actor.identityContextId,
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue