merge: integrate current master and preserve preview migration upgrades

Regenerate migration metadata after mainline claimed 0272. Preserve the work-folder SQL bytes under 0273 and exercise missing earlier migrations against saved preview data.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:15:48 -05:00
commit 3dd2b51596
201 changed files with 62609 additions and 3881 deletions

View File

@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8");
const nodeStep = workflow.split(" - name: Setup Node.js\n")[1]?.split(" - name:")[0];
assert.ok(nodeStep, "the refresh workflow must set up Node");
const input = (name) => nodeStep.match(new RegExp(`^ ${name}: (.+)$`, "m"))?.[1].trim();
// setup-node's explicit cache input enables a store cache independently of its
// automatic npm detection. Disabling only automatic detection is insufficient.
function cacheProvider(explicitCache, automaticCache, packageManager) {
if (explicitCache) return explicitCache;
if (automaticCache !== "false" && packageManager.startsWith("npm@")) return "npm";
return undefined;
}
for (const packageManager of ["pnpm@9.15.4", "npm@11.0.0"]) {
test(`resolution-only refresh cannot write a package-store cache (${packageManager})`, () => {
assert.match(workflow, /run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile/);
assert.equal(
cacheProvider(input("cache"), input("package-manager-cache"), packageManager),
undefined,
"a metadata-only job must not claim the shared cache key with an empty store",
);
// This is the original failure mode, even with automatic caching disabled.
assert.equal(cacheProvider("pnpm", "false", packageManager), "pnpm");
});
}

View File

@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const fleet = "runs-on/fleet=paperclip-post-merge-x64/env=public-ci";
const sha = "a".repeat(40);
const base = {
repository: "paperclipai/paperclip", repository_id: "1170821064",
ref: "refs/heads/master", event_name: "push", sha,
};
const expectedJobs = {
"cloud-readiness.yml": ["artifacts", "source_verified", "ready"],
"cloud-artifacts.yml": ["dispatch_migrator"],
"release-verify.yml": ["typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"],
"runner-chaos-evals.yml": ["chaos_and_recovery"],
"release.yml": ["plan_preview", "package_preview"],
};
for (const [file, expectedNames] of Object.entries(expectedJobs)) {
const workflow = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8");
const jobs = [...workflow.matchAll(/^ ([a-z_]+):\n([\s\S]*?)(?=^ [a-z_]+:\n|(?![\s\S]))/gm)];
const routed = jobs.filter(([, , body]) => body.includes(fleet));
test(`${file}: all intended jobs carry the post-merge guard`, () => {
assert.deepEqual(routed.map(([, name]) => name).sort(), [...expectedNames].sort());
});
for (const [, job, body] of routed) {
const expression = body.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(expression, `${file}/${job} must use an explicit runner expression`);
const release = file === "release.yml";
const checkRef = release || file === "release-verify.yml" || file === "runner-chaos-evals.yml";
const inputs = { ref: sha, source_ref: sha, channel: "cloud-migrator" };
const defaultContext = { ...base, event_name: release ? "workflow_dispatch" : "push" };
const cases = [
{ name: "exact master source", expected: fleet },
{ name: "manual exact master source", github: { event_name: "workflow_dispatch" }, expected: fleet },
{ name: "switch disabled", enabled: "false" },
{ name: "switch absent", enabled: "" },
{ name: "malformed switch", enabled: "yes" },
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
{ name: "repository renamed or transferred", github: { repository_id: "123" } },
{ name: "unapproved PR", github: { event_name: "pull_request", ref: "refs/pull/1/merge" } },
{ name: "PR event even with master ref", github: { event_name: "pull_request" } },
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
{ name: "repository dispatch", github: { event_name: "repository_dispatch" } },
{ name: "scheduled caller", github: { event_name: "schedule" } },
{ name: "branch workflow", github: { ref: "refs/heads/feature" } },
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
];
if (checkRef) {
const key = release ? "source_ref" : "ref";
for (const value of ["b".repeat(40), "refs/pull/1/head", "master", "feature", "v1.0.0", ""]) {
cases.push({ name: `unverified source ${value || "(empty)"}`, inputs: { [key]: value } });
}
cases.push({ name: "missing source identity", github: { sha: "" }, inputs: { [key]: "" } });
}
if (release) {
cases.push({ name: "preview of master", inputs: { channel: "preview" } });
cases.push({ name: "stable release", inputs: { channel: "stable" } });
}
for (const { name, github = {}, inputs: overrides = {}, enabled = "true", expected = "ubuntu-latest" } of cases) {
test(`${file}/${job}: ${name}`, () => {
const context = { github: { ...defaultContext, ...github }, inputs: { ...inputs, ...overrides }, vars: { AWS_POST_MERGE_CI_ENABLED: enabled } };
// These canonical contexts use boolean operators and string comparisons
// whose results match GitHub's expression evaluation.
assert.equal(runInNewContext(expression, context), expected);
const timeout = body.match(/^ timeout-minutes: (.+)$/m)?.[1];
assert.ok(timeout, "AWS jobs need a timeout below the 45-minute instance lifetime");
const minutes = timeout.startsWith("${{") ? runInNewContext(timeout.slice(3, -2), context) : Number(timeout);
if (expected === fleet) assert.ok(minutes > 0 && minutes < 45);
if (release && job === "plan_preview") assert.equal(minutes, expected === fleet ? 10 : 360);
});
}
}
if (file === "release.yml") {
test("npm publisher always uses a GitHub-hosted runner", () => {
const publisher = jobs.find(([ , job]) => job === "publish_preview")?.[2];
assert.match(publisher, /^ runs-on: ubuntu-latest$/m);
assert.match(publisher, /^ environment: npm-canary$/m);
assert.match(publisher, /^ id-token: write$/m);
});
}
}

View File

@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8");
const typecheck = workflow.split(" typecheck:\n")[1].split(" general_tests:\n")[0];
const cache = typecheck.split(" - name: Cache typecheck Rust dependencies\n")[1].split(" - name: Validate release package manifest")[0];
const sha = "a".repeat(40);
const github = { repository: "paperclipai/paperclip", event_name: "push", ref: "refs/heads/master", sha };
for (const [name, overrides, ref, allowed] of [
["exact master push", {}, sha, true],
["PR", { event_name: "pull_request", ref: "refs/pull/1/merge" }, sha, false],
["privileged PR", { event_name: "pull_request_target" }, sha, false],
["fork", { repository: "someone/paperclip" }, sha, false],
["branch", { ref: "refs/heads/feature" }, sha, false],
["manual source", { event_name: "workflow_dispatch" }, sha, false],
["unmerged source", {}, "b".repeat(40), false],
["moving ref", {}, "master", false],
]) {
test(`typecheck cache restore and save: ${name}`, () => {
for (const field of ["if", "save-if"]) {
const expr = cache.match(new RegExp(`^ +${field}: \\$\\{\\{ (.+) \\}\\}$`, "m"))?.[1];
assert.ok(expr);
assert.equal(runInNewContext(expr, { github: { ...github, ...overrides }, inputs: { ref } }), allowed);
}
});
}
test("cache excludes workspace code and executable installs, and preserves full checks", () => {
assert.match(cache, /uses: Swatinem\/rust-cache@[a-f0-9]{40}/);
assert.match(cache, /workspaces: packages\/paperclip-runner\/runner -> target/);
assert.match(cache, /shared-key: release-typecheck-v1/);
assert.match(cache, /cache-workspace-crates: false/);
assert.match(cache, /cache-bin: false/);
assert.ok(typecheck.indexOf('echo "RUSTUP_TOOLCHAIN=$toolchain"') < typecheck.indexOf("uses: Swatinem/rust-cache"));
assert.match(typecheck, /run: pnpm -r typecheck/);
assert.match(workflow, /shared-key: release-runner-v1/);
});

View File

@ -11,7 +11,7 @@ jobs:
dispatch_migrator:
name: Start exact-source cloud migrator publication
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
actions: write

View File

@ -32,7 +32,7 @@ jobs:
artifacts:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
name: Wait for exact-source cloud artifacts
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 35
permissions:
contents: read
@ -55,7 +55,7 @@ jobs:
name: Cloud source verified v1
needs: [verify]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@ -81,7 +81,7 @@ jobs:
name: Cloud deployable v1
needs: [verify, image, artifacts]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 5
steps:
- name: Record cloud readiness

View File

@ -32,7 +32,9 @@ jobs:
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
# Resolution-only installs do not populate the package store. Do not
# claim the shared cache key with an empty archive before full installs.
package-manager-cache: false
- name: Refresh pnpm lockfile
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile

View File

@ -8,6 +8,9 @@ on:
required: true
type: string
# Caller-provided refs may name unmerged PR code. AWS is eligible only when
# the caller runs on canonical master and verifies that event's exact SHA.
# The organization group also restricts these workflow files to master.
jobs:
runner_chaos_evals:
name: Pre-release Runner chaos evals
@ -17,7 +20,7 @@ jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read
@ -39,6 +42,30 @@ jobs:
node-version: 24
cache: pnpm
- name: Select the pinned Runner Rust toolchain
working-directory: packages/paperclip-runner
run: |
set -euo pipefail
rustup show
toolchain="$(rustup show active-toolchain | awk '{print $1}')"
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
- name: Cache typecheck Rust dependencies
# Restore and save only within trusted master-push verification. GitHub
# isolates branch/PR caches from master; other callers compile afresh.
if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: packages/paperclip-runner/runner -> target
shared-key: release-typecheck-v1
# Rebuild workspace code and rerun every check. Cache only compiled
# dependencies; never restore installed executables from cargo/bin.
cache-workspace-crates: false
cache-bin: false
# The step guard also restricts restores. Save only after a successful
# master-push verification of that push's exact commit.
save-if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
@ -50,7 +77,7 @@ jobs:
general_tests:
name: General tests (${{ matrix.group_label }})
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read
@ -157,7 +184,7 @@ jobs:
serialized_tests:
name: Serialized tests (${{ matrix.shard_label }})
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read
@ -206,7 +233,7 @@ jobs:
runner_workflow_evals:
name: Runner workflow eval scorer contract
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
@ -236,7 +263,7 @@ jobs:
verify_paperclip_runner:
name: Verify Paperclip Runner
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read
@ -290,7 +317,7 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read

View File

@ -76,11 +76,15 @@ env:
jobs:
plan_preview:
# Only the current master commit can use AWS. A preview or older source
# falls back to GitHub-hosted runners, including raced merge dispatches.
name: Check preview artifacts
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
permissions:
contents: read
# Preserve the previous hosted default; only AWS needs the Fleet limit.
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 10 || 360 }}
outputs:
image: ${{ steps.plan.outputs.image }}
packages: ${{ steps.plan.outputs.packages }}
@ -104,7 +108,7 @@ jobs:
name: Build preview migrator
needs: plan_preview
if: needs.plan_preview.outputs.packages == 'true'
runs-on: ubuntu-latest
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 30
permissions:
contents: read
@ -141,6 +145,7 @@ jobs:
retention-days: 7
publish_preview:
# npm trusted publishing supports GitHub-hosted runners only.
name: Publish preview migrator
needs: [plan_preview, package_preview]
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.packages == 'true' && needs.package_preview.result == 'success'

View File

@ -20,8 +20,8 @@ concurrency:
jobs:
chaos_and_recovery:
name: Restart, replay, trace, and recovery faults
runs-on: ubuntu-latest
timeout-minutes: 45
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
permissions:
contents: read

View File

@ -0,0 +1,76 @@
import { readFile } from "node:fs/promises";
import { Command } from "commander";
import { emailSendSchema } from "@paperclipai/shared";
import {
addCommonClientOptions,
resolveCommandContext,
printOutput,
type BaseClientOptions,
} from "./common.js";
export function registerEmailCommands(program: Command) {
const email = program
.command("email")
.description(
"Explicitly send and inspect task-bound AgentMail conversations",
);
addCommonClientOptions(email.command("inboxes"), {
includeCompany: true,
}).action(async (opts: BaseClientOptions) => {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(
await ctx.api.get(`/api/companies/${ctx.companyId}/email/inboxes`),
{ json: true },
);
});
for (const verb of ["send", "reply"] as const) {
addCommonClientOptions(
email
.command(verb)
.requiredOption(
"--file <path>",
"JSON request file, including a stable idempotencyKey",
),
{ includeCompany: true },
).action(async (opts: BaseClientOptions & { file: string }) => {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const input = emailSendSchema.parse(
JSON.parse(await readFile(opts.file, "utf8")),
);
if ((verb === "reply") !== Boolean(input.conversationId))
throw new Error(
`${verb} requires ${verb === "reply" ? "an existing conversation" : "a parent task and a new conversation"}`,
);
printOutput(
await ctx.api.post(`/api/companies/${ctx.companyId}/email/send`, input),
{ json: true },
);
});
}
addCommonClientOptions(
email.command("thread").argument("<issueId>", "Email task ID"),
{ includeCompany: true },
).action(async (issueId: string, opts: BaseClientOptions) => {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(
await ctx.api.get(
`/api/companies/${ctx.companyId}/email/tasks/${encodeURIComponent(issueId)}`,
),
{ json: true },
);
});
addCommonClientOptions(
email
.command("delivery")
.argument("<publicationId>", "Publication ID returned by send"),
{ includeCompany: true },
).action(async (publicationId: string, opts: BaseClientOptions) => {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(
await ctx.api.get(
`/api/companies/${ctx.companyId}/email/deliveries/${encodeURIComponent(publicationId)}`,
),
{ json: true },
);
});
}

View File

@ -1,3 +1,4 @@
import { registerEmailCommands } from "./commands/client/email.js";
import { Command } from "commander";
import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version";
import { onboard } from "./commands/onboard.js";
@ -233,6 +234,7 @@ heartbeat
registerContextCommands(program);
registerConnectCommand(program);
registerConnectionIntentCommands(program);
registerEmailCommands(program);
registerCompanyCommands(program);
registerIssueCommands(program);
registerAgentCommands(program);

View File

@ -150,7 +150,7 @@ Paperclips core identity is a **control plane for autonomous AI companies**,
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
6. **Execution visibility without log worship**
Active runs, recovery issues, productivity review states, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
Active runs, recovery issues, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
7. **Local-first, cloud-ready**
The mental model should not change between local solo use and shared/private or public/cloud deployment.

View File

@ -1582,3 +1582,16 @@ action outcomes; do not replay tool calls or reset the failed incident's automat
retry budget. Existing pause, approval, budget, ownership, and dependency gates
remain in effect. See `doc/execution-semantics.md` for admission and stop-proof
requirements.
### Experimental task-bound email
AgentMail channel connections extend the experimental conversation/task pipeline
with explicit email publication. Each owned inbox/provider thread binds one task;
external email senders do not gain board authority. Incoming correspondence uses
the assigned agent's normal execution controls. Internal task activity never
implicitly sends email. New outgoing conversations create child tasks and durable
send intents before provider contact. The board directs email work through the
normal task conversation; rich email cards show the correspondence and delivery
outcomes without a separate email composer. See
[AgentMail connections](connections/AGENTMAIL.md) for setup, transports, recovery,
authorization, and the API/CLI contract.

View File

@ -220,6 +220,19 @@ Examples:
- a materialization failure records the failed phase and next retry time rather
than silently dropping the side effect.
## Policy upgrades
The policy version on an assessment is audit metadata. New runs use the current
rules. A version change alone does not reassess an old run, change task status,
or ask a person to review completion. New evidence and explicit status changes
still use the existing reconciliation paths.
Reconciliation also withdraws pending review cards created solely by the old
policy-version check. It restores the previous status only if that exact decision
and status version are still current and no other review gate is pending. A later
user or agent decision takes precedence. The old assessments and decisions remain
in the audit history; cleanup does not accept or reject the agent's work.
## Diagnosing an unexpected status
Start with the terminal heartbeat run and inspect:

View File

@ -123,6 +123,36 @@ registry checks can be rerun without deploying or changing mutable npm channels.
When reverting this workflow, restore the master push trigger in
`docker-cloud.yml` in the same change so master images continue to build.
## Reserved AWS verification capacity
`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification, artifact
waiting, readiness signals, and exact-master migrator preparation to the
`paperclip-post-merge` runner group. The separate Fleet label is
`runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. Its 36 reserved slots use
the same four-vCPU, 16-GiB machines as approved PR jobs. PR capacity is reduced
to 64; image capacity stays at eight. The total ceiling remains 108 runners.
This keeps PR bursts from consuming every post-merge verification slot.
Every selector checks the canonical repository name and ID, master ref, and a
push or manual event. Reusable verification also requires `inputs.ref` to equal
that event's `github.sha`. The migrator route requires `cloud-migrator` and
`inputs.source_ref == github.sha`. Branch/tag refs, PR events, arbitrary preview
sources, and missing or disabled switches use GitHub-hosted runners. If another
merge lands before a migrator dispatch resolves master, the older source uses
GitHub-hosted runners too. npm publication always remains GitHub-hosted to keep
its trusted-publisher identity.
Before enabling the switch, deploy the separate Fleet and restrict its GitHub
runner group to repository ID `1170821064` and these workflows at
`refs/heads/master`: `cloud-readiness.yml`, `cloud-artifacts.yml`,
`release-verify.yml`, `runner-chaos-evals.yml`, and `release.yml`. Do not authorize
PR-controlled workflow versions. PR placement retains its independent pinned
workflow and six-account author/actor allowlist.
Disable the switch and rerun the whole workflow to restore GitHub-hosted
placement. Assigned jobs keep their original runners. Readiness requirements,
source checks, and npm integrity checks are unchanged.
## AWS cloud build routing
`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the
@ -145,3 +175,50 @@ workflow. Changing the variable does not migrate an already assigned job.
Check the Actions job's runner name and runner group to verify placement. Record
queue time, image verification completion, and `Cloud deployable v1` separately;
source verification and the migrator still run on GitHub-hosted runners.
### Typecheck Rust dependency cache
Source verification's typecheck job builds the native Runner binary through the
server's `prepare:runner-vendor` command. It restores and saves compiled Rust
dependencies only for canonical master pushes that verify the event's exact SHA.
The `release-typecheck-v1` cache is separate from Runner verification because
those jobs compile different profiles. The pinned toolchain is selected before
cache lookup. Workspace crates and installed cargo binaries are excluded, and
all typechecks still execute. A missing or invalidated cache triggers compilation.
### pnpm dependency store cache
The Refresh Lockfile workflow does not cache the pnpm store. Its resolution-only
command does not download packages and can save an empty default-branch cache
before full install jobs finish. Jobs that install dependencies retain caching.
After deploying this correction, remove any existing empty default-branch entry
for the current lockfile key. List cache IDs, branches, and archive sizes first:
```sh
gh api --paginate 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&key=node-cache-Linux-x64-pnpm-&per_page=100' \
--jq '.actions_caches[] | {id, ref, key, size_in_bytes}'
```
Match the key and upload size against the cache-creation job's logs. The
September 11 incident was cache ID `7559920987`, a 216-byte archive. This guarded
command deletes only that observed entry. It leaves a populated replacement or
an entry on another branch untouched, and does nothing if the old ID is absent:
```sh
bad_cache_id=7559920987
bad_cache_key=node-cache-Linux-x64-pnpm-c3096ecb02a34aaa9782baaadafcb731510e1dba10dd661618c3a2ee91e58fa5
entries="$(gh api --paginate --slurp 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&per_page=100')"
if printf '%s\n' "$entries" | jq -e --argjson id "$bad_cache_id" --arg key "$bad_cache_key" '
[.[].actions_caches[] | select(.id == $id)] |
length == 1 and .[0].ref == "refs/heads/master" and
.[0].key == $key and .[0].size_in_bytes == 216
' >/dev/null; then
gh api --method DELETE "repos/paperclipai/paperclip/actions/caches/$bad_cache_id"
fi
```
A subsequent master install can populate the missing entry. Check the saved
archive size and package reuse in install logs; a cache hit alone does not prove
that the entry contains dependencies.

View File

@ -10,6 +10,25 @@ application origin and is visible to every browser that receives the UI shell.
Do not include secrets or customer data. Restart the app after changing it.
Operators must review scripts and any required CSP changes before deployment.
## Base64 variant
Delivery pipelines that write env vars through provider APIs can sit behind
web application firewalls that reject values containing raw script markup.
`PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet through them as
standard base64 of the UTF-8 HTML:
```sh
PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)"
```
Whitespace and line wrapping in the value are tolerated. A value that is not
canonical padded base64 of UTF-8 text, or that decodes to blank, is ignored —
if the widget does not appear, check that the value round-trips through
`base64 -d`. A present `PAPERCLIP_CLOUD_UI_SNIPPET` always wins, blank
included: clearing the plain variable to blank disables injection even while
a base64 value is still deployed. Everything else about the snippet is
unchanged.
## Plain closed beta
Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the

View File

@ -0,0 +1,105 @@
# AgentMail Daytona verification — 2026-09-11
Worktree: `codex/agentmail`; isolated test drive at `http://localhost:3103`.
The original checkout remains untouched. Test mail used only the two previously
authorized inboxes, `pap15838-qa@agentmail.to` and
`attractiveforce961@agentmail.to`.
## Defects corrected
- AgentMail REST connections fell through the generic health-check branch into
local-stdio MCP validation. Both saved account credentials and inbox credentials
now validate against AgentMail's `/auth/me` API. Catalog refresh returns no MCP
tools, and invalid keys still produce a failed health result. The live Apps card
was inspected in the browser and showed Connected with no stdio error.
- Sandbox callback routing omitted task-email endpoints. It now allows assigned
inbox discovery, task-thread reads, delivery reads, and explicit sends. Server
company, inbox, task/run, and action-policy authorization remains in force.
Setup, credentials, reconnect, and operator delivery resolution stay denied.
- Shell-backed sandbox reads did not preserve ENOENT for a missing optional
Codex `auth.json`, causing cleanup to fail after a successful email send. Reads
now confirm absence in a searchable parent and return ENOENT; actual read and
transport failures still propagate. This lets existing auth copy-back treat
missing credentials as a no-op.
- Runtime instructions now document inbox discovery directly; the agent otherwise
spent time guessing that endpoint when initiating a new conversation.
## Live observations
The board used the ordinary task composer in
[AGE-10](http://localhost:3103/AGE/issues/AGE-10) to request a test email.
The agent executed the real Codex CLI in Daytona, used the sandbox callback
bridge to discover its assigned inbox and queue the send, and created
[AGE-11](http://localhost:3103/AGE/issues/AGE-11) as an email child task.
- Provider sandbox: `c2f176ca-dbde-41a6-995d-aefa4689e4c5`.
- Runtime verified by the agent: Linux, x86_64; hostname matched the sandbox.
- Run: `cd7b934a-5555-4361-b0e5-b8106c1510ce`.
- Publication: `d5b7bf41-a583-4f9f-90c0-4d21680e39c2`, **Delivered**.
- Subject: `[Paperclip E2E] Daytona sandbox — Sep 11`.
- Provider key remained in Paperclip's vault. The sandbox used its injected
Paperclip run credential, and the model key was separately vaulted.
The first fixture launches exposed an unavailable default ACP executable and a
host `service_tier` setting incompatible with the fleet image's Codex CLI. The
QA fixture explicitly selects the CLI engine and an isolated Codex home. Earlier
failed launches remain in AGE-9. The outbound send above completed, but its run
then failed during missing-auth-file cleanup; the cleanup fix is verified
separately below rather than rewriting that history.
## Cleanup verification
A fresh Daytona run in [AGE-12](http://localhost:3103/AGE/issues/AGE-12)
read the existing publication, confirmed Delivered, recorded its Linux hostname,
and completed successfully without sending another email.
- Run: `39e77902-714e-455f-90d8-8709f2d13762`, **Succeeded**.
- Sandbox: `d50c6979-de6e-4a0c-ac18-bd616a39ee1f`.
- Cleanup log: “no sandbox credential to copy back (absent auth.json); host
credential kept.” The environment lease reached Released.
## Automated checks
- 20 durable email pipeline tests passed, including health checks for account and
inbox credentials, catalog discovery, and invalid credentials.
- 56 sandbox callback bridge tests passed, including the four email routes and
denial of email administration routes.
- 28 command-managed runtime tests passed, including the missing-file contract
and propagation of real read failures.
- 4 capability inventory tests passed. Regenerated both capability indexes for
the new task-email runtime documentation and updated the expected row count.
- Server typecheck, server build, adapter-utils build, and whitespace checks passed.
## Inbound round trip
After Chrome access recovered, sent a new authorized test email from the other
inbox through AgentMail Console. WebSocket intake created
[AGE-13](http://localhost:3103/AGE/issues/AGE-13), assigned Email QA, and started
the agent in a fresh Daytona sandbox. The agent read the bound thread, explicitly
replied once, checked delivery, and marked the task Done.
- Run: `76be255b-df2e-4479-8c62-f4506f039132`, **Succeeded**.
- Sandbox/verified Linux hostname: `7bb660fa-3cff-4b26-9e10-68c884be21bb`.
- Reply publication: `efdd704c-afd3-4025-ab48-24fab6c97333`, **Delivered**.
- Incoming comment persisted at `19:05:14.750Z`; run started at
`19:05:14.920Z` (170 ms later). This interval excludes provider delivery and
does not measure model startup. The run finished at `19:06:09.481Z`.
- Exactly one incoming and one outgoing email comment, plus an internal summary.
- Visually verified the exact acknowledgement in
[the other AgentMail inbox](https://console.agentmail.to/dashboard/inboxes/attractiveforce961@agentmail.to?thread=805fd7f1-26c2-414a-b139-5fb65f490f50),
with matching reply message ID and original-message reference.
## Test cleanup
Restored Email QA's original local adapter configuration. Removed the temporary
Daytona environments, all six sandbox instances created by this test, and the
temporary vaulted Daytona/model credentials. Provider inboxes, saved AgentMail
credentials, task history, and run evidence remain available.
## Qualification limits
This run exercises the Codex CLI sandbox adapter. The native runner `task_email`
path is covered deterministically, but was not separately live-qualified in Daytona.
The Daytona inbound round trip used WebSocket intake. Earlier local-agent
WebSocket and signed-webhook qualification is documented in
[the main verification report](AGENTMAIL-VERIFICATION.md).

View File

@ -0,0 +1,122 @@
# AgentMail verification — 2026-09-11
## Environment
Worktree: `codex/agentmail`. The original checkout and its merge conflicts were
preserved. Live checks used the isolated AgentMail Test Drive company at
`http://localhost:3103`, with the experimental connections feature enabled.
Only these user-authorized inboxes exchanged test mail:
- Paperclip: `pap15838-qa@agentmail.to`, assigned to Email QA.
- Other end: `attractiveforce961@agentmail.to`, inspected in AgentMail Console.
## Live browser results
| Journey | Observed result |
| --- | --- |
| Connect from the Apps catalog | Saved personal human access, selected agent access, and a vaulted key through the real UI. |
| Give an agent an address | Used Permissions → three-step wizard → existing scoped inbox. The selected agent, review warnings, and connection persisted. |
| Trust controls | Saved Low-trust review with a root-task boundary, verified the missing-sandbox prerequisite, then explicitly restored Standard for this local QA agent. |
| Live receiving | Inbound correspondence created AGE-6. The agent explicitly replied once; the reply appeared in AgentMail Console and Paperclip recorded Delivered. |
| Signed webhook | Registered an inbox-scoped webhook. Actual signed POSTs returned 204. AGE-7 received its email, the agent replied once, and both consoles showed the exchange. |
| Reply to a completed conversation | New mail reused the same task and reopened it. |
| Restart catch-up | Sent another reply while the server health endpoint was unreachable. Startup imported it into AGE-7, woke the agent, and sent one acknowledgement in the same thread. |
| Agent-initiated new conversation | A board request in AGE-7 caused the agent to create AGE-8 with `parentId` pointing to AGE-7. One email was sent and marked Delivered; it appeared as a separate thread in AgentMail Console. |
| Internal publication boundary | Internal summaries and the outbound-only child task's “No reply sent” response produced no additional emails. |
| Cleanup | Restored WebSocket mode, removed Paperclip's test webhook, stopped the webhook-only proxy/tunnel, and removed the temporary public URL from the isolated configuration. Inbox history and vaulted test credentials remain inspectable. |
Useful live pages:
- [Saved connection permissions](http://localhost:3103/AGE/apps/78dd5c23-f60f-42ca-b30a-6f0c701b38d3/permissions)
- [Inbox settings](http://localhost:3103/AGE/apps/chat/7cdf17d6-465e-4eef-8858-2b545be64b3a/settings)
- [Inbound conversation and restart recovery: AGE-7](http://localhost:3103/AGE/issues/AGE-7)
- [Agent-created email child: AGE-8](http://localhost:3103/AGE/issues/AGE-8)
- [Other inbox in AgentMail Console](https://console.agentmail.to/dashboard/inboxes/attractiveforce961@agentmail.to)
## Timing
These are individual observations from `email.received` audit records, not a
load test or latency guarantee. Admission-to-wakeup includes durable processing
and heartbeat admission; it excludes provider delivery and subsequent model
startup/generation.
| Check | Admission to wakeup |
| --- | ---: |
| Live inbound, AGE-6 | 409 ms |
| Signed webhook, AGE-7 | 421 ms |
| Startup catch-up, AGE-7 | 585 ms |
The clean webhook run was created at `18:10:53.471Z`, started at
`18:10:53.512Z`, sent its reply at approximately `18:11:40Z`, and finished at
`18:12:05.225Z`. Model work is separate from the sub-second admission measurement.
## Fixes found by testing
- Personal credential access displayed as organization access in the generic
connection panel. AgentMail now displays the actual saved grants and installs.
- Low-trust permissions used the wrong mutation route; Standard omitted rather
than cleared the previous boundary. Both are fixed and covered by regressions.
- Email task recovery incorrectly entered restricted chat replay. Normal email
work now uses normal task recovery while retaining execution controls.
- A send/read-only key could not register a webhook. Setup now explains the
required inbox-scoped webhook permissions. A failed switch leaves the live
connection active. The user authorized a replacement scoped key for the live
webhook test.
- Graceful shutdown retained the socket lease until its crash timeout. Shutdown
now releases only this worker's socket tokens; the ownership test verifies
immediate takeover by a second worker. The final live restart became ready at
`18:30:10Z` and completed a mail check at `18:30:14Z`, with no connection error.
- A path-like attachment filename could produce a stored object key that the
storage reader rejected. Imported filenames now remove path traversal segments.
The regression covers bounded, deduplicated intake, reading stored bytes,
task-scoped attachment references, and rejecting bytes changed after queueing.
- The initial QA agent attempted to install the released CLI for an unreleased
feature. The test agent now uses the local HTTP API. Runtime documentation also
describes the direct HTTP fallback.
- The first QA instruction to leave work open omitted a valid task disposition,
triggering existing recovery controls after a successful send. Corrected QA
instructions explicitly set the requested disposition. Clean subsequent runs
completed successfully; those earlier diagnostic tasks remain inspectable.
## Automated verification
- API/provider and durable-pipeline tests: 32 passed, including signature checks,
deduplication, callback-before-response, uncertain-send handling, inbox/company
isolation, credentials, low-trust placement, and socket ownership/shutdown.
- OpenAPI contract checks passed (8 tests); the final combined run passed all 40.
- Deterministic Playwright setup and task-conversation coverage includes actual
trust-permission persistence, rich email cards, and Bcc details. Following the
board UX revision, email controls were removed and instructions use the normal
task composer. Its provider responses are mocked; it is separate from the live
browser results above.
- Trust UI tests passed (10 tests).
- Catalog regression and damaged-runner-history recovery regression passed.
- Repository typecheck and build passed; changed-package checks were repeated
after subsequent fixes. Token gates and whitespace checks passed.
- Full repository Vitest run did **not** pass. The general server group finished
with 10,584 passing tests, five failing tests, and one database-startup suite
failure. Its five individual failures subsequently passed in focused reruns
(email recovery/trust, gallery count, plugin wait, and damaged runner history).
This broad run began before the final fixes; it is not a final green result.
- Additional broad workspace and serialized-route groups encountered database
startup, hook, and adapter timeouts. The UI group had 5,923 passing tests and
five failures; rerunning its two affected files passed all 73 tests. Shared
contracts passed 727 tests and the skills catalog passed 20. Remaining broad
groups have not been rerun to completion, so this is not a PR-ready all-green
qualification.
## Limits
The account was at its inbox limit, so live setup attached an existing inbox.
Programmatic inbox creation and custom domains were not live-qualified.
Attachment transfer, invalid signatures, cross-company denial, cancellation,
duplicate callbacks, and expired idempotency windows are checked deterministically
rather than against the live provider. Low-trust execution was not run in a real sandbox; setup correctly
rejected the isolated test drive's missing sandbox runtime.
One restart-test acknowledgement arrived in the other inbox while Paperclip's
status remained Sent because its delivery receipt was missed during socket
recovery. Sent records provider acceptance; Paperclip does not fabricate a
Delivered receipt or resend the message. The later independent outbound email
received and recorded its Delivered receipt normally.

View File

@ -0,0 +1,216 @@
# AgentMail email connections
AgentMail is an experimental **channel** connection. Enable experimental chat
connections, open Apps → AgentMail, select which humans and agents may use the
credential, then enter an API key. In the saved connections Permissions page,
choose **Give an agent an email address**. The three-step wizard selects an agent,
creates or attaches an address, and reviews the setup. Selecting an agent outside
the current allowed list adds that agent when setup completes. Every provider thread in that inbox has one Paperclip task. Subjects are
not identifiers. The same email delivered to two connected inboxes creates two
independent tasks.
Setup accepts an AgentMail API key or the saved company credential from another
AgentMail connection. Organization and pod keys create an inbox-scoped runtime
key. An existing inbox-scoped key can connect only its own inbox. Credentials
are vaulted and resolved by the server; they are not passed to agents. An inbox
can have only one non-archived Paperclip endpoint across the instance.
Verified custom domains are selectable after checking the API key. Complete DNS
setup in [AgentMail](https://docs.agentmail.to/custom-domains). Paperclip does not
register domains or manage DNS.
The setup and Permissions page warn that an unrestricted inbox can receive mail
from anyone. Configure sender allowlists in AgentMail; Paperclip does not manage
or verify them. AgentMail controls new-message and reply lists separately. The
wizard recommends Paperclips existing **Low-trust review** preset and lets the
operator configure a project or root-task boundary. Incoming tasks are placed
inside that boundary. Low-trust execution also requires isolated workspaces and an active sandbox
environment selected for the agent; setup rejects an unavailable runtime. New
inbound tasks request isolated execution. The trust preset itself does not
sandbox filesystem or network access. Standard agents remain selectable with a warning.
Removing the assigned agents saved-connection access or revoking its credential
grant stops receiving and sending. Connection creation saves the vaulted binding,
human grants, and agent access in one database transaction.
## Receiving and task lifecycle
WebSocket is the default and needs no public HTTP URL. The server authenticates
with an Authorization header, keeping the provider key out of the connection URL
([provider handshake](https://www.agentmail.to/docs/api-reference/websockets/websockets)). The service holds a
renewable database lease, subscribes to the connected inbox, and reconnects with
backoff. Webhook mode needs the configured public HTTPS webhook base URL. Setup
registers a Paperclip-owned webhook. The raw request body is verified using Svix
before the inbox is admitted to the shared durable delivery queue.
The API key needs inbox-scoped `webhook_create`, `webhook_read`, and
`webhook_delete` permissions in addition to mail access. AgentMail's
"Send & read mail" preset alone cannot register a webhook. A rejected
registration while switching from WebSocket leaves live receiving active.
Both transports deduplicate by inbox, event kind, and provider message ID. A
per-conversation worker lease serializes work; independent conversations can
proceed concurrently. Provider messages, comments, and attachment links preserve
the provider message identity. A reply to a completed task reopens it. A cancelled
task retains new mail but does not wake its agent. Provider-classified spam,
blocked and unauthenticated mail do not start automatic work. Recognized automatic
replies can be retained in an existing conversation but do not wake an agent or
create a new task.
Activation establishes the intake cutoff. Activation, reconnect, and periodic
maintenance scan paginated message metadata and fetch eligible messages using a
receipt-time checkpoint with a five-minute overlap. Metadata scans traverse all
pages because AgentMail sorts messages by the sender's timestamp: a newly
received message can have an old Date header. Message-ID deduplication makes
repeated scans safe. Earlier messages in a newly active thread are imported as
context without separate historical wakeups. There is no automatic historical
mailbox import and no assumption of WebSocket replay.
Incoming mail wakes the selected agent through its normal task execution path,
including its configured permissions and budget controls. The external sender
is recorded in the email envelope; an email address never grants Paperclip
membership or board authority.
## Explicit email actions
Internal comments, progress, final responses, approvals, and errors never send
email. Email endpoints have an explicit publication mode; shared automatic chat
publication paths exclude them. Sending email does not close a task.
The task displays the email envelope, extracted reply text, full text context,
attachments, and delivery outcomes. Use the normal task conversation to ask the
agent to send an email or reply. There is no separate email composer or mode
switch. The agent uses an explicit email action; task messages themselves are
not sent as email. Reply uses Reply-To when present, otherwise the sender;
reply-all must be requested.
Bcc is retained in the originating envelope but is not copied to reply inputs.
Remote email images are not rendered. Attachments use Paperclip's content-type,
size, company, and task bounds.
An agent must own the inbox, be assigned the source task, and supply the running
source task's `X-Paperclip-Run-Id` at acceptance. Board actions require company
write access. Configured action policies apply to both. Authority is checked
again when the durable send executes. A new conversation creates its child task
and immutable send intent in one transaction before contacting AgentMail.
All paths below are relative to `/api`:
| Operation | Path |
| --- | --- |
| Save credential and human/agent access | `POST /companies/:companyId/email/connections` |
| Inspect a saved credential | `POST /companies/:companyId/email/connections/:connectionId/inspect` |
| List authorized inboxes | `GET /companies/:companyId/email/inboxes` |
| Inspect setup credentials (connection manager) | `POST /companies/:companyId/email/inspect` |
| Create or attach an inbox (connection manager) | `POST /companies/:companyId/email/inboxes` |
| Pause, resume, disconnect | `POST /email/inboxes/:endpointId/control` |
| Replace credentials / receiving mode | `POST /email/inboxes/:endpointId/reconnect` |
| Start an email child task or reply | `POST /companies/:companyId/email/send` |
| Read the email context of a bound task | `GET /companies/:companyId/email/tasks/:issueId` |
| Read delivery outcome | `GET /companies/:companyId/email/deliveries/:publicationId` |
| Resolve an uncertain outcome (connection manager) | `POST /companies/:companyId/email/deliveries/:publicationId/resolve` |
A new send request:
```json
{
"endpointId": "<inbox-endpoint-uuid>",
"parentIssueId": "<current-task-uuid>",
"to": ["recipient@example.com"],
"cc": [],
"bcc": [],
"subject": "Question about the proposal",
"text": "Could you clarify the delivery date?",
"attachmentIds": [],
"idempotencyKey": "<new-request-uuid>"
}
```
A reply request uses `conversationId` and `replyToMessageId` from the bound task:
```json
{
"endpointId": "<inbox-endpoint-uuid>",
"conversationId": "<email-conversation-uuid>",
"replyToMessageId": "<provider-message-id>",
"replyAll": false,
"text": "Thanks, that answers the question.",
"attachmentIds": [],
"idempotencyKey": "<new-request-uuid>"
}
```
Native runners with an active, authorized inbox receive `agentmail_inboxes`,
`agentmail_read_thread`, `agentmail_send`, and `agentmail_delivery`. The system
also installs the AgentMail skill for those agents through the normal runtime
skill path. These tools supply run authority and work independently of the
optional generic runtime API rollout. Where enabled, `search_api` and `call_api`
also expose these operations. The CLI uses the same authenticated
operations and inherits the agent run ID:
```sh
paperclipai email inboxes
paperclipai email thread "$PAPERCLIP_TASK_ID"
paperclipai email send --file email-request.json
paperclipai email reply --file email-reply.json
paperclipai email delivery '<publication-uuid>'
```
A `202` response includes task, conversation, and publication IDs immediately.
The publication progresses through queued, sent, delivered, failed, or uncertain.
Delivery callbacks update that publication and do not create new correspondence.
Retries reuse the same immutable request and provider idempotency key. The worker
stops automatic retries after 23 hours, conservatively inside AgentMail's 24-hour
deduplication window. An uncertain receipt can be resolved by matching its
provider message ID and Paperclip publication header, or by an operator confirming
that it was not sent. The latter marks it failed; any resend is a new explicit
action. Do not change an idempotency key just because a request timed out.
## Disconnect and diagnostics
Reconnect preserves inbox and task identity. Pause stops intake and sending.
Disconnect archives the local endpoint and removes its credential bindings,
unreferenced vaulted credentials, and only the webhook/runtime key created by
Paperclip. It never deletes the provider inbox or task history. If a revoked key
prevents provider cleanup, local disconnection still completes and reports that
Paperclip's provider registrations need cleanup in AgentMail.
Connection settings show state, receiving mode, catch-up time and errors. Tasks
show publication failures and uncertain delivery resolution. Delivery admission,
message processing and agent wakeup are separate from provider delivery and model
startup; live latency measurements must distinguish those stages.
## Verification and live qualification
Deterministic coverage lives in `server/src/__tests__/agentmail-api.test.ts`,
`server/src/__tests__/email-channels.integration.test.ts`, and
`tests/e2e/agentmail.spec.ts`. It exercises real database transactions with a fake
provider, plus browser setup and explicit task email actions.
Before labeling an installation live-qualified, use a disposable inbox and an
approved test recipient. In each transport mode, receive a message, verify one
task and one wake, send an explicit reply, and verify provider threading and
delivery. Also disconnect/reconnect, interrupt receiving, and verify catch-up.
Record provider message IDs and timestamps without copying credentials. Compare
the durable delivery `received_at` with the wake request time separately from
provider transit time and model startup. Automated fixtures do not constitute
live provider qualification.
Provider references: [inboxes](https://docs.agentmail.to/inboxes),
[webhook verification](https://docs.agentmail.to/webhook-verification),
[idempotency](https://docs.agentmail.to/idempotency),
[message listing](https://docs.agentmail.to/api-reference/inboxes/messages/list),
[reply API](https://docs.agentmail.to/api-reference/inboxes/messages/reply).
### Sandbox execution
AgentMail runs in the Paperclip control plane using its vaulted credentials. It
is a REST connection, not a local-stdio MCP server. The connection health check
validates the key against AgentMail; it does not launch a local command or discover
MCP tools.
Agents in Daytona and other sandbox environments use the same task email actions.
The sandbox callback bridge allows inbox discovery, bound-thread reads, delivery
reads, and explicit sends. The controller enforces company, inbox, task/run, and
action-policy checks. Mailbox setup, credential inspection, reconnect, and manual
delivery resolution remain outside that sandbox API surface. Native runners use
the assigned AgentMail tools through their run-bound tool channel. Neither path exposes the
AgentMail provider key to the sandbox.

View File

@ -42,6 +42,7 @@ for the P1/P2/P3 boundary and the D7 standing rule.
- [Secret storage and lifecycle](#secret-storage-and-lifecycle)
- [Current access defaults](#current-default-access-policy)
- [Golden-path agent tutorial](#golden-path-agent-tutorial)
- [Connection UX and user journeys](#connection-ux-and-user-journeys)
- [AppDefinition field reference](#appdefinition-field-reference)
- [Troubleshooting](#troubleshooting-and-failure-classification)
- [Definition of done](#definition-of-done)
@ -425,6 +426,122 @@ connection work or enforce a real tenant boundary. Follow these rules:
label is not enforcement. The provider, gateway, wrapper, or managed header/
query projection must enforce the boundary.
#### Connector-provided skills and tools
Connectors may contribute bundled skills with optional native tools. Keep provider-specific
instructions out of the universal Paperclip skill and provider-specific tools
out of the universal runner catalog. Use the trusted connector contribution
registry in `server/src/services/connector-runtime.ts`; AgentMail is the first
consumer. This registry describes bundled server implementations, not executable
code or skill URLs supplied by a credential or external message.
For each contribution, declare its connector key, bundled skill, namespaced tool
definitions, resource-assignment resolver, and execution handler. Use names such
as `agentmail_send` rather than extending core tools with provider-specific
branches. Existing MCP connectors continue to use their normal MCP tool catalog;
they do not need a duplicate native wrapper just to supply a skill.
**Resolve eligibility from current assignments and access.** An AgentMail account
credential alone does not give an agent email capabilities. An active inbox
assigned to that agent does, provided both the inbox connection and saved
credential access remain authorized and the experimental chat-connector flag is
on. Other connectors must define an equally concrete assignment rule. Keep every
lookup company-scoped. Revoked grants, disabled connections, removed assignments,
and experimental gates must remove the contribution. Fail closed on lookup errors.
**Install skills transparently through the existing runtime skill path.** Merge
system-managed contributions with the agent's chosen skills for each run, without
writing them into its saved skill preferences. Deduplicate multiple resources
from the same connector into one skill. Include only authorized resource context,
never provider secrets; treat resource values as data. Supply the short skill
description for discovery and keep detailed instructions in the skill. The same
resolved set must reach local CLI adapters, sandbox adapters, and native runners.
Adapters with isolated skill delivery receive the bundle. Adapters that install
into shared user directories receive the same assigned skill in the run prompt,
including resumed turns, without writing connector files into that directory.
Manual skill-sync operations must also exclude automatic connector bundles.
The agent Skills page should identify automatic contributions and explain that
assignment controls them; they are not independently enabled/disabled there.
**Bind tools to the same resolved skill assignment.** Native sessions advertise
only contributions present in their pinned runtime skill bundle. Include skill
content, resource assignments, and tool revisions in session compatibility so a
changed assignment cannot reuse stale declarations. Revalidate live assignment,
company/task/run authority, and configured action policy on every execution.
Removing a tool from discovery alone is not revocation enforcement. Retained
provider sessions and previously issued calls must fail after access is revoked.
**Avoid shared runtime contamination.** Do not install assignment-specific skills
into a company-wide or user-wide runtime home. Use immutable skill bundles and
scoped runtime directories. Codex CLI connector runs use a separate home per
agent and connector-skill revision, seeded from the selected model credential
home. Disconnecting returns to a runtime without those skills; another agent must
never inherit them. Preserve explicit model identity and normal session recovery.
Required tests cover no assignment, credential access without a resource,
authorized assignment, multiple resources with one skill, cross-company access,
revocation during a retained run, disabled flags/connections, and reassignment.
Verify skill installation and removal in both CLI/sandbox and native execution,
including tool discovery, runtime cache changes, and absence of provider secrets.
Exercise an actual connector operation through the contributed tool, not just
its declaration. Record which runtime paths were tested live versus deterministically.
#### Connection UX and user journeys
Design the whole journey, from finding the app to doing useful work with an
agent. A successful credential exchange is only one step. Describe who the
user is, where they start, what they want to accomplish, and where they will
see the result. Walk through first use, returning use, and recovery from a
failed action. For messaging connections, cover both agent-initiated work and
incoming messages that start or continue work.
**Separate connecting from assigning an agent a resource.** First configure
who can use the connection and authenticate with the provider. If the feature
also assigns a resource to a specific agent, offer a second wizard from the
connection's Permissions view after the connection is saved. Give its entry
point a prominent, concrete action name. For example, AgentMail uses “Give an
agent an email address,” followed by Agent → Email address → Review. Reuse the
saved credential; do not ask for the API key again. Use the existing numbered
step pattern, sensible defaults, Back and Cancel, and a clear completion state.
Do not add a second wizard when there is no separate assignment to configure.
Let the operator search eligible company agents, including agents not yet on
the connection's allowed list. When assigning a resource also grants connection
access, make that consequence clear and persist the grant through the existing
access machinery. Respect the operator's authority to grant access, and show
the selected agent's avatar and name.
**Use the minimum text needed to make the next action clear.** Prefer familiar
controls and precise labels over explanatory paragraphs. Remove repeated
headings, redundant access summaries, implementation details, and reassurance
that does not help the user decide or act. Keep necessary warnings, meaningful
consequences, and actionable errors. Put optional expert settings under a
collapsed Advanced disclosure. Link to provider-owned administration, such as
AgentMail allowlists, rather than rebuilding it in Paperclip.
**Keep ongoing interactions in Paperclip tasks.** Connections are where users
set up access and configuration; tasks are where they work with agents. Design
what happens after setup: how an agent invokes the connection, where incoming
work lands, how follow-ups stay associated with that work, and how users see
success or recover from failure. Avoid introducing a separate mailbox or
provider dashboard as the primary interaction surface.
Use rich cards in the task feed when they make external activity easier to
understand. An email card, for example, can show the sender, recipients, body,
attachments, and delivery state. Keep external activity distinguishable from
internal discussion; a task comment or agent progress update must not imply
that an external action occurred. Reuse existing task-feed components and
preserve one visible record per external event.
**Make interactive Storybooks for setup and actual use.** Include the catalog
card, access and credential steps, any agent-resource wizard, and the task
journeys after setup. Provide a clickable walkthrough plus focused stories for
important steps, loading, errors, and recovery. Use realistic fixtures and
clearly label simulated actions. Reuse production components as implementation
lands, and replace obsolete stories so the examples describe the current
experience. Storybooks support design review and deterministic interaction
tests; they do not replace a real-provider browser test.
### Phase 4: Add official branding before exposing the app
Every store-visible provider needs an official local mark. A letter tile is
@ -689,7 +806,11 @@ At minimum, add or update tests in these layers:
declared.
- Finish setup resumes the exact draft using `resumeConnectionId`.
- Optional customer OAuth details stay folded when automatic OAuth exists.
- Setup success leads to the connection's Test page.
- Setup success leads to the connection's Test page, or to Permissions when a
separate agent-resource assignment is the next step. Follow the
[connection UX guidance](#connection-ux-and-user-journeys).
- Interactive Storybooks cover setup and ongoing task interactions, including
relevant failure states; the walkthrough matches the implemented journey.
- Missing images fall back at runtime, while manifest acceptance still fails
missing branding.
@ -1213,6 +1334,11 @@ connection actually enables it.
The wizard path comes from auth mode and transport:
These paths describe authentication and provisioning. Apply the
[connection UX guidance](#connection-ux-and-user-journeys) to the user-facing
sequence: choose access before authentication, then configure any per-agent
resource through a separate wizard on the saved connection.
| Auth mode | Operator path | Stored result |
| --- | --- | --- |
| OAuth | Gallery card -> Connect -> vendor consent -> callback -> configure filters -> health/catalog -> access defaults. | OAuth token material in `company_secrets`; connection metadata redacted. |

View File

@ -130,7 +130,6 @@ Grouped by rough domain area. One line each; variants column is props-based wher
| `ExternalObjectStatusIcon.tsx` / `ExternalObjectStatusSummary.tsx` / `ExternalObjectPill.tsx` | External-object (linked PR/doc/etc.) status glyph, rollup summary, and inline pill — a third, deliberately separate status-presentation family |
| `BlockedReasonChip.tsx` | Chip explaining why a task is blocked |
| `SourceTrustBadge.tsx` / `SourceResolvedFoldBadge.tsx` / `SourceResolvedFoldCallout.tsx` | Trust/fold badges for external content sources |
| `ProductivityReviewBadge.tsx` | Review-status badge |
**KNOWN-DUPLICATES.md lead verified:** StatusIcon / inline-mention chips / task chips are intentionally three separate systems (StatusIcon+StatusGlyph = task status glyph family; `ExternalObjectStatusIcon`/`Pill`/`Summary` = a second, external-object-specific family; mention chips in `lib/mention-chips.ts` + markdown CSS = a third, generic "chip in prose" family). **Documented here per instruction, not merged.**

View File

@ -499,6 +499,12 @@ Monitor policy lives under `executionPolicy.monitor` and includes:
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
The task's waiting banner and composer countdown also display automatic retries
while their run is `scheduled_retry`. Once a retry is `queued` or `running`, its
retained `scheduledRetryAt` is historical and must not produce a waiting or overdue
warning. A separately scheduled monitor remains visible. Completed and cancelled
tasks hide both waiting surfaces even if a stale schedule remains in the response.
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
@ -595,15 +601,16 @@ Automatic retries that can continue source work use the agent's configured model
Startup recovery and periodic recovery are different from normal wakeup delivery.
On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
On startup and on the periodic recovery loop, Paperclip performs the following recovery passes:
1. reap orphaned `running` runs
2. resume persisted `queued` runs
3. reconcile stranded assigned work
4. scan silent active runs only for source-aware terminal folding and legacy cleanup; API reads classify ordinary output silence for the board UI
5. reconcile productivity reviews
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
Automatic productivity reviews are retired. Run counts, missing comments, and elapsed task time do not create review tasks or impose continuation holds. Bounded continuation, provider recovery, budget limits, explicit blockers, and normal review/approval stages remain in force. Existing productivity-review tasks, comments, assignments, and dependencies remain unchanged and readable; their historical origins still identify them as recovery work for recursion suppression.
### Issue-thread interaction resolution
@ -777,7 +784,7 @@ Do not fold a run only because it is quiet. Keep the informational signal visibl
In the normal non-terminal case, critical silence remains a UI signal and does not block the source issue. In the source-resolved case, a completed source issue does not acquire a new review or blocker merely because an old run handle stayed active. Only real unresolved work should block work.
This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
Source-resolved watchdog folding concerns stale active-run bookkeeping after a valid terminal disposition. It does not infer productivity from run counts, comment frequency, or elapsed task time.
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
@ -831,7 +838,7 @@ Shutdown, process loss, and provider failure use the existing durable failure re
Real gates still apply: company and task ownership, active provider ownership, budget limits, agent availability, dependencies, pending approval/review paths, and explicit pause holds. Native runner reattachment and finalization retain their existing ownership protocol. Process, HTTP, and gateway adapters retain their recovery rules because invoking those adapters can itself repeat an external action rather than start a conversation turn.
An operator Stop still waits for local provider termination. Stop alone never promotes deferred comments or starts an automatic continuation. Once stopped, the next explicit wake adopts pending comment IDs in order through the existing queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
An operator Stop waits for provider termination. Remote sandbox providers may return a stopped/deleted receipt after their control-plane operation completes. Paperclip binds that receipt to the company, run, and exact lease; successful file cleanup, a terminal run row, or an in-sandbox shutdown event is not sufficient. Legacy conversational runs receive their cancellation acknowledgement after all remote leases have confirmed termination. Stop alone never creates a continuation. A user message queued during remote cleanup is reconsidered when the provider confirms termination; it still passes normal admission and adopts pending comment IDs in order. Once stopped, the next explicit wake uses the same queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
Historical legacy interruption holds for conversational adapters no longer block new messages or Resume. Classification uses the runs saved adapter invocation or continuation policy, never the agents current adapter settings. Missing historical adapter evidence retains the hold. A terminal row with a live predecessor process or unreleased environment lease still blocks actual admission and Resume. Retry scheduling can happen before cleanup, but grants no execution authority. Recovery folds their obsolete no-replay bookkeeping without changing task ownership, status, or automatically waking old work. The audit trail remains readable. Native integrity and ownership holds, and non-conversational adapter holds, remain enforced.
@ -878,9 +885,13 @@ request, task history, completed work, and the interruption notice. It receives
no instruction to repeat old tool calls. Later messages cannot reset the old
incident's retry budget or create another automatic replacement for it.
The initial native admission path verifies local process identities. Missing
process identity or remote ownership without a target-aware stop proof remains a
hold; a terminal database status or a PID check on the wrong host is insufficient.
Native admission verifies local process identities for local runs. Remote runs
instead require a provider termination receipt for every lease, with successful
cleanup and no active ownership. This applies to both per-turn and warm native
runners. A stop receipt retires only the settled cleanup owner for that exact company, run, provider, and sandbox resource, without changing its checkpoint or recorded action outcomes. Independent remote sandboxes have separate cleanup gates, including when one run owns multiple sandboxes. Successful pending-cleanup retries persist the same receipt and reconsider deferred user messages; a delivery failure never reverts successful provider cleanup. A failed checkpoint does not prevent destruction of a terminal run's isolated sandbox; busy ownership still prevents it.
Missing receipts and failed cleanup retain the hold. Older providers that return
no receipt remain supported but cannot authorize remote continuation. A terminal
database status or a PID check on the wrong host is insufficient.
No historical task is automatically awakened by this change.
### Explicit Recovery Action

View File

@ -1548,6 +1548,30 @@ describe("shared ACPX engine runtime behavior", () => {
expect(env.PAPERCLIP_CLOUD_PROVIDER_TOKEN).toBe("cloud-token");
});
it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)(
"selects Codex ACP API-key authentication when %s is configured",
async (apiKeyName) => {
const root = await makeTempRoot();
const codexHome = path.join(root, "codex-home");
await fs.mkdir(codexHome, { recursive: true });
const { sessionInputs } = await runExecutor({
agent: "codex",
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: codexHome,
[apiKeyName]: "sk-acp-test-key",
},
paperclipRuntimeSkills: [],
paperclipSkillSync: { desiredSkills: [] },
});
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
expect(env[apiKeyName]).toBe("sk-acp-test-key");
expect(env.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
},
);
it("busts the session fingerprint when resolved adapter env changes but not across wakes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@ -4854,13 +4878,13 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
const { traceContext, spans } = createRecordingStartupTrace();
// A codex bring-up runs the codex-home.seed step, which nests skills.reconcile.
const { events } = await runExecutor(
const { events, sessionInputs } = await runExecutor(
{
agent: "codex",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
env: { CODEX_HOME: codexHome },
env: { CODEX_HOME: codexHome, OPENAI_API_KEY: "sk-acp-test-key" },
},
{ authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext },
);
@ -5426,13 +5450,13 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
runner: createLocalSandboxRunner(),
};
const { events } = await runExecutor(
const { events, sessionInputs } = await runExecutor(
{
agent: "codex",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
env: { CODEX_HOME: codexHome },
env: { CODEX_HOME: codexHome, OPENAI_API_KEY: "sk-acp-test-key" },
},
{ authToken: "real-run-jwt", executionTarget },
);
@ -5454,6 +5478,9 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
expect(typeof event!.payload?.durationMs).toBe("number");
expect(event!.payload?.durationMs as number).toBeGreaterThanOrEqual(0);
}
const sessionEnv = (sessionInputs[0]?.sessionOptions as { env: Record<string, string> }).env;
expect(sessionEnv.OPENAI_API_KEY).toBe("sk-acp-test-key");
expect(sessionEnv.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
});
it("emits the 5 non-codex boundaries for a custom-agent sandbox bring-up (no codex steps)", async () => {

View File

@ -1967,6 +1967,17 @@ async function buildRuntime(input: {
// are absent from tempKeysApplied and keep their compatibility protection.
if (!scratchKeys.has(key) || value !== scratch.dir) resolvedAdapterEnv[key] = value;
}
// codex-acp supports both key names, but ACP clients must select its
// api-key authentication method during session creation. Without this
// request, the server advertises authentication and rejects session/new even
// though the credential is present in the launched process environment.
if (
acpxAgent === "codex" &&
(env.OPENAI_API_KEY || env.CODEX_API_KEY) &&
!env.DEFAULT_AUTH_REQUEST
) {
env.DEFAULT_AUTH_REQUEST = JSON.stringify({ methodId: "api-key" });
}
if (authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler

View File

@ -157,6 +157,27 @@ describe("command managed runtime", () => {
}
});
it("reports a missing sandbox file as ENOENT without masking command failures", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-missing-"));
try {
const { runner } = makeSpawnRunner();
const client = createCommandManagedRuntimeClient({ runner, commandCwd: root, timeoutMs: 5000 });
const missingPath = path.join(root, "auth.json");
await expect(client.readFile(missingPath)).rejects.toMatchObject({ code: "ENOENT", path: missingPath });
await writeFile(missingPath, "present");
const failedClient = createCommandManagedRuntimeClient({
commandCwd: root, timeoutMs: 5000,
runner: { ...runner, execute: async (input) => input.args?.some((arg) => arg.startsWith("wc -c"))
? { exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "transport read failed", pid: null, startedAt: new Date().toISOString() }
: runner.execute(input) },
});
await expect(failedClient.readFile(missingPath)).rejects.toThrow("transport read failed");
await expect(client.readFile(missingPath)).resolves.toEqual(Buffer.from("present"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("keeps the runtime overlay out of sandbox workspace sync by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-"));
cleanupDirs.push(rootDir);

View File

@ -338,7 +338,26 @@ export function createCommandManagedRuntimeClient(input: {
// Chunked reads intentionally query the remote size first, even without
// a progress sink, so each sandbox RPC stays bounded and truncation is
// detected without materializing the whole file as one stdout string.
const sizeResult = await runShell(`wc -c < ${shellQuote(remotePath)}`);
let sizeResult;
try {
sizeResult = await runShell(`wc -c < ${shellQuote(remotePath)}`);
} catch (error) {
// Shell-backed sandbox reads need the same absent-file contract as fs.
// Confirm the parent is searchable so permission/transport failures are
// never silently converted into a missing optional credential file.
const parent = shellQuote(path.posix.dirname(remotePath));
const missing = await runShell(
`if [ -d ${parent} ] && [ -x ${parent} ] && [ ! -e ${shellQuote(remotePath)} ]; ` +
`then printf 'missing'; fi`,
).catch(() => null);
if (missing?.stdout === "missing") {
throw Object.assign(new Error(`No such file: ${remotePath}`), {
code: "ENOENT",
path: remotePath,
});
}
throw error;
}
const totalBytes = Number.parseInt(sizeResult.stdout.trim(), 10);
if (!Number.isFinite(totalBytes) || totalBytes < 0) {
throw new Error(`Could not determine remote file size for ${remotePath}`);

View File

@ -1356,6 +1356,10 @@ describe("sandbox callback bridge", () => {
{ method: "GET", path: "/api/companies/co-1/approvals" },
{ method: "GET", path: "/api/companies/co-1/routines" },
{ method: "GET", path: "/api/companies/co-1/skills" },
{ method: "GET", path: "/api/companies/co-1/email/inboxes" },
{ method: "GET", path: "/api/companies/co-1/email/tasks/issue-1" },
{ method: "GET", path: "/api/companies/co-1/email/deliveries/send-1" },
{ method: "POST", path: "/api/companies/co-1/email/send" },
// Hire skill (paperclip-create-agent): discovery + submit + issue linking
{ method: "GET", path: "/llms/agent-configuration.txt" },
{ method: "GET", path: "/llms/agent-configuration/claude_local.txt" },
@ -1413,6 +1417,13 @@ describe("sandbox callback bridge", () => {
}
const denied: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/companies/co-1/email/inboxes" },
{ method: "POST", path: "/api/companies/co-1/email/connections" },
{ method: "POST", path: "/api/companies/co-1/email/inspect" },
{ method: "POST", path: "/api/companies/co-1/email/deliveries/send-1/resolve" },
{ method: "POST", path: "/api/email/inboxes/inbox-1/reconnect" },
{ method: "POST", path: "/api/email/inboxes/inbox-1/control" },
{ method: "DELETE", path: "/api/companies/co-1/email/tasks/issue-1" },
{ method: "DELETE", path: "/api/secrets" },
// Pin the runtime-services regex to start/stop/restart only — anything
// else (delete, reset, wipe, etc.) must stay denied even if the API

View File

@ -139,6 +139,13 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
// Task-bound email actions. Company, inbox ownership, task/run authority,
// and action policies are enforced by the controller; mailbox setup stays denied.
{ method: "GET", path: /^\/api\/companies\/[^/]+\/email\/inboxes$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/email\/tasks\/[^/]+$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/email\/deliveries\/[^/]+$/ },
{ method: "POST", path: /^\/api\/companies\/[^/]+\/email\/send$/ },
// Issue lifecycle: read context, checkout, update, comment, document, release
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },

View File

@ -6,6 +6,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { CONNECTION_INTENT_AGENT_GUIDANCE } from "@paperclipai/shared";
import {
readPaperclipRuntimeSkillEntries,
applyPaperclipWorkspaceEnv,
appendWithByteCap,
buildPersistentSkillSnapshot,
@ -3753,3 +3754,19 @@ describe("buildPaperclipEnv", () => {
);
});
});
describe("runtime skill assignment boundaries", () => {
it("preserves an explicitly empty assignment instead of discovering bundled connector skills", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-empty-"));
try {
await fs.mkdir(path.join(root, "agentmail"));
await fs.writeFile(path.join(root, "agentmail", "SKILL.md"), "---\nname: agentmail\ndescription: Email connector\n---\n");
const discovered = await readPaperclipRuntimeSkillEntries({}, root, [root]);
expect(discovered.some((entry) => entry.runtimeName === "agentmail")).toBe(true);
expect(await readPaperclipRuntimeSkillEntries({ paperclipRuntimeSkills: [] }, root, [root])).toEqual([]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@ -2161,7 +2161,21 @@ export function selectPaperclipTaskMarkdown(
return compact || full;
}
// Runtime-only connector skills are supplied by the server after assignment resolution.
// Shared-home adapters consume them here on fresh and resumed runs without installing
// files into a user-wide skills directory. They are not part of serialized wake data.
export function renderPaperclipWakePrompt(
value: unknown,
options: Parameters<typeof renderPaperclipWakePromptBody>[1] = {},
): string {
const instructions = asString(parseObject(value).connectorSkillInstructions, "").trim();
return joinPromptSections([
renderPaperclipWakePromptBody(value, options),
instructions ? `## Assigned connector skills\n\n${instructions}` : "",
]);
}
function renderPaperclipWakePromptBody(
value: unknown,
options: {
resumedSession?: boolean;
@ -3950,7 +3964,8 @@ export async function readPaperclipRuntimeSkillEntries(
const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(
config.paperclipRuntimeSkills,
);
if (configuredEntries.length > 0) return configuredEntries;
// An explicit empty assignment must not fall back to every bundled skill.
if (Array.isArray(config.paperclipRuntimeSkills)) return configuredEntries;
return listPaperclipSkillEntries(moduleDir, additionalCandidates);
}

View File

@ -631,10 +631,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const envConfig = parseObject(config.env);
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const configuredCodexHome =
let configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
const connectorSourceHome = configuredCodexHome;
const connectorSkillDigest = typeof config.paperclipConnectorSkillDigest === "string"
&& /^[a-f0-9]{64}$/.test(config.paperclipConnectorSkillDigest) ? config.paperclipConnectorSkillDigest : null;
if (connectorSkillDigest) {
// Never mount assignment-specific skills into the shared company/user home.
// A different skill revision gets a new home, so revoked/changed resources
// cannot survive as stale symlinks or bleed into another agent's session.
configuredCodexHome = path.join(resolveManagedCodexHomeDir(process.env, agent.companyId),
"connector-runtimes", agent.id, connectorSkillDigest);
}
const codexSkillEntries = (await readPaperclipRuntimeSkillEntries(config, __moduleDir))
// A missing-source entry would become a dangling skill symlink; skip it.
.filter((entry) => !isPaperclipSkillSourceMissing(entry));
@ -680,12 +690,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
void error;
});
}
if (configuredCodexHome == null) {
if (configuredCodexHome == null || (connectorSkillDigest && connectorSourceHome == null)) {
await prepareManagedCodexHome(process.env, onLog, agent.companyId, {
apiKey: configuredOpenAiApiKey,
});
} else if (configuredHomeIsManaged) {
await seedManagedCodexHome(configuredCodexHome, process.env, onLog, {
}
if (configuredHomeIsManaged && configuredCodexHome) {
const seedEnv = connectorSkillDigest ? {
...process.env, CODEX_HOME: connectorSourceHome ?? resolveManagedCodexHomeDir(process.env, agent.companyId),
} : process.env;
await seedManagedCodexHome(configuredCodexHome, seedEnv, onLog, {
apiKey: configuredOpenAiApiKey,
});
}

View File

@ -0,0 +1,98 @@
-- Safe to replay after an interrupted or previously applied development migration.
CREATE TABLE IF NOT EXISTS "email_endpoints" (
"endpoint_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"receive_mode" text NOT NULL,
"webhook_id" text,
"owned_api_key_id" text,
"activation_at" timestamp with time zone,
"sync_checkpoint" timestamp with time zone,
"last_sync_at" timestamp with time zone,
CONSTRAINT "email_endpoints_receive_mode_check" CHECK ("email_endpoints"."receive_mode" in ('websocket', 'webhook'))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "email_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"conversation_id" uuid NOT NULL,
"provider_message_id" text NOT NULL,
"envelope" jsonb NOT NULL,
"text" text NOT NULL,
"full_text" text DEFAULT '' NOT NULL,
"direction" text NOT NULL,
"automatic" boolean DEFAULT false NOT NULL,
"attachment_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"timestamp" timestamp with time zone NOT NULL,
CONSTRAINT "email_messages_direction_check" CHECK ("email_messages"."direction" in ('inbound', 'outbound'))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "email_sends" (
"publication_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"request" jsonb NOT NULL,
"actor" jsonb NOT NULL,
"digest" text NOT NULL,
"outcome" text DEFAULT 'queued' NOT NULL,
"first_attempt_at" timestamp with time zone,
CONSTRAINT "email_sends_outcome_check" CHECK ("email_sends"."outcome" in ('queued', 'sent', 'delivered', 'failed', 'uncertain'))
);
--> statement-breakpoint
ALTER TABLE "chat_endpoints" DROP CONSTRAINT IF EXISTS "chat_endpoints_provider_check";--> statement-breakpoint
ALTER TABLE "chat_external_principals" DROP CONSTRAINT IF EXISTS "chat_external_principals_provider_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_channel_transport_check";--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "publication_mode" text DEFAULT 'automatic' NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "external_execution_policy" text DEFAULT 'restricted' NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_endpoints" ADD CONSTRAINT "email_endpoints_company_id_endpoint_id_chat_endpoints_company_id_id_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_company_id_endpoint_id_chat_endpoints_company_id_id_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_company_id_conversation_id_chat_conversations_company_id_id_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_sends" ADD CONSTRAINT "email_sends_company_id_endpoint_id_chat_endpoints_company_id_id_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "email_sends" ADD CONSTRAINT "email_sends_company_id_publication_id_chat_publications_company_id_id_fk" FOREIGN KEY ("company_id","publication_id") REFERENCES "public"."chat_publications"("company_id","id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "email_messages_provider_uq" ON "email_messages" USING btree ("endpoint_id","provider_message_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "email_messages_conversation_idx" ON "email_messages" USING btree ("company_id","conversation_id","timestamp");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "email_sends_pending_idx" ON "email_sends" USING btree ("endpoint_id","outcome") WHERE "email_sends"."outcome" in ('queued', 'uncertain');--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "chat_endpoints_agentmail_inbox_uq" ON "chat_endpoints" USING btree ("bot_external_id") WHERE "chat_endpoints"."provider" = 'agentmail' and "chat_endpoints"."status" != 'archived' and "chat_endpoints"."bot_external_id" is not null;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_publication_mode_check" CHECK ("chat_endpoints"."publication_mode" in ('automatic', 'explicit'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_execution_policy_check" CHECK ("chat_endpoints"."external_execution_policy" in ('restricted', 'agent'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_email_policy_check" CHECK ("chat_endpoints"."provider" <> 'agentmail' or ("chat_endpoints"."publication_mode" = 'explicit' and "chat_endpoints"."external_execution_policy" = 'agent'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_provider_check" CHECK ("chat_endpoints"."provider" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "chat_external_principals" ADD CONSTRAINT "chat_external_principals_provider_check" CHECK ("chat_external_principals"."provider" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_channel_transport_check" CHECK ((
("tool_connections"."connection_purpose" = 'tool' and "tool_connections"."transport" <> 'chat_sdk')
or
("tool_connections"."connection_purpose" = 'channel' and ("tool_connections"."transport" = 'chat_sdk' or ("tool_connections"."transport" = 'rest_api' and "tool_connections"."config"->>'provider' = 'agentmail')))
));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1895,8 +1895,15 @@
{
"idx": 272,
"version": "7",
"when": 1789153813732,
"tag": "0272_naive_the_watchers",
"when": 1789137216452,
"tag": "0272_light_kate_bishop",
"breakpoints": true
},
{
"idx": 273,
"version": "7",
"when": 1789164867037,
"tag": "0273_sandbox_work_folders",
"breakpoints": true
}
]

View File

@ -44,6 +44,8 @@ export const chatEndpoints = pgTable(
connectionId: uuid("connection_id").notNull(),
provider: text("provider").$type<ChatProvider>().notNull(),
publicId: text("public_id").notNull(),
publicationMode: text("publication_mode").$type<"automatic" | "explicit">().notNull().default("automatic"),
externalExecutionPolicy: text("external_execution_policy").$type<"restricted" | "agent">().notNull().default("restricted"),
assignedAgentId: uuid("assigned_agent_id")
.notNull()
.references(() => agents.id, { onDelete: "restrict" }),
@ -109,9 +111,12 @@ export const chatEndpoints = pgTable(
.defaultNow(),
},
(table) => [
check("chat_endpoints_publication_mode_check", sql`${table.publicationMode} in ('automatic', 'explicit')`),
check("chat_endpoints_execution_policy_check", sql`${table.externalExecutionPolicy} in ('restricted', 'agent')`),
check("chat_endpoints_email_policy_check", sql`${table.provider} <> 'agentmail' or (${table.publicationMode} = 'explicit' and ${table.externalExecutionPolicy} = 'agent')`),
check(
"chat_endpoints_provider_check",
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram')`,
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail')`,
),
check(
"chat_endpoints_status_check",
@ -132,6 +137,9 @@ export const chatEndpoints = pgTable(
),
index("chat_endpoints_status_idx").on(table.companyId, table.status),
uniqueIndex("chat_endpoints_public_id_uq").on(table.publicId),
uniqueIndex("chat_endpoints_agentmail_inbox_uq")
.on(table.botExternalId)
.where(sql`${table.provider} = 'agentmail' and ${table.status} != 'archived' and ${table.botExternalId} is not null`),
uniqueIndex("chat_endpoints_connection_uq").on(table.connectionId),
// A native provider identity can back only one live Paperclip endpoint.
// Historical archived/revoked endpoints retain attribution without
@ -270,7 +278,7 @@ export const chatExternalPrincipals = pgTable(
(table) => [
check(
"chat_external_principals_provider_check",
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram')`,
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail')`,
),
check(
"chat_external_principals_kind_check",

View File

@ -0,0 +1,135 @@
import { sql } from "drizzle-orm";
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
boolean,
foreignKey,
uniqueIndex,
index,
check,
} from "drizzle-orm/pg-core";
import type {
EmailEnvelope,
EmailDeliveryOutcome,
EmailSendInput,
} from "@paperclipai/shared";
import {
chatEndpoints,
chatConversations,
chatPublications,
} from "./chat_channels.js";
/** Email-specific state; conversations, delivery queues and send outboxes remain shared. */
export const emailEndpoints = pgTable(
"email_endpoints",
{
endpointId: uuid("endpoint_id").primaryKey(),
companyId: uuid("company_id").notNull(),
receiveMode: text("receive_mode")
.$type<"websocket" | "webhook">()
.notNull(),
webhookId: text("webhook_id"),
ownedApiKeyId: text("owned_api_key_id"),
activationAt: timestamp("activation_at", { withTimezone: true }),
syncCheckpoint: timestamp("sync_checkpoint", { withTimezone: true }),
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
},
(t) => [
check(
"email_endpoints_receive_mode_check",
sql`${t.receiveMode} in ('websocket', 'webhook')`,
),
foreignKey({
columns: [t.companyId, t.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
}).onDelete("cascade"),
],
);
export const emailMessages = pgTable(
"email_messages",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull(),
endpointId: uuid("endpoint_id").notNull(),
conversationId: uuid("conversation_id").notNull(),
providerMessageId: text("provider_message_id").notNull(),
envelope: jsonb("envelope").$type<EmailEnvelope>().notNull(),
text: text("text").notNull(),
fullText: text("full_text").notNull().default(""),
direction: text("direction").$type<"inbound" | "outbound">().notNull(),
automatic: boolean("automatic").notNull().default(false),
attachmentIds: jsonb("attachment_ids")
.$type<string[]>()
.notNull()
.default([]),
timestamp: timestamp("timestamp", { withTimezone: true }).notNull(),
},
(t) => [
check(
"email_messages_direction_check",
sql`${t.direction} in ('inbound', 'outbound')`,
),
uniqueIndex("email_messages_provider_uq").on(
t.endpointId,
t.providerMessageId,
),
index("email_messages_conversation_idx").on(
t.companyId,
t.conversationId,
t.timestamp,
),
foreignKey({
columns: [t.companyId, t.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
}).onDelete("cascade"),
foreignKey({
columns: [t.companyId, t.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
}).onDelete("cascade"),
],
);
export const emailSends = pgTable(
"email_sends",
{
publicationId: uuid("publication_id").primaryKey(),
companyId: uuid("company_id").notNull(),
endpointId: uuid("endpoint_id").notNull(),
request: jsonb("request").$type<EmailSendInput>().notNull(),
actor: jsonb("actor")
.$type<{
userId?: string;
agentId?: string;
runId?: string;
localImplicit?: boolean;
}>()
.notNull(),
digest: text("digest").notNull(),
outcome: text("outcome")
.$type<EmailDeliveryOutcome>()
.notNull()
.default("queued"),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true }),
},
(t) => [
foreignKey({
columns: [t.companyId, t.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
}).onDelete("cascade"),
foreignKey({
columns: [t.companyId, t.publicationId],
foreignColumns: [chatPublications.companyId, chatPublications.id],
}).onDelete("cascade"),
check(
"email_sends_outcome_check",
sql`${t.outcome} in ('queued', 'sent', 'delivered', 'failed', 'uncertain')`,
),
index("email_sends_pending_idx")
.on(t.endpointId, t.outcome)
.where(sql`${t.outcome} in ('queued', 'uncertain')`),
],
);

View File

@ -205,3 +205,5 @@ export { toolActionDeliveries } from "./tool_action_deliveries.js";
export { chatTeamsFileTransfers } from "./chat_teams_file_transfers.js";
export { chatDiscordCommandOwners } from "./chat_discord_command_owners.js";
export { chatTelegramDraftIds } from "./chat_telegram_draft_ids.js";
export * from "./email.js";

View File

@ -151,7 +151,7 @@ export const toolConnections = pgTable(
check("tool_connections_channel_transport_check", sql`(
(${table.connectionPurpose} = 'tool' and ${table.transport} <> 'chat_sdk')
or
(${table.connectionPurpose} = 'channel' and ${table.transport} = 'chat_sdk')
(${table.connectionPurpose} = 'channel' and (${table.transport} = 'chat_sdk' or (${table.transport} = 'rest_api' and ${table.config}->>'provider' = 'agentmail')))
)`),
check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`),
check("tool_connections_credential_source_check", sql`${table.credentialSource} in ('paperclip_vault', 'vercel_connect')`),

View File

@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import postgres from "postgres";
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const migration = readFileSync(new URL("./migrations/0272_naive_the_watchers.sql", import.meta.url), "utf8");
const migration = readFileSync(new URL("./migrations/0273_sandbox_work_folders.sql", import.meta.url), "utf8");
(support.supported ? describe : describe.skip)("work folder preview migration", () => {
it("preserves cached content, trash, and unpushed repository checkpoints on replay", async () => {
@ -46,4 +47,43 @@ const migration = readFileSync(new URL("./migrations/0272_naive_the_watchers.sql
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
it("applies an earlier mainline migration after a renamed preview without losing files", async () => {
const database = await startEmbeddedPostgresTestDatabase("work-folder-renumber-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const company = randomUUID(), task = randomUUID(), folder = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Preview upgrade', 'PVU')`;
await sql`INSERT INTO issues (id, company_id, title) VALUES (${task}, ${company}, 'Existing task')`;
await sql`INSERT INTO work_folders (id, company_id, scope, owner_id) VALUES (${folder}, ${company}, 'task', ${task})`;
await sql`INSERT INTO work_files (company_id, folder_id, path, object_key, executable)
VALUES (${company}, ${folder}, 'saved.sh', 'preview/saved', true)`;
await sql`INSERT INTO task_repository_bindings (company_id, task_id, workspace_id, name, checkpoint_key)
VALUES (${company}, ${task}, ${randomUUID()}, 'repo', 'preview/unpushed')`;
const mainline = readFileSync(new URL("./migrations/0272_light_kate_bishop.sql", import.meta.url), "utf8");
const mainlineHash = createHash("sha256").update(mainline).digest("hex");
const previewHash = createHash("sha256").update(migration).digest("hex");
// Model a preview that already recorded its work-folder migration with a
// timestamp newer than the subsequently merged mainline migration.
await sql`DROP TABLE email_sends, email_messages, email_endpoints`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${mainlineHash}`;
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = 1789153813732 WHERE hash = ${previewHash}`;
const before = await inspectMigrations(database.connectionString);
expect(before.status).toBe("needsMigrations");
await applyPendingMigrations(database.connectionString);
await applyPendingMigrations(database.connectionString);
expect((await inspectMigrations(database.connectionString)).status).toBe("upToDate");
expect(await sql`SELECT to_regclass('public.email_messages') AS table_name`)
.toMatchObject([{ table_name: "email_messages" }]);
expect(await sql`SELECT path, object_key, executable FROM work_files WHERE folder_id = ${folder}`)
.toMatchObject([{ path: "saved.sh", object_key: "preview/saved", executable: true }]);
expect(await sql`SELECT checkpoint_key FROM task_repository_bindings WHERE task_id = ${task}`)
.toMatchObject([{ checkpoint_key: "preview/unpushed" }]);
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${mainlineHash}`).toHaveLength(1);
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${previewHash}`).toHaveLength(1);
} finally {
await sql.end();
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
});

View File

@ -12,6 +12,51 @@ WebSocket acceptance header are not authentication. A production bridge should
still use `wss://` for defense in depth and remains a separately reviewed
deployment phase.
## Execution duration and operation deadlines
Native turns have no total elapsed-time limit unless the operator configures
`timeoutSec` on the agent. Zero means unlimited. The controller passes that
setting separately from bootstrap, recovery, checkpoint, and finalization
operation bounds. A live turn must not inherit the internal 15-minute operation
deadline; tool waits count toward an explicitly configured turn duration.
The native-session API exposes `turnTimeoutMs` for that duration. An explicit
`timeoutMs` retains its prior operation/turn behavior for existing embedding
callers, while `turnTimeoutMs: 0` overrides its turn bound. Cancellation, budget
controls, input handoffs, and bounded cleanup remain independent.
Normal runner launches also set `--max-runtime-ms 0`, meaning no total process
lifetime limit. The standalone durable runner defaults to the same value.
Explicit positive limits still stop at their deadline; connection/auth attempts,
reconnect grace, cancellation, and idle cleanup remain bounded independently.
Neither quiet tool execution nor a productive turn is an idle session.
The authenticated welcome advertises `connectionLeaseRenewalVersion: 1`.
Supporting runners send `lease_renew` halfway through the remaining lease,
independently of provider output. The request carries the current expiry and
revocation epoch under the exact connection, lease, and run identity. The
controller persists an extended expiry before returning `lease_renewed`, which
also echoes the request's previous expiry. The runner updates its in-memory
lease without replacing its process, provider, thread, turn, token, or epoch.
Retries of the same observed expiry replay the persisted extension. Renewal
never admits an expired, revoked, or differently bound credential.
If a reply is lost, reconnect authentication may reconcile a later expiry only
when this runner has an outstanding renewal on that same credential. Warm
handoff receipts continue to bind exact expiry and renewal pauses during their
transition. Old controllers that do not advertise renewal retain their bounded
lease behavior; deploying both updated controller and runner is required.
Tests simulate three weeks of renewal on one authenticated connection, exercise
lost-reply reconnect without reexecuting provider startup, and keep a quiet
active Codex fixture in the same runner/provider PIDs beyond its original lease
expiry. These are boundary regressions, not a weeks-long real-provider soak.
Provider startup ownership summaries validate goal commands with their required
PRP v2 command vocabulary. Incoming wire commands still undergo the negotiated
protocol validation before execution; ordinary persisted v1 summaries remain
compatible.
## Connection and authentication
The connection starts in this order:

View File

@ -193,6 +193,13 @@ These envelopes are local Local runner implementation contracts.
- A one-use bootstrap bearer capability returns a short-lived connection lease
in `welcome`. Later connections use that lease. Neither raw capability is
durable state.
- `welcome.payload.connectionLeaseRenewalVersion: 1` opts into authenticated
`lease_renew` / `lease_renewed` control frames. Renewal extends the persisted
expiry on the same live authority without restarting provider work. Identity,
protocol, and revocation epoch remain fixed; expired or revoked leases cannot
renew. See [durable recovery](durable-recovery.md#execution-duration-and-operation-deadlines)
for retry and warm-handoff rules. Peers lacking this capability retain their
original lease expiry.
- `hello.resume` reports the last processed controller sequence, next source
sequence, cumulative ACK cursor, and current unacknowledged range.
- `welcome` selects the one overlapping protocol version, returns the core's

View File

@ -125,8 +125,8 @@
"report:runner-protocol-eval:catalog": "node scripts/runner-protocol-eval-campaign.mjs catalog",
"report:runner-protocol-eval:publish": "node scripts/publish-runner-protocol-eval-history.mjs",
"report:runner-chaos-evals": "pnpm run build:typescript && node scripts/run-runner-live-eval-schedule.mjs --mode chaos",
"check:conformance-parity": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core runs_the_mock_core_path_with_stable_output",
"check:replay-parity": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core replay_fixture_parity",
"check:conformance-parity": "cargo test --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core runs_the_mock_core_path_with_stable_output",
"check:replay-parity": "cargo test --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core replay_fixture_parity",
"docs:validate": "node scripts/validate-doc-links.mjs",
"trace:conformance": "pnpm run trace:conformance:rust",
"trace:conformance:rust": "cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin conformance-tracer",

View File

@ -330,7 +330,7 @@ fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> {
max_frame_bytes: usize_value(args, "--max-frame-bytes", 1024 * 1024)?,
reconnect_delay: duration("--reconnect-delay-ms", 250)?,
reconnect_grace: optional_u64(args, "--reconnect-grace-ms")?.map(Duration::from_millis),
max_runtime: duration("--max-runtime-ms", 60 * 60 * 1000)?,
max_runtime: duration("--max-runtime-ms", 0)?,
};
let executor = NativeProviderCommandExecutor::with_runner_config(state_dir, &config);
run_durable_runner(config, ticket, executor)

View File

@ -194,6 +194,7 @@ pub struct DurableRunnerConfig {
pub max_frame_bytes: usize,
pub reconnect_delay: Duration,
pub reconnect_grace: Option<Duration>,
/// Zero disables the total process lifetime limit.
pub max_runtime: Duration,
}
@ -245,11 +246,6 @@ impl DurableRunnerConfig {
"transport frame limit must be between 1 KiB and 16 MiB",
));
}
if self.max_runtime.is_zero() {
return Err(DurableRunnerError::invalid(
"durable runner max runtime must be non-zero",
));
}
if self.reconnect_delay.is_zero() || self.reconnect_delay > Duration::from_secs(60) {
return Err(DurableRunnerError::invalid(
"reconnect delay must be between one millisecond and 60 seconds",
@ -260,11 +256,6 @@ impl DurableRunnerConfig {
"reconnect grace must be non-zero when configured",
));
}
if self.max_runtime > Duration::from_secs(7 * 24 * 60 * 60) {
return Err(DurableRunnerError::invalid(
"durable runner max runtime must not exceed seven days",
));
}
if let Some(profile) = self.acpx_launch_profile.as_ref() {
if profile.authority_digest.len() != 71
|| !profile.authority_digest.starts_with("sha256:")

View File

@ -77,18 +77,20 @@ fn connection_attempt_deadline(
disconnected_since: Option<Instant>,
) -> Instant {
let now = Instant::now();
let runtime_remaining = config
.max_runtime
.saturating_sub(now.saturating_duration_since(started));
let remaining = disconnected_since.zip(config.reconnect_grace).map_or(
runtime_remaining,
|(disconnected_at, grace)| {
runtime_remaining
.min(grace.saturating_sub(now.saturating_duration_since(disconnected_at)))
},
);
// Validation caps max_runtime at seven days, and reconnect grace can only
// shorten this budget, so adding it to a current Instant cannot overflow.
// Bound each connection/auth attempt independently of a productive
// session's lifetime. Zero means there is no total runtime deadline.
let mut remaining = Duration::from_secs(30);
if !config.max_runtime.is_zero() {
remaining = remaining.min(
config
.max_runtime
.saturating_sub(now.saturating_duration_since(started)),
);
}
if let Some((disconnected_at, grace)) = disconnected_since.zip(config.reconnect_grace) {
remaining =
remaining.min(grace.saturating_sub(now.saturating_duration_since(disconnected_at)));
}
now + remaining
}
@ -417,7 +419,7 @@ pub fn run_durable_runner<E: CommandExecutor>(
));
}
}
if started.elapsed() >= config.max_runtime {
if !config.max_runtime.is_zero() && started.elapsed() >= config.max_runtime {
let _ = shutdown_preserving_cleanup(&state, &mut executor);
record_recoverable_transport_failure(
&mut state,
@ -514,7 +516,16 @@ pub fn run_durable_runner<E: CommandExecutor>(
state.restore_v2_replay_events(&config)?;
}
state.last_connection_protocol_version = Some(protocol_version);
let connection = welcome.connection;
let mut connection = welcome.connection;
// Reconnect may follow a durably committed renewal whose reply was
// lost. Authentication admits an increased expiry only while our
// matching renewal is outstanding; identity and epoch stay exact.
if let Some(credential) = lease.as_mut() {
credential.expires_at_unix_ms = connection.expires_at_unix_ms;
credential.renewal_requested = false;
}
let mut next_lease_renewal =
lease_renewal_deadline(current_unix_ms()?, connection.expires_at_unix_ms);
if let Some(transition) = state.warm_transition.clone() {
if welcome.warm_transition_version != Some(1) {
return Err(DurableRunnerError::invalid(
@ -739,7 +750,7 @@ pub fn run_durable_runner<E: CommandExecutor>(
continue;
}
loop {
if started.elapsed() >= config.max_runtime {
if !config.max_runtime.is_zero() && started.elapsed() >= config.max_runtime {
break;
}
if let Err(error) = send_outbox(
@ -766,6 +777,38 @@ pub fn run_durable_runner<E: CommandExecutor>(
"active connection lease expired; durable state is preserved",
));
}
let now = current_unix_ms()?;
if welcome.lease_renewal_version == Some(1) && now >= next_lease_renewal {
// A failed write may still have reached the controller.
if let Some(credential) = lease.as_mut() {
credential.renewal_requested = true;
}
if let Err(error) = transport.send_json(&control_envelope(
&state,
&connection,
"lease_renew",
json!({
"connectionLeaseExpiresAtUnixMs": connection.expires_at_unix_ms,
"connectionLeaseRevocationEpoch": connection.revocation_epoch,
}),
)) {
disconnected_since.get_or_insert_with(Instant::now);
state.record_diagnostic(format!("lease renewal reconnect scheduled: {error}"));
state.reconnect_count = state.reconnect_count.saturating_add(1);
store.save(&state)?;
break;
}
// Retry a lost reply before expiry without flooding the channel.
next_lease_renewal = now.saturating_add(
5_000.min(
connection
.expires_at_unix_ms
.saturating_sub(now)
.saturating_div(2)
.max(1),
),
);
}
// Read control before starting another fsynced provider batch.
// Consuming the last cumulative ACK must not let a new output
// suffix overtake the stop/suspend already queued behind it.
@ -799,6 +842,14 @@ pub fn run_durable_runner<E: CommandExecutor>(
break;
}
match message.get("kind").and_then(Value::as_str) {
Some("lease_renewed") => {
let credential = lease.as_mut().ok_or_else(|| {
DurableRunnerError::invalid("lease renewal requires a live credential")
})?;
apply_lease_renewal(&message, &mut connection, credential)?;
next_lease_renewal =
lease_renewal_deadline(current_unix_ms()?, connection.expires_at_unix_ms);
}
Some("ack") => {
let acked = message
.pointer("/payload/ackedSourceSeq")
@ -985,6 +1036,47 @@ pub fn run_durable_runner<E: CommandExecutor>(
}
}
fn lease_renewal_deadline(now: u64, expires_at: u64) -> u64 {
now.saturating_add(expires_at.saturating_sub(now) / 2)
}
fn apply_lease_renewal(
message: &Value,
connection: &mut ConnectionMetadata,
credential: &mut LeaseCredential,
) -> Result<(), DurableRunnerError> {
let previous = message
.pointer("/payload/previousExpiresAtUnixMs")
.and_then(Value::as_u64);
let expiry = message
.pointer("/payload/connectionLeaseExpiresAtUnixMs")
.and_then(Value::as_u64);
let epoch = message
.pointer("/payload/connectionLeaseRevocationEpoch")
.and_then(Value::as_u64);
let Some(expiry) = expiry else {
return Err(DurableRunnerError::invalid(
"lease renewal expiry is required",
));
};
if epoch != Some(connection.revocation_epoch)
|| expiry < connection.expires_at_unix_ms
|| previous.is_none_or(|old| old > connection.expires_at_unix_ms)
|| (expiry > connection.expires_at_unix_ms
&& (!credential.renewal_requested || previous != Some(connection.expires_at_unix_ms)))
|| credential.lease_id != connection.lease_id
|| credential.revocation_epoch != connection.revocation_epoch
{
return Err(DurableRunnerError::invalid(
"lease renewal changed the authenticated binding",
));
}
connection.expires_at_unix_ms = expiry;
credential.expires_at_unix_ms = expiry;
credential.renewal_requested = false;
Ok(())
}
fn persist_lifecycle_before_shutdown<E: CommandExecutor>(
state: &mut DurableState,
store: &DurableStateStore,
@ -1904,6 +1996,25 @@ mod tests {
}
}
#[test]
fn unlimited_lifetime_keeps_attempts_bounded_after_weeks_of_work() {
let mut config = config(std::env::temp_dir());
config.max_runtime = Duration::ZERO;
config.validate().unwrap();
let started = Instant::now() - Duration::from_secs(21 * 24 * 60 * 60);
let before = Instant::now();
let deadline = connection_attempt_deadline(&config, started, None);
assert!(deadline >= before + Duration::from_secs(29));
assert!(deadline <= Instant::now() + Duration::from_secs(30));
config.reconnect_grace = Some(Duration::from_secs(5));
let deadline = connection_attempt_deadline(&config, started, Some(Instant::now()));
assert!(deadline <= Instant::now() + Duration::from_secs(5));
config.max_runtime = Duration::from_secs(7 * 24 * 60 * 60);
assert!(connection_attempt_deadline(&config, started, None) <= Instant::now());
config.max_runtime = Duration::from_secs(30 * 24 * 60 * 60);
config.validate().unwrap();
}
#[test]
fn startup_failure_facts_cross_full_fifo_before_failed_command_and_never_poll() {
for mode in [

View File

@ -710,6 +710,7 @@ pub(crate) struct LeaseCredential {
pub(crate) expires_at_unix_ms: u64,
pub(crate) revocation_epoch: u64,
token: Secret,
pub(crate) renewal_requested: bool,
}
impl LeaseCredential {
@ -734,6 +735,7 @@ pub(crate) struct Welcome {
pub(crate) acked_source_seq: Option<u64>,
pub(crate) pending_commands: Vec<Command>,
pub(crate) warm_transition_version: Option<u64>,
pub(crate) lease_renewal_version: Option<u64>,
pub(crate) warm_transition: Option<Value>,
pub(crate) warm_transition_phase: Option<String>,
}
@ -1259,7 +1261,10 @@ fn validate_challenge(
match expected_lease {
Some(lease)
if challenge.credential_lease_id.as_deref() == Some(lease.lease_id.as_str())
&& challenge.credential_expires_at_unix_ms == lease.expires_at_unix_ms
&& (challenge.credential_expires_at_unix_ms == lease.expires_at_unix_ms
|| (lease.renewal_requested
&& state.warm_transition.is_none()
&& challenge.credential_expires_at_unix_ms > lease.expires_at_unix_ms))
&& challenge.revocation_epoch == lease.revocation_epoch => {}
None if challenge.credential_lease_id.is_none() => {}
_ => {
@ -1369,7 +1374,10 @@ fn validate_welcome(
.ok_or_else(|| DurableRunnerError::invalid("welcome revocation epoch is required"))?;
if let Some(expected) = expected_lease {
if connection_lease_id != expected.lease_id
|| expires_at_unix_ms != expected.expires_at_unix_ms
|| (expires_at_unix_ms != expected.expires_at_unix_ms
&& !(expected.renewal_requested
&& state.warm_transition.is_none()
&& expires_at_unix_ms > expected.expires_at_unix_ms))
|| revocation_epoch != expected.revocation_epoch
{
return Err(DurableRunnerError::invalid(
@ -1385,6 +1393,7 @@ fn validate_welcome(
expires_at_unix_ms,
revocation_epoch,
token: Secret::new(token),
renewal_requested: false,
})
}
None | Some(Value::Null) if credential_kind == "lease" => None,
@ -1422,6 +1431,9 @@ fn validate_welcome(
acked_source_seq: payload.get("ackedSourceSeq").and_then(Value::as_u64),
pending_commands,
warm_transition_version: payload.get("warmTransitionVersion").and_then(Value::as_u64),
lease_renewal_version: payload
.get("connectionLeaseRenewalVersion")
.and_then(Value::as_u64),
warm_transition: payload.get("warmTransition").cloned(),
warm_transition_phase: payload
.get("warmTransitionPhase")
@ -2411,6 +2423,15 @@ mod tests {
#[test]
fn reconnect_replays_unacked_events_and_not_command_effects() {
reconnect_without_reexecuting(false);
}
#[test]
fn lost_renewal_reply_reconnects_without_restarting_the_provider() {
reconnect_without_reexecuting(true);
}
fn reconnect_without_reexecuting(renewal_reply_lost: bool) {
struct EventExecutor {
session_open_calls: Arc<AtomicUsize>,
shutdown_calls: Arc<AtomicUsize>,
@ -2448,13 +2469,14 @@ mod tests {
let mut config = config(port);
config.max_runtime = Duration::from_secs(5);
let directory = std::env::temp_dir().join(format!(
"paperclip-runner-reconnect-fault-{}",
"paperclip-runner-reconnect-fault-{}-{renewal_reply_lost}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&directory);
config.state_dir = directory.clone();
let state = test_state(&config);
let expires = current_unix_ms().unwrap() + 60_000;
let mut expires =
current_unix_ms().unwrap() + if renewal_reply_lost { 2_000 } else { 60_000 };
let open_command = json!({
"schema": "paperclip.prp.command.v1",
"commandId": "command_open",
@ -2489,23 +2511,32 @@ mod tests {
revocation_epoch: 0,
},
);
send_secure(
&mut first,
&mut first_secure,
&server_config,
&welcome(
&server_state,
"connection_1",
Some("lease-secret"),
expires,
0,
vec![server_open.clone()],
),
let mut greeting = welcome(
&server_state,
"connection_1",
Some("lease-secret"),
expires,
0,
vec![server_open.clone()],
);
if renewal_reply_lost {
greeting["payload"]["connectionLeaseRenewalVersion"] = json!(1);
}
send_secure(&mut first, &mut first_secure, &server_config, &greeting);
let first_result = receive_secure(&mut first, &mut first_secure, &server_config);
let first_event = receive_secure(&mut first, &mut first_secure, &server_config);
assert_eq!(first_result["kind"], "command_result");
assert_eq!(first_event["kind"], "event");
if renewal_reply_lost {
let renewal = receive_secure(&mut first, &mut first_secure, &server_config);
assert_eq!(renewal["kind"], "lease_renew");
assert_eq!(
renewal["payload"]["connectionLeaseExpiresAtUnixMs"],
json!(expires)
);
// Commit a new expiry but lose the reply before the runner sees it.
expires = current_unix_ms().unwrap() + 60_000;
}
drop(first);
let (second_stream, _) = listener.accept().unwrap();

View File

@ -143,8 +143,16 @@ impl ProviderStartupAttempt {
!value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control)
};
let command_valid = self.command.as_ref().is_none_or(|command| {
// This is a summary of an already-validated command, not a new
// wire request. Goal commands require v2; reconstructing every
// summary as v1 rejects a legitimate provider restart on goal/get.
let schema = if command.command_type.starts_with("session.goal.") {
"paperclip.prp.command.v2"
} else {
"paperclip.prp.command.v1"
};
Command {
schema: "paperclip.prp.command.v1".to_owned(),
schema: schema.to_owned(),
command_id: command.command_id.clone(),
controller_seq: command.controller_seq,
command_type: command.command_type.clone(),
@ -4553,6 +4561,38 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn goal_command_can_persist_provider_restart_ownership() {
for command_type in ["session.goal.get", "session.goal.set", "session.goal.clear"] {
let directory = std::env::temp_dir()
.join(format!("paperclip-goal-startup-{}", uuid::Uuid::new_v4()));
let mut executor = CodexCommandExecutor::new(&directory);
executor.state = Some(opencode_result_state());
executor.startup_command = Some(ProviderStartupCommand {
command_id: "command-goal-recovery".to_owned(),
controller_seq: 12,
command_type: command_type.to_owned(),
});
executor
.begin_startup(ProviderStartupTrigger::Ensure, 4)
.unwrap();
executor
.observe_startup(ProviderStartupObservation::Spawned {
process_id: 123,
process_group_id: 123,
})
.unwrap();
let saved: CodexProviderState =
serde_json::from_slice(&fs::read(executor.state_path()).unwrap()).unwrap();
saved.validate().unwrap();
assert_eq!(
saved.startup_attempt.unwrap().command.unwrap().command_type,
command_type
);
fs::remove_dir_all(directory).unwrap();
}
}
#[test]
fn startup_phase_fields_are_closed_and_coherent() {
let mut attempt = ProviderStartupAttempt {

View File

@ -341,9 +341,9 @@
"mode": "native",
"covers": { "decisionRows": [], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": ["REC-08"], "compatibilityRows": [], "migrationRows": [] },
"tags": ["supersession", "reconciliation", "deterministic_replay"],
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "new_policy_requires_review", "trigger": "authorized_agent" },
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "completion_review_required", "requiredEffects": ["bind_reviewer", "append_superseding_assessment"], "forbiddenEffects": ["mutate_old_decision"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 2, "maxWakeCount": 1, "maxNotificationCount": 1 },
"replay": { "attempts": 2, "sameDecisionDigest": false, "maxSemanticDecisions": 2, "maxDomainEffectsPerKey": 1 }
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "policy_version_changed", "trigger": "authorized_agent" },
"expected": { "runStatus": "succeeded", "statusAction": "preserve", "reasonCode": "prior_fixture_decision", "requiredEffects": [], "forbiddenEffects": ["mutate_old_decision", "bind_reviewer", "append_superseding_assessment"], "livePathKind": null, "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
},
{
"id": "legacy-adapter-unchanged",

View File

@ -42,6 +42,100 @@ const identity: DurableRecoveryIdentity = {
const expectedRunnerVersion = "0.3.0";
const expectedRunnerDigest = `sha256:${"a".repeat(64)}`;
function renewalRequest(client: AuthenticatedClient, expiresAt: number): Record<string, unknown> {
return {
protocol: "paperclip.runner", version: client.welcome.version,
kind: "lease_renew", ...identity,
connectionId: client.welcome.connectionId,
connectionLeaseId: client.welcome.connectionLeaseId,
payload: {
connectionLeaseExpiresAtUnixMs: expiresAt,
connectionLeaseRevocationEpoch: (client.welcome.payload as Record<string, unknown>).connectionLeaseRevocationEpoch,
},
};
}
it("renews one authenticated connection for three weeks without replacing its authority", async () => {
const root = mkdtempSync(resolve(tmpdir(), "runner-lease-renewal-"));
let now = Date.now();
const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
const ttl = 6 * 60 * 60 * 1_000;
const core = new DurablePrpControlPlane({
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
connectionLeaseTtlMs: ttl,
});
try {
await core.start();
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
let expiry = Number((client.welcome.payload as Record<string, unknown>).connectionLeaseExpiresAtUnixMs);
const leaseId = client.welcome.connectionLeaseId;
for (let hour = 0; hour < 21 * 24; hour += 3) {
now += ttl / 2;
const request = renewalRequest(client, expiry);
sendSecure(client, request);
const reply = (await receiveSecure(client))!;
expect(reply.kind).toBe("lease_renewed");
expect(reply.connectionLeaseId).toBe(leaseId);
expect(reply.connectionId).toBe(client.welcome.connectionId);
const next = Number((reply.payload as Record<string, unknown>).connectionLeaseExpiresAtUnixMs);
expect(next).toBe(now + ttl);
// A lost reply can be retried without another authority extension.
now += 1;
sendSecure(client, request);
expect((await receiveSecure(client))!.payload).toEqual(reply.payload);
expiry = next;
}
expect(core.store.state.connectionCount).toBe(1);
expect(Object.keys(core.store.state.leases)).toHaveLength(1);
expect(core.store.state.commands).toEqual([]);
client.socket.destroy();
await core.stop();
const restored = new DurablePrpControlPlane({
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
connectionLeaseTtlMs: ttl,
});
try {
await restored.start();
const resumed = (await authenticate(restored, client.leaseToken!))!;
expect(resumed.welcome.connectionLeaseId).toBe(leaseId);
expect((resumed.welcome.payload as Record<string, unknown>).connectionLeaseExpiresAtUnixMs).toBe(expiry);
resumed.socket.destroy();
} finally { await restored.stop(); }
} finally {
clock.mockRestore();
await core.stop();
rmSync(root, { recursive: true, force: true });
}
});
it.each(["expired", "revoked", "wrong-run", "wrong-connection", "wrong-epoch", "future-expiry"])(
"cannot renew a lease with %s authority",
async (fault) => {
const root = mkdtempSync(resolve(tmpdir(), "runner-lease-denial-"));
const core = new DurablePrpControlPlane({ stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest });
let clock: ReturnType<typeof vi.spyOn> | undefined;
try {
await core.start();
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
const expiry = Number((client.welcome.payload as Record<string, unknown>).connectionLeaseExpiresAtUnixMs);
const request = renewalRequest(client, expiry);
if (fault === "expired") clock = vi.spyOn(Date, "now").mockReturnValue(expiry);
if (fault === "revoked") Object.values(core.store.state.leases)[0]!.revokedAt = new Date().toISOString();
if (fault === "wrong-run") request.runId = "different-run";
if (fault === "wrong-connection") request.connectionId = "different-connection";
if (fault === "wrong-epoch") (request.payload as Record<string, unknown>).connectionLeaseRevocationEpoch = 999;
if (fault === "future-expiry") (request.payload as Record<string, unknown>).connectionLeaseExpiresAtUnixMs = expiry + 1;
sendSecure(client, request);
expect(await receiveSecure(client)).toBeNull();
expect(Object.values(core.store.state.leases)[0]!.expiresAtUnixMs).toBe(expiry);
} finally {
clock?.mockRestore();
await core.stop();
rmSync(root, { recursive: true, force: true });
}
},
);
it("persists the initial warm attachment seed idempotently and rejects replacement", () => {
const root = mkdtempSync(
resolve(tmpdir(), "runner-initial-attachment-seed-test-"),

View File

@ -2057,6 +2057,9 @@ export class DurablePrpControlPlane {
await this.#authResponse(connection, envelope);
return;
}
// Admit every post-handshake frame against persisted authority before
// dispatch, including lease_renew. Renewal cannot revive a revoked or
// expired credential or bypass changes to its persisted binding.
if (
connection.secureChannel === null ||
connection.lease === null ||
@ -2068,6 +2071,10 @@ export class DurablePrpControlPlane {
connection.close();
return;
}
if (kind === "lease_renew") {
this.#renewLease(connection, envelope);
return;
}
if (kind === "event") {
if (this.#store.state.warmTransition?.phase === "awaiting_result")
connection.replayOnly = true;
@ -2143,6 +2150,62 @@ export class DurablePrpControlPlane {
}
}
#renewLease(
connection: AuthorityConnection,
envelope: Record<string, unknown>,
): void {
const lease = connection.lease!;
const payload = envelope.payload as Record<string, unknown> | undefined;
const expectedExpiry = payload?.connectionLeaseExpiresAtUnixMs;
if (
Object.entries(connection.identity!).some(
([key, value]) => envelope[key] !== value,
) ||
envelope.connectionId !== connection.connectionId ||
envelope.connectionLeaseId !== lease.leaseId ||
payload?.connectionLeaseRevocationEpoch !== lease.revocationEpoch ||
!Number.isSafeInteger(expectedExpiry) ||
(expectedExpiry as number) <= 0 ||
(expectedExpiry as number) > lease.expiresAtUnixMs
) {
connection.close();
return;
}
// A handoff receipt binds the exact expiry. Finish that boundary before
// renewing; terminal commands likewise retain their existing authority.
if (
connection.replayOnly ||
this.#store.state.warmTransition ||
connection.terminalLifecycleCommandId !== null
) return;
// Repeating a request after a lost reply replays the persisted expiry.
// It never extends a credential twice for the same observed generation.
if (expectedExpiry === lease.expiresAtUnixMs) {
const candidate = structuredClone(this.#store.state);
const renewed = candidate.leases[lease.credentialId]!;
renewed.expiresAtUnixMs = Math.max(
lease.expiresAtUnixMs,
Date.now() + this.#connectionLeaseTtlMs,
);
renewed.expiresAt = new Date(renewed.expiresAtUnixMs).toISOString();
candidate.lastLeaseExpiresAt = renewed.expiresAt;
this.#store.commit(candidate);
connection.lease = this.#store.state.leases[lease.credentialId]!;
}
connection.sendJson(
this.#controlEnvelope(
connection,
`lease_renewed_${expectedExpiry}`,
"lease_renewed",
{
previousExpiresAtUnixMs: expectedExpiry,
connectionLeaseExpiresAtUnixMs: connection.lease!.expiresAtUnixMs,
connectionLeaseRevocationEpoch: connection.lease!.revocationEpoch,
},
),
);
}
#authorizeHello(
payload: Record<string, unknown>,
): PendingAuthorization | null {
@ -2652,6 +2715,7 @@ export class DurablePrpControlPlane {
payload: {
selectedVersion: lease.protocolVersion,
heartbeatIntervalMs: 250,
connectionLeaseRenewalVersion: 1,
connectionLeaseId: lease.leaseId,
...(leaseToken === null ? {} : { connectionLeaseToken: leaseToken }),
connectionLeaseExpiresAt: lease.expiresAt,

View File

@ -3993,6 +3993,58 @@ it("expands coalesced canonical items without dropping strict bindings", () => {
]);
});
it("keeps a quiet active Codex turn in the same process across connection lease expiry", async () => {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-renew-active-"));
const callsPath = join(stateDirectory, "calls.log");
const cores: DurablePrpControlPlane[] = [];
const OriginalCore = durableControlPlane.DurablePrpControlPlane;
const coreSpy = vi.spyOn(durableControlPlane, "DurablePrpControlPlane")
.mockImplementation(function(options: ConstructorParameters<typeof OriginalCore>[0]) {
const core = new OriginalCore({ ...options, connectionLeaseTtlMs: 60_000 });
cores.push(core);
return core;
} as unknown as typeof OriginalCore);
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: fakeCodex,
codexArgs: fakeCodexArgs(stateDirectory, "--hold-turn", "--record-process-start", "--call-log", callsPath),
stateDirectory, runnerReconnectGraceMs: 5_000,
});
try {
const opened = await bundle.transport.request("thread/start", { cwd: tmpdir(), dynamicTools: [] });
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Keep working quietly." }] });
const runnerPid = bundle.evidence().runnerPid;
const codexPid = bundle.evidence().codexPid;
expect(runnerPid).toBeGreaterThan(0);
expect(codexPid).toBeGreaterThan(0);
const core = cores[0]!;
const original = structuredClone(Object.values(core.store.state.leases)[0]!);
await vi.waitFor(() => {
expect(Date.now()).toBeGreaterThan(original.expiresAtUnixMs + 1_000);
}, { timeout: 75_000, interval: 1_000 });
const current = Object.values(core.store.state.leases)[0]!;
expect(current.expiresAtUnixMs).toBeGreaterThan(original.expiresAtUnixMs);
expect(current.leaseId).toBe(original.leaseId);
expect(core.store.state.connectionCount).toBe(1);
expect(bundle.evidence().runnerPid).toBe(runnerPid);
expect(bundle.evidence().codexPid).toBe(codexPid);
process.kill(runnerPid!, 0);
process.kill(codexPid!, 0);
const calls = (await readFile(callsPath, "utf8")).trim().split(/\r?\n/);
expect(calls.filter(call => call === "process-start")).toHaveLength(1);
expect(calls.filter(call => call === "thread/start")).toHaveLength(1);
expect(calls.filter(call => call === "turn/start")).toHaveLength(1);
expect(calls).not.toContain("turn/interrupt");
expect(calls).not.toContain("thread/resume");
const state = JSON.parse(await readFile(join(stateDirectory, "fake-codex-state.json"), "utf8"));
expect(state.threadId).toBe(opened.thread.id);
expect(state.activeTurnId).toBe("provider-turn-1");
} finally {
await bundle.transport.close();
coreSpy.mockRestore();
await rm(stateDirectory, { recursive: true, force: true });
}
}, 90_000);
it("runs the lab provider boundary through authenticated durable PRP", async () => {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-lab-provider-"));
const bundle = createCapabilityRunnerdCodexTransport({

View File

@ -4624,7 +4624,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
ticket: core.issueBootstrapTicket(RUNNER_BOOTSTRAP_TICKET_TTL_MS),
maxOutboxBytes: RUNNERD_MAX_OUTBOX_BYTES,
p0ReserveBytes: RUNNERD_P0_RESERVE_BYTES,
maxRuntimeMs: 60 * 60 * 1_000,
maxRuntimeMs: 0,
reconnectGraceMs: this.options.runnerReconnectGraceMs,
lifecyclePolicy: this.options.lifecyclePolicy,
runnerBinaryPath,
@ -5248,7 +5248,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
ticket: bootstrapTicket!,
maxOutboxBytes: RUNNERD_MAX_OUTBOX_BYTES,
p0ReserveBytes: RUNNERD_P0_RESERVE_BYTES,
maxRuntimeMs: 60 * 60 * 1_000,
maxRuntimeMs: 0,
reconnectGraceMs: this.options.runnerReconnectGraceMs,
lifecyclePolicy: this.options.lifecyclePolicy,
runnerBinaryPath,

View File

@ -27,6 +27,7 @@ import {
} from "./contracts/runtime-context.js";
import {
executeNativeSession,
completeTerminatedRemoteNativeSessionCleanup,
type ExecuteNativeSessionOptions,
} from "./native-session-runtime.js";
@ -200,6 +201,88 @@ function highestContiguous(events: PrpEvent[]): number {
}
describe("executeNativeSession recovery", () => {
it.each([undefined, 0, 7 * 24 * 60 * 60 * 1000, 30 * 24 * 60 * 60 * 1000])(
"honors long-lived turn duration independently of operation bounds (%s)",
async (turnTimeoutMs) => {
vi.useFakeTimers();
let finish = () => {};
const waiting = new Promise<void>((resolve) => { finish = resolve; });
const capabilities = {
resume: true, typedEvents: true, steering: false,
interruption: true, structuredResult: true,
};
const cancel = vi.fn(async () => { finish(); });
const close = vi.fn(async () => { finish(); });
const session: NativeSession = {
identity: () => identity,
async capabilities() { return capabilities; },
async *events() {
yield runnerEvent(1, "tool.execution.started", { name: "wait for CI", status: "running" });
await waiting;
yield runnerEvent(2, "turn.completed");
},
async startTurn() { return { turnId: "turn-recovery" }; },
async result() { return { result, terminal, turnId: "turn-recovery" }; },
async snapshot() {
return { backendKind: "mock", sessionId: "driver-recovery", identity,
providerSessionId: "provider-recovery", cursor: null, activeTurnId: null,
pendingRuntimeRequests: [], lineage: [] };
},
cancel, close,
};
const appendEvent = vi.fn<ControlPlanePort["appendEvent"]>(async event => ({
cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq,
disposition: "committed",
}));
const completeRun = vi.fn(async () => {});
try {
const execution = executeNativeSession({
input, turnTimeoutMs,
backend: {
async descriptor() { return { kind: "mock", name: "long-lived", version: "1", capabilities }; },
async openSession() { return session; },
},
controlPlane: {
async openRun() {}, async checkpointSession() {}, appendEvent,
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
completeRun,
},
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
});
// Observe rejection before advancing the clock, including on the old implementation.
let failure: unknown;
const observed = execution.catch(error => { failure = error; return null; });
await vi.advanceTimersByTimeAsync(0);
expect(appendEvent).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(6 * 24 * 60 * 60 * 1000);
expect(failure).toBeUndefined();
expect(cancel).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
expect(completeRun).not.toHaveBeenCalled();
if (turnTimeoutMs) {
// Includes a 30-day bound beyond Node's single-timer maximum.
await vi.advanceTimersByTimeAsync(turnTimeoutMs - 6 * 24 * 60 * 60 * 1000 - 1);
expect(failure).toBeUndefined();
await vi.advanceTimersByTimeAsync(1);
await observed;
expect(failure).toBeInstanceOf(Error);
expect((failure as Error).message).toContain(`native session timed out after ${turnTimeoutMs}ms`);
expect(cancel).toHaveBeenCalledOnce();
expect(completeRun).not.toHaveBeenCalled();
} else {
await vi.advanceTimersByTimeAsync(2 * 24 * 60 * 60 * 1000);
finish();
expect(await observed).toMatchObject({ result });
expect(completeRun).toHaveBeenCalledOnce();
expect(cancel).not.toHaveBeenCalled();
}
} finally {
finish();
vi.useRealTimers();
}
},
);
it.each([false, true])("preserves a durable session failure when its stream closes (throws=%s)", async throws => {
const capabilities = { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
const session: NativeSession = {
@ -3857,6 +3940,59 @@ describe("executeNativeSession recovery", () => {
},
);
it("retires only the terminated remote resource, including two sandboxes for one run", async () => {
const scopedIdentity = { ...identity, companyId: "remote-stop-company", runId: "remote-stop-run" };
const binding = { ...scopedIdentity, remoteCleanupScope: "first-sandbox" };
const scopedInput = { ...input, binding: { ...input.binding, companyId: scopedIdentity.companyId, runId: scopedIdentity.runId } };
const failure = new NativeSessionCloseUnrecoverableError();
const capabilities = { resume: true, typedEvents: true, steering: false, interruption: false, structuredResult: true };
const session: NativeSession = {
identity: () => scopedIdentity,
capabilities: async () => capabilities,
async *events() { throw new Error("cancelled remote transport"); },
startTurn: async () => ({ turnId: "remote-turn" }),
result: async () => null,
close: vi.fn(async () => { throw failure; }),
};
const backend: NativeSessionBackend = {
descriptor: async () => ({ kind: "remote", name: "remote-stop-test", version: "1", capabilities }),
openSession: vi.fn(async () => session),
};
const controlPlane: ControlPlanePort = {
openRun: async () => {}, checkpointSession: async () => {},
appendEvent: async () => ({ cursor: 0, highestContiguousSourceSeq: 0, disposition: "committed" }),
replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }),
completeRun: vi.fn(async () => {}),
};
const options = { input: scopedInput, backend, controlPlane, runnerInstanceId: "remote-runner",
controlPlaneInstanceId: "control", requireSessionCloseBeforeReturn: true,
remoteCleanupScope: binding.remoteCleanupScope };
await expect(executeNativeSession(options)).rejects.toBe(failure);
expect(completeTerminatedRemoteNativeSessionCleanup({ ...binding, runId: "other-run" })).toBe(true);
expect(completeTerminatedRemoteNativeSessionCleanup({ ...binding, companyId: "other-company" })).toBe(true);
await expect(executeNativeSession(options)).rejects.toBeInstanceOf(NativeSessionCleanupQuarantinedError);
expect(backend.openSession).toHaveBeenCalledOnce();
// A separate sandbox can start without inheriting this process quarantine.
const independent = { ...scopedIdentity, sessionId: "other-sandbox-session" };
const independentSession = { ...session, identity: () => independent };
const independentBackend = { ...backend, openSession: vi.fn(async () => independentSession) };
await expect(executeNativeSession({ ...options, remoteCleanupScope: "other-sandbox",
input: { ...scopedInput, binding: { ...scopedInput.binding, runId: independent.runId } },
backend: independentBackend })).rejects.toBe(failure);
expect(independentBackend.openSession).toHaveBeenCalledOnce();
expect(completeTerminatedRemoteNativeSessionCleanup(binding)).toBe(true);
// Same company/run, different sandbox: its quarantine must remain intact.
await expect(executeNativeSession({ ...options, remoteCleanupScope: "other-sandbox",
backend: independentBackend })).rejects.toBeInstanceOf(NativeSessionCleanupQuarantinedError);
expect(independentBackend.openSession).toHaveBeenCalledOnce();
// Reopening is now possible; the old failure/result was never rewritten.
await expect(executeNativeSession(options)).rejects.toBe(failure);
expect(backend.openSession).toHaveBeenCalledTimes(2);
expect(controlPlane.completeRun).not.toHaveBeenCalled();
completeTerminatedRemoteNativeSessionCleanup(binding);
completeTerminatedRemoteNativeSessionCleanup({ ...binding, remoteCleanupScope: "other-sandbox" });
});
it("propagates an exhausted required backend checkpoint close", async () => {
vi.useFakeTimers();
try {

View File

@ -125,6 +125,34 @@ export function completeRetainedNativeSessionCleanup(
return matches.length;
}
/** Control-plane-only cleanup boundary after the environment provider confirmed
* termination of the exact remote resource for this run. This retires process
* ownership, not checkpoints, action outcomes, or authorization to run again.
* An in-flight close must settle first: it must never reach a reused sandbox.
*/
export function completeTerminatedRemoteNativeSessionCleanup(binding: {
companyId: string;
runId: string;
remoteCleanupScope: string;
}): boolean {
if (!binding.remoteCleanupScope) return false;
const matches = [...quarantinedSessionCleanups].filter(({ session, domain }) => {
const identity = session.identity();
// Domains are created internally from company, backend kind/name, and the
// optional remote resource. Local domains have no fourth element.
const [, , , remoteCleanupScope] = JSON.parse(domain) as string[];
return identity.companyId === binding.companyId && identity.runId === binding.runId &&
remoteCleanupScope === binding.remoteCleanupScope;
});
if (matches.some(entry => entry.attempt || entry.recovery)) return false;
for (const entry of matches) {
if (entry.timer) clearTimeout(entry.timer);
entry.timer = null;
quarantinedSessionCleanups.delete(entry);
}
return true;
}
export interface NativeSessionGoalControl {
requestId: string;
action: "create" | "edit" | "replace" | "pause" | "resume" | "clear";
@ -138,7 +166,13 @@ export interface ExecuteNativeSessionOptions {
controlPlane: ControlPlanePort;
runnerInstanceId: string;
controlPlaneInstanceId: string;
/** Trusted provider resource identity: independent remote sandboxes must not
* inherit each other's process-cleanup gates. Omit for local backends. */
remoteCleanupScope?: string;
/** Operation bound; explicit values also preserve the legacy turn bound. */
timeoutMs?: number;
/** Total turn duration. Zero or no configured bound allows long-running work. */
turnTimeoutMs?: number;
/** Abort admission while waiting for prior cleanup in the same domain. */
signal?: AbortSignal;
/** Internal test seam; production bounds checkpoint persistence to 30 seconds. */
@ -1127,9 +1161,19 @@ async function consumeTurn(
return await Promise.race([
consumer,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`native session timed out after ${timeoutMs}ms`));
}, timeoutMs);
if (timeoutMs <= 0) return;
// Node timers overflow above ~24.8 days. Keep explicit long deadlines
// in bounded chunks instead of accidentally firing them immediately.
const deadline = Date.now() + timeoutMs;
const checkDeadline = () => {
const remaining = deadline - Date.now();
if (remaining <= 0) {
reject(new Error(`native session timed out after ${timeoutMs}ms`));
} else {
timer = setTimeout(checkDeadline, Math.min(remaining, 2_147_483_647));
}
};
checkDeadline();
}),
handoffFailure,
externalAbortFailure,
@ -1739,6 +1783,7 @@ export async function executeNativeSession(
input.binding.companyId,
descriptor.kind,
descriptor.name,
...(options.remoteCleanupScope ? [options.remoteCleanupScope] : []),
]);
await retryQuarantinedSessionCleanups(cleanupDomain, options.signal);
if ("runtimeContext" in input) {
@ -2201,7 +2246,7 @@ export async function executeNativeSession(
session,
options.controlPlane,
input,
options.timeoutMs ?? 900_000,
options.turnTimeoutMs ?? options.timeoutMs ?? 0,
options.runtimeInputLiveWindowMs ??
DEFAULT_NATIVE_RUNTIME_INPUT_LIVE_WINDOW_MS,
options.keepSessionOpen

View File

@ -1291,13 +1291,38 @@ describe("Daytona sandbox provider plugin", () => {
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
});
it("refreshes a cached stopped handle before granting a termination receipt", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-resumed", state: "stopped" });
sandbox.refreshData.mockImplementation(async () => { sandbox.state = "started"; });
mockGet.mockResolvedValue(sandbox);
await expect(plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
providerLeaseId: sandbox.id, config: { reuseLease: true },
})).resolves.toEqual({ providerLeaseId: sandbox.id, state: "stopped" });
expect(sandbox.refreshData).toHaveBeenCalled();
expect(sandbox.stop).toHaveBeenCalled();
});
it("does not acknowledge termination when both provider stop and delete fail", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-failed-stop", state: "started" });
sandbox.stop.mockRejectedValueOnce(new Error("stop failed"));
sandbox.delete.mockRejectedValueOnce(new Error("delete failed"));
mockGet.mockResolvedValue(sandbox);
await expect(plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
providerLeaseId: sandbox.id, config: { reuseLease: true },
})).rejects.toThrow("delete failed");
});
it("stops reusable leases and deletes ephemeral leases on release", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const reusable = createMockSandbox({ id: "sandbox-reusable" });
const ephemeral = createMockSandbox({ id: "sandbox-ephemeral" });
mockGet.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
await plugin.definition.onEnvironmentReleaseLease?.({
const reusableReceipt = await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
@ -1307,7 +1332,7 @@ describe("Daytona sandbox provider plugin", () => {
reuseLease: true,
},
});
await plugin.definition.onEnvironmentReleaseLease?.({
const ephemeralReceipt = await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
@ -1318,9 +1343,11 @@ describe("Daytona sandbox provider plugin", () => {
},
});
expect(reusableReceipt).toEqual({ providerLeaseId: "sandbox-reusable", state: "stopped" });
expect(ephemeralReceipt).toEqual({ providerLeaseId: "sandbox-ephemeral", state: "destroyed" });
expect(reusable.stop).toHaveBeenCalledWith(300);
expect(reusable.delete).not.toHaveBeenCalled();
expect(ephemeral.delete).toHaveBeenCalledWith(300);
expect(ephemeral.delete).toHaveBeenCalledWith(300, true);
});
it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => {
@ -1367,7 +1394,7 @@ describe("Daytona sandbox provider plugin", () => {
expect(sandbox.stop).not.toHaveBeenCalled();
expect(sandbox.archive).toHaveBeenCalled();
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
expect(warnSpy).toHaveBeenCalled();
});
@ -1389,7 +1416,7 @@ describe("Daytona sandbox provider plugin", () => {
});
expect(errored.stop).toHaveBeenCalledWith(300);
expect(errored.delete).toHaveBeenCalledWith(300);
expect(errored.delete).toHaveBeenCalledWith(300, true);
});
it("falls back to delete when stopping a healthy reusable lease fails mid-call", async () => {
@ -1411,7 +1438,7 @@ describe("Daytona sandbox provider plugin", () => {
});
expect(sandbox.stop).toHaveBeenCalledWith(300);
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
expect(warnSpy).toHaveBeenCalled();
});
@ -1628,7 +1655,24 @@ describe("Daytona sandbox provider plugin", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(sessionId));
// The rest of teardown still ran: the ephemeral sandbox was deleted.
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
});
it("returns a destroy receipt only after the provider confirms deletion", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
let complete!: () => void;
sandbox.delete.mockImplementationOnce(() => new Promise<void>(resolve => { complete = resolve; }));
const release = plugin.definition.onEnvironmentDestroyLease!({ driverKey: "daytona",
companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123",
config: { timeoutMs: 300000, reuseLease: false } });
let settled = false;
void Promise.resolve(release).then(() => { settled = true; });
await vi.waitFor(() => expect(sandbox.delete).toHaveBeenCalledWith(300, true));
expect(settled).toBe(false);
complete();
await expect(release).resolves.toEqual({ providerLeaseId: "sandbox-123", state: "destroyed" });
});
it("clears the session store after delete so no orphan id survives a second teardown", async () => {

View File

@ -33,6 +33,7 @@ import type {
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentTerminationReceipt,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentStartInteractiveSetupParams,
PluginEnvironmentSyncInParams,
@ -2263,7 +2264,7 @@ const plugin = definePlugin({
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const scope: SandboxScope = {
@ -2280,7 +2281,7 @@ const plugin = definePlugin({
sandboxHandleLeaseAdmissionStates.close(scope);
try {
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
if (!sandbox) return;
if (!sandbox) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
evictSandboxHandle(scope);
await sandboxHandleActivityGates.waitForIdle(scope);
@ -2289,6 +2290,9 @@ const plugin = definePlugin({
// so no channel outlives the sandbox and no stored channel id survives.
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
// A cached stopped state is not a receipt: the resource could have been
// resumed since the handle was cached. Read provider state at this boundary.
await withLivenessTimeout("sandbox.refreshData", config.livenessTimeoutMs, () => sandbox.refreshData());
if (config.reuseLease) {
if (sandbox.state !== "stopped") {
try {
@ -2297,14 +2301,11 @@ const plugin = definePlugin({
console.warn(
`Failed to stop Daytona sandbox during lease release: ${formatErrorMessage(error)}. Attempting delete instead.`,
);
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch((deleteError) => {
console.warn(
`Failed to delete Daytona sandbox after stop failure: ${formatErrorMessage(deleteError)}`,
);
});
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true);
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
}
}
return;
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
}
if (config.archiveOnRelease) {
@ -2314,7 +2315,7 @@ const plugin = definePlugin({
}
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
await sandbox.archive();
return;
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
} catch (error) {
console.warn(
`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`,
@ -2322,7 +2323,8 @@ const plugin = definePlugin({
}
}
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true);
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
} finally {
sandboxHandleTeardownGates.end(scope, teardownGate);
evictSandboxHandle(scope);
@ -2331,7 +2333,7 @@ const plugin = definePlugin({
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const scope: SandboxScope = {
@ -2347,7 +2349,7 @@ const plugin = definePlugin({
sandboxHandleLeaseAdmissionStates.close(scope);
try {
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
if (!sandbox) return;
if (!sandbox) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
evictSandboxHandle(scope);
await sandboxHandleActivityGates.waitForIdle(scope);
@ -2355,7 +2357,8 @@ const plugin = definePlugin({
// Close every duplex channel on this lease before the delete, so no channel
// outlives the sandbox and no stored channel id survives.
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true);
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
} finally {
sandboxHandleTeardownGates.end(scope, teardownGate);
evictSandboxHandle(scope);

View File

@ -75,6 +75,7 @@ import type {
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentTerminationReceipt,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
@ -384,12 +385,12 @@ export interface PluginDefinition {
/** Called when a run finishes and the provider lease can be released. */
onEnvironmentReleaseLease?(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void>;
): Promise<PluginEnvironmentTerminationReceipt | void>;
/** Called when the host needs to force-destroy provider state. */
onEnvironmentDestroyLease?(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void>;
): Promise<PluginEnvironmentTerminationReceipt | void>;
/** Called to materialize the run workspace inside the provider lease. */
onEnvironmentRealizeWorkspace?(

View File

@ -183,6 +183,7 @@ export type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentTerminationReceipt,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,

View File

@ -666,6 +666,13 @@ export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDr
leaseMetadata?: Record<string, unknown>;
}
/** Returned only after the provider confirms that execution has ended. A queued
* stop request or successful local cleanup is not a termination receipt. */
export interface PluginEnvironmentTerminationReceipt {
providerLeaseId: string;
state: "stopped" | "destroyed";
}
export interface PluginEnvironmentDestroyLeaseParams extends PluginEnvironmentReleaseLeaseParams {}
export interface PluginEnvironmentRealizeWorkspaceParams extends PluginEnvironmentDriverBaseParams {
@ -1363,11 +1370,11 @@ export interface HostToWorkerMethods {
];
environmentReleaseLease: [
params: PluginEnvironmentReleaseLeaseParams,
result: void,
result: PluginEnvironmentTerminationReceipt | void,
];
environmentDestroyLease: [
params: PluginEnvironmentDestroyLeaseParams,
result: void,
result: PluginEnvironmentTerminationReceipt | void,
];
environmentRealizeWorkspace: [
params: PluginEnvironmentRealizeWorkspaceParams,

View File

@ -55,6 +55,7 @@ import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentTerminationReceipt,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
@ -185,8 +186,8 @@ export interface EnvironmentTestHarnessOptions extends TestHarnessOptions {
onProbe?: (params: PluginEnvironmentProbeParams) => Promise<PluginEnvironmentProbeResult>;
onAcquireLease?: (params: PluginEnvironmentAcquireLeaseParams) => Promise<PluginEnvironmentLease>;
onResumeLease?: (params: PluginEnvironmentResumeLeaseParams) => Promise<PluginEnvironmentLease>;
onReleaseLease?: (params: PluginEnvironmentReleaseLeaseParams) => Promise<void>;
onDestroyLease?: (params: PluginEnvironmentDestroyLeaseParams) => Promise<void>;
onReleaseLease?: (params: PluginEnvironmentReleaseLeaseParams) => Promise<PluginEnvironmentTerminationReceipt | void>;
onDestroyLease?: (params: PluginEnvironmentDestroyLeaseParams) => Promise<PluginEnvironmentTerminationReceipt | void>;
onRealizeWorkspace?: (params: PluginEnvironmentRealizeWorkspaceParams) => Promise<PluginEnvironmentRealizeWorkspaceResult>;
onExecute?: (params: PluginEnvironmentExecuteParams) => Promise<PluginEnvironmentExecuteResult>;
onStartInteractiveSetup?: (params: PluginEnvironmentStartInteractiveSetupParams) => Promise<PluginEnvironmentInteractiveSetupSession>;
@ -210,9 +211,9 @@ export interface EnvironmentTestHarness extends TestHarness {
/** Invoke the environment driver's resumeLease hook. */
resumeLease(params: PluginEnvironmentResumeLeaseParams): Promise<PluginEnvironmentLease>;
/** Invoke the environment driver's releaseLease hook. */
releaseLease(params: PluginEnvironmentReleaseLeaseParams): Promise<void>;
releaseLease(params: PluginEnvironmentReleaseLeaseParams): Promise<PluginEnvironmentTerminationReceipt | void>;
/** Invoke the environment driver's destroyLease hook. */
destroyLease(params: PluginEnvironmentDestroyLeaseParams): Promise<void>;
destroyLease(params: PluginEnvironmentDestroyLeaseParams): Promise<PluginEnvironmentTerminationReceipt | void>;
/** Invoke the environment driver's realizeWorkspace hook. */
realizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams): Promise<PluginEnvironmentRealizeWorkspaceResult>;
/** Invoke the environment driver's execute hook. */

View File

@ -1,67 +1,68 @@
import a0 from "./app-definitions/zapier.json" with { type: "json" };
import a1 from "./app-definitions/github.json" with { type: "json" };
import a2 from "./app-definitions/slack.json" with { type: "json" };
import a3 from "./app-definitions/microsoft-teams.json" with { type: "json" };
import a4 from "./app-definitions/telegram.json" with { type: "json" };
import a5 from "./app-definitions/discord.json" with { type: "json" };
import a6 from "./app-definitions/notion.json" with { type: "json" };
import a7 from "./app-definitions/posthog.json" with { type: "json" };
import a8 from "./app-definitions/linear.json" with { type: "json" };
import a9 from "./app-definitions/context7.json" with { type: "json" };
import a10 from "./app-definitions/shopify.json" with { type: "json" };
import a11 from "./app-definitions/composio.json" with { type: "json" };
import a12 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a13 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a14 from "./app-definitions/sentry.json" with { type: "json" };
import a15 from "./app-definitions/vercel.json" with { type: "json" };
import a16 from "./app-definitions/anthropic.json" with { type: "json" };
import a17 from "./app-definitions/jira.json" with { type: "json" };
import a18 from "./app-definitions/airtable.json" with { type: "json" };
import a19 from "./app-definitions/beehiiv.json" with { type: "json" };
import a20 from "./app-definitions/bitly.json" with { type: "json" };
import a21 from "./app-definitions/candid.json" with { type: "json" };
import a22 from "./app-definitions/cloudflare.json" with { type: "json" };
import a23 from "./app-definitions/cloudinary.json" with { type: "json" };
import a24 from "./app-definitions/coda.json" with { type: "json" };
import a25 from "./app-definitions/hugging-face.json" with { type: "json" };
import a26 from "./app-definitions/kernel.json" with { type: "json" };
import a27 from "./app-definitions/local-falcon.json" with { type: "json" };
import a28 from "./app-definitions/make.json" with { type: "json" };
import a29 from "./app-definitions/manufact.json" with { type: "json" };
import a30 from "./app-definitions/miro.json" with { type: "json" };
import a31 from "./app-definitions/netlify.json" with { type: "json" };
import a32 from "./app-definitions/oreilly.json" with { type: "json" };
import a33 from "./app-definitions/planetscale.json" with { type: "json" };
import a34 from "./app-definitions/resend.json" with { type: "json" };
import a35 from "./app-definitions/ticktick.json" with { type: "json" };
import a36 from "./app-definitions/todoist.json" with { type: "json" };
import a37 from "./app-definitions/webflow.json" with { type: "json" };
import a38 from "./app-definitions/wix.json" with { type: "json" };
import a39 from "./app-definitions/brex.json" with { type: "json" };
import a40 from "./app-definitions/clickhouse.json" with { type: "json" };
import a41 from "./app-definitions/egnyte.json" with { type: "json" };
import a42 from "./app-definitions/embat.json" with { type: "json" };
import a43 from "./app-definitions/mixpanel.json" with { type: "json" };
import a44 from "./app-definitions/postman.json" with { type: "json" };
import a45 from "./app-definitions/razorpay.json" with { type: "json" };
import a46 from "./app-definitions/sanity.json" with { type: "json" };
import a47 from "./app-definitions/stripe.json" with { type: "json" };
import a48 from "./app-definitions/supabase.json" with { type: "json" };
import a49 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a50 from "./app-definitions/asana.json" with { type: "json" };
import a51 from "./app-definitions/box.json" with { type: "json" };
import a52 from "./app-definitions/mem0.json" with { type: "json" };
import a53 from "./app-definitions/pagerduty.json" with { type: "json" };
import a54 from "./app-definitions/similarweb.json" with { type: "json" };
import a55 from "./app-definitions/xero.json" with { type: "json" };
import a56 from "./app-definitions/gmail.json" with { type: "json" };
import a57 from "./app-definitions/google-drive.json" with { type: "json" };
import a58 from "./app-definitions/google-docs.json" with { type: "json" };
import a59 from "./app-definitions/google-sheets.json" with { type: "json" };
import a60 from "./app-definitions/google-slides.json" with { type: "json" };
import a61 from "./app-definitions/google-calendar.json" with { type: "json" };
import a62 from "./app-definitions/google-chat.json" with { type: "json" };
import a63 from "./app-definitions/google-people.json" with { type: "json" };
import a64 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a0 from "./app-definitions/agentmail.json" with { type: "json" };
import a1 from "./app-definitions/zapier.json" with { type: "json" };
import a2 from "./app-definitions/github.json" with { type: "json" };
import a3 from "./app-definitions/slack.json" with { type: "json" };
import a4 from "./app-definitions/microsoft-teams.json" with { type: "json" };
import a5 from "./app-definitions/telegram.json" with { type: "json" };
import a6 from "./app-definitions/discord.json" with { type: "json" };
import a7 from "./app-definitions/notion.json" with { type: "json" };
import a8 from "./app-definitions/posthog.json" with { type: "json" };
import a9 from "./app-definitions/linear.json" with { type: "json" };
import a10 from "./app-definitions/context7.json" with { type: "json" };
import a11 from "./app-definitions/shopify.json" with { type: "json" };
import a12 from "./app-definitions/composio.json" with { type: "json" };
import a13 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a14 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a15 from "./app-definitions/sentry.json" with { type: "json" };
import a16 from "./app-definitions/vercel.json" with { type: "json" };
import a17 from "./app-definitions/anthropic.json" with { type: "json" };
import a18 from "./app-definitions/jira.json" with { type: "json" };
import a19 from "./app-definitions/airtable.json" with { type: "json" };
import a20 from "./app-definitions/beehiiv.json" with { type: "json" };
import a21 from "./app-definitions/bitly.json" with { type: "json" };
import a22 from "./app-definitions/candid.json" with { type: "json" };
import a23 from "./app-definitions/cloudflare.json" with { type: "json" };
import a24 from "./app-definitions/cloudinary.json" with { type: "json" };
import a25 from "./app-definitions/coda.json" with { type: "json" };
import a26 from "./app-definitions/hugging-face.json" with { type: "json" };
import a27 from "./app-definitions/kernel.json" with { type: "json" };
import a28 from "./app-definitions/local-falcon.json" with { type: "json" };
import a29 from "./app-definitions/make.json" with { type: "json" };
import a30 from "./app-definitions/manufact.json" with { type: "json" };
import a31 from "./app-definitions/miro.json" with { type: "json" };
import a32 from "./app-definitions/netlify.json" with { type: "json" };
import a33 from "./app-definitions/oreilly.json" with { type: "json" };
import a34 from "./app-definitions/planetscale.json" with { type: "json" };
import a35 from "./app-definitions/resend.json" with { type: "json" };
import a36 from "./app-definitions/ticktick.json" with { type: "json" };
import a37 from "./app-definitions/todoist.json" with { type: "json" };
import a38 from "./app-definitions/webflow.json" with { type: "json" };
import a39 from "./app-definitions/wix.json" with { type: "json" };
import a40 from "./app-definitions/brex.json" with { type: "json" };
import a41 from "./app-definitions/clickhouse.json" with { type: "json" };
import a42 from "./app-definitions/egnyte.json" with { type: "json" };
import a43 from "./app-definitions/embat.json" with { type: "json" };
import a44 from "./app-definitions/mixpanel.json" with { type: "json" };
import a45 from "./app-definitions/postman.json" with { type: "json" };
import a46 from "./app-definitions/razorpay.json" with { type: "json" };
import a47 from "./app-definitions/sanity.json" with { type: "json" };
import a48 from "./app-definitions/stripe.json" with { type: "json" };
import a49 from "./app-definitions/supabase.json" with { type: "json" };
import a50 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a51 from "./app-definitions/asana.json" with { type: "json" };
import a52 from "./app-definitions/box.json" with { type: "json" };
import a53 from "./app-definitions/mem0.json" with { type: "json" };
import a54 from "./app-definitions/pagerduty.json" with { type: "json" };
import a55 from "./app-definitions/similarweb.json" with { type: "json" };
import a56 from "./app-definitions/xero.json" with { type: "json" };
import a57 from "./app-definitions/gmail.json" with { type: "json" };
import a58 from "./app-definitions/google-drive.json" with { type: "json" };
import a59 from "./app-definitions/google-docs.json" with { type: "json" };
import a60 from "./app-definitions/google-sheets.json" with { type: "json" };
import a61 from "./app-definitions/google-slides.json" with { type: "json" };
import a62 from "./app-definitions/google-calendar.json" with { type: "json" };
import a63 from "./app-definitions/google-chat.json" with { type: "json" };
import a64 from "./app-definitions/google-people.json" with { type: "json" };
import a65 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65] as AppDefinition[];

View File

@ -679,7 +679,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(40);
expect(APP_STORE_DEFINITIONS).toHaveLength(41);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
@ -691,7 +691,7 @@ describe("AppDefinition catalog", () => {
expect(storeSlugs.has(slug), slug).toBe(false);
}
});
it("ships complete local branding provenance for all 40 store-visible providers", () => {
it("ships complete local branding provenance for all 41 store-visible providers", () => {
const uiPublic = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../ui/public",
@ -711,14 +711,14 @@ describe("AppDefinition catalog", () => {
}>;
};
const visible = manifest.providers.filter((entry) => entry.catalogVisible);
expect(visible).toHaveLength(40);
expect(visible).toHaveLength(41);
expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty(
"size",
40,
41,
);
expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty(
"size",
40,
41,
);
expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual(
new Set(visible.map((entry) => entry.slug)),

View File

@ -4,6 +4,7 @@ import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-d
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"agentmail",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"zapier",
"slack",

View File

@ -0,0 +1,50 @@
{
"schemaVersion": 1,
"slug": "agentmail",
"name": "AgentMail",
"description": "Give agents email inboxes and handle each conversation as a task.",
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/agentmail.svg",
"darkLogoUrl": "/brands/apps/agentmail-dark.svg"
},
"urlPatterns": [
"https://console.agentmail.to/*"
],
"methods": [
{
"key": "email-agent",
"label": "Email with an agent",
"purpose": "channel",
"provider": "agentmail",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Assign an inbox to an agent and manage email conversations in tasks.",
"credentialFields": [
{
"key": "apiKey",
"label": "AgentMail API key",
"type": "password",
"placeholder": "am_…",
"required": true,
"secret": true
}
],
"guidanceMd": "Connect an AgentMail API key, then create or select an inbox for your agent. WebSocket receiving works without a public URL.",
"consoleLinks": {
"keys": "https://console.agentmail.to",
"docs": "https://docs.agentmail.to/inboxes"
},
"riskTier": "S3",
"requiredResourceFilters": [
"inbox"
]
}
]
}

View File

@ -358,6 +358,7 @@ export const ISSUE_ORIGIN_KINDS = [
"routine_execution",
"stale_active_run_evaluation",
"harness_liveness_escalation",
// Historical origin only; automatic productivity reviews have been retired.
"issue_productivity_review",
"stranded_issue_recovery",
"task_watchdog",

View File

@ -1122,8 +1122,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
IssueWatchdog,
IssueWatchdogStatus,
@ -2762,3 +2760,6 @@ export type { ExecutionContinuationEnvelope } from "./types/execution-continuati
export type { ExecutionProjection, ExecutionReconciliation, ExecutionBlocker } from "./types/execution-projection.js";
export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from "./types/execution-projection.js";
export * from "./types/email.js";
export * from "./validators/email.js";

View File

@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpos
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface ConnectionMethodDef { key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram" | "agentmail"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export type SelfServeMcpAuthMode =

View File

@ -11,6 +11,7 @@ export const ATTENTION_SOURCE_KINDS = [
"issue_thread_interaction",
"join_request",
"recovery_action",
// Legacy persisted decision sources remain readable; no feed items are generated.
"productivity_review",
"blocker_attention",
"review",

View File

@ -5,6 +5,7 @@ export const CHAT_PROVIDERS = [
"discord",
"microsoft-teams",
"telegram",
"agentmail",
] as const;
export type ChatProvider = (typeof CHAT_PROVIDERS)[number];
@ -202,10 +203,16 @@ export interface ChatEndpointSetupSecret {
webhookSecret: string;
}
export type ChannelPublicationMode = "automatic" | "explicit";
export type ExternalMessageExecutionPolicy = "restricted" | "agent";
export interface ChatEndpoint {
id: string;
companyId: string;
connectionId: string;
/** Older clients omit these fields; defaults are automatic/restricted. */
publicationMode?: ChannelPublicationMode;
externalExecutionPolicy?: ExternalMessageExecutionPolicy;
provider: ChatProvider;
publicId: string;
status: ChatEndpointStatus;

View File

@ -0,0 +1,54 @@
export interface EmailEnvelope {
from: string;
to: string[];
cc?: string[];
bcc?: string[];
replyTo?: string[];
subject: string;
}
export type EmailDeliveryOutcome =
| "queued"
| "sent"
| "delivered"
| "failed"
| "uncertain";
export interface EmailMessage extends EmailEnvelope {
id: string;
providerMessageId: string;
direction: "inbound" | "outbound";
text: string;
fullText: string;
commentId: string | null;
attachmentIds: string[];
timestamp: string;
automatic: boolean;
}
export interface EmailEndpointSummary {
id: string;
companyId: string;
connectionId: string;
assignedAgentId: string;
address: string | null;
status: string;
receiveMode: "websocket" | "webhook";
lastError: string | null;
lastSyncAt: string | null;
}
export interface EmailPublicationSummary {
request?: import("../validators/email.js").EmailSendInput;
createdAt?: string;
id: string;
issueId: string;
conversationId: string;
outcome: EmailDeliveryOutcome;
error: string | null;
providerMessageId: string | null;
}
export interface EmailThreadSummary {
conversationId: string;
issueId: string;
endpoint: EmailEndpointSummary;
subject: string;
messages: EmailMessage[];
publications: EmailPublicationSummary[];
}

View File

@ -683,8 +683,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
@ -1066,3 +1064,5 @@ export type {
} from "./plugin.js";
export * from "./app-definition.js";
export * from "./chat-channels.js";
export * from "./email.js";

View File

@ -537,22 +537,6 @@ export interface IssueUnblockDescriptor {
action: string;
}
export type IssueProductivityReviewTrigger =
| "no_comment_streak"
| "long_active_duration"
| "high_churn";
export interface IssueProductivityReview {
reviewIssueId: string;
reviewIdentifier: string | null;
status: IssueStatus;
priority: IssuePriority;
trigger: IssueProductivityReviewTrigger | null;
noCommentStreak: number | null;
createdAt: Date;
updatedAt: Date;
}
export interface IssueRecoveryAction {
id: string;
companyId: string;
@ -846,7 +830,6 @@ export interface Issue {
unblockDescriptor?: IssueUnblockDescriptor | null;
blockedTransitionAt?: Date | null;
blockedOwnerNotifiedAt?: Date | null;
productivityReview?: IssueProductivityReview | null;
activeRecoveryAction?: IssueRecoveryAction | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
executionBlocker?: ExecutionBlocker | null;
@ -919,7 +902,6 @@ export type CompactIssue = Pick<
blockerAttention?: IssueBlockerAttention;
reviewAttention?: IssueReviewAttention;
blockedInboxAttention?: IssueBlockedInboxAttention | null;
productivityReview?: IssueProductivityReview | null;
scheduledRetry?: IssueScheduledRetry | null;
liveDescendantCount?: number;
myLastTouchAt?: Date | null;

View File

@ -242,6 +242,8 @@ export const resetAgentSessionSchema = z.object({
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
/** Saved agent whose redacted environment entries are restored for this probe. */
agentId: z.string().guid().optional(),
/** One-shot provider keys for a probe. Never persist these in agent config. */
testCredentials: z.object({
ANTHROPIC_API_KEY: z.string().max(16384),

View File

@ -5,6 +5,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
try{return new URL(value).protocol==="https:";}catch{return false;}
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:toolConnectionPurposeSchema.optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram"]).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{const purpose=v.purpose??"tool";if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk")c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:toolConnectionPurposeSchema.optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail"]).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{const purpose=v.purpose??"tool";if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});

View File

@ -69,7 +69,7 @@ const chatEndpointCredentialsSchema = z
export const createChatEndpointSchema = z
.object({
provider: chatProviderSchema,
provider: chatProviderSchema.exclude(["agentmail"]),
assignedAgentId: z.string().uuid(),
applicationId: z.string().uuid().optional(),
name: z.string().trim().min(1).max(160).optional(),

View File

@ -0,0 +1,75 @@
import { z } from "zod";
const address = z.string().trim().email().max(320);
const addresses = z.array(address).max(50);
export const emailEndpointSetupSchema = z
.object({
assignedAgentId: z.string().uuid(),
applicationId: z.string().uuid().optional(),
apiKey: z.string().min(1).max(4096).optional(),
credentialConnectionId: z.string().uuid().optional(),
inboxId: address.optional(),
username: z
.string()
.regex(/^[a-zA-Z0-9._-]+$/)
.max(64)
.optional(),
domain: z.string().max(253).optional(),
receiveMode: z.enum(["websocket", "webhook"]).default("websocket"),
idempotencyKey: z.string().uuid(),
})
.strict()
.refine((v) => Boolean(v.apiKey) !== Boolean(v.credentialConnectionId), {
message: "Supply an API key or a saved connection, not both",
});
export const emailSendSchema = z
.object({
endpointId: z.string().uuid(),
parentIssueId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
replyToMessageId: z.string().min(1).max(998).optional(),
replyAll: z.boolean().default(false),
to: addresses.optional(),
cc: addresses.optional(),
bcc: addresses.optional(),
subject: z
.string()
.trim()
.min(1)
.max(998)
.regex(/^[^\r\n]+$/)
.optional(),
text: z.string().trim().min(1).max(100_000),
attachmentIds: z.array(z.string().uuid()).max(20).default([]),
idempotencyKey: z.string().uuid(),
})
.strict()
.superRefine((v, ctx) => {
const fail = (message: string) => ctx.addIssue({ code: "custom", message });
if (v.conversationId) {
if (!v.replyToMessageId) fail("A reply requires its exact message ID");
if (v.parentIssueId || v.to || v.cc || v.bcc || v.subject)
fail(
"Reply recipients come from the original message; use replyAll explicitly",
);
} else {
if (!v.parentIssueId || !v.to?.length || !v.subject)
fail("A new email requires a parent task, recipient, and subject");
if (v.replyToMessageId || v.replyAll)
fail("A new email cannot be a reply");
}
});
export type EmailEndpointSetupInput = z.infer<typeof emailEndpointSetupSchema>;
export type EmailSendInput = z.infer<typeof emailSendSchema>;
export const emailConnectionSchema = z
.object({
apiKey: z.string().min(1).max(4096),
grantKind: z.enum(["user", "organization"]).default("user"),
allAgents: z.boolean().default(false),
agentIds: z.array(z.string().uuid()).max(500).default([]),
idempotencyKey: z.string().uuid(),
})
.strict();
export type EmailConnectionInput = z.infer<typeof emailConnectionSchema>;

View File

@ -977,3 +977,5 @@ export * from "./skill-policy.js";
export * from "./provider-trace.js";
export * from "./app-definition.js";
export * from "./chat-channels.js";
export * from "./email.js";

View File

@ -274,7 +274,6 @@
"server/src/__tests__/plugin-worker-manager.test.ts": 2669,
"server/src/__tests__/private-hostname-guard.test.ts": 300,
"server/src/__tests__/private-json-etag.test.ts": 271,
"server/src/__tests__/productivity-review-service.test.ts": 11098,
"server/src/__tests__/project-icon-persistence.test.ts": 3936,
"server/src/__tests__/project-list-metrics.test.ts": 1198,
"server/src/__tests__/project-shortname-resolution.test.ts": 1196,

View File

@ -177,6 +177,12 @@ const posthogMethod = (key, auth, extra = {}) =>
{ tenantFields: posthogConfigFields(), ...extra },
);
const apps = [
["agentmail", "AgentMail", "Give agents email inboxes and handle each conversation as a task.", "communication", "agentmail.to", ["https://console.agentmail.to/*"], {
key: "email-agent", label: "Email with an agent", purpose: "channel", provider: "agentmail", transport: "rest_api", auth: "api_key", ownershipModes: ["customer"],
whenToUse: "Assign an inbox to an agent and manage email conversations in tasks.", credentialFields: [{ key: "apiKey", label: "AgentMail API key", type: "password", placeholder: "am_…", required: true, secret: true }],
guidanceMd: "Connect an AgentMail API key, then create or select an inbox for your agent. WebSocket receiving works without a public URL.",
consoleLinks: { keys: "https://console.agentmail.to", docs: "https://docs.agentmail.to/inboxes" }, riskTier: "S3", requiredResourceFilters: ["inbox"]
}],
[
"zapier",
"Zapier",

View File

@ -45,8 +45,8 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1122.0",
"@chat-adapter/github": "4.39.0",
"@chat-adapter/discord": "4.39.0",
"@chat-adapter/github": "4.39.0",
"@chat-adapter/slack": "4.39.0",
"@chat-adapter/teams": "4.39.0",
"@chat-adapter/telegram": "4.39.0",
@ -90,6 +90,7 @@
"sharp": "^0.35.4",
"smol-toml": "^1.4.2",
"ssh2": "^1.17.0",
"svix": "1.76.1",
"ws": "^8.21.3",
"zod": "^4.4.3"
},

View File

@ -536,6 +536,52 @@ describe("agent routes adapter validation", () => {
expect(String(env.CODEX_HOME)).toContain(`/companies/company-1/agents/${agentId}/codex-home`);
});
it("restores a saved agent's redacted CODEX_HOME before testing its adapter", async () => {
const agentId = "11111111-1111-4111-8111-111111111111";
const storedHome = "/paperclip/companies/company-1/agents/agent-1/codex-home";
mockAgentService.getById.mockResolvedValue({
...(await mockAgentService.getById()),
id: agentId,
adapterType: "external_test",
adapterConfig: { env: { CODEX_HOME: storedHome } },
});
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
agentId,
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
{ env: { CODEX_HOME: storedHome } },
expect.objectContaining({ adapterType: "external_test" }),
);
});
it("rejects redacted-value restoration from an incompatible saved agent", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
agentId: "11111111-1111-4111-8111-111111111111",
adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } } },
}),
);
expect(res.status).toBe(422);
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>

View File

@ -0,0 +1,216 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { Webhook } from "svix";
import {
agentmailApi,
agentmailMessageSchema,
emailText,
emailReplyRecipients,
isAutomaticEmail,
isFilteredEmail,
normalizeAgentmailEvent,
verifyAgentmailWebhook,
} from "../services/agentmail-api.js";
import { emailSendSchema } from "@paperclipai/shared";
import { buildRunnerApiCatalog } from "../services/native-runtime/runner-api-catalog.js";
const message = (extra = {}) =>
agentmailMessageSchema.parse({
inbox_id: "agent@agentmail.to",
thread_id: "thread",
message_id: "message",
timestamp: new Date().toISOString(),
...extra,
});
describe("AgentMail protocol boundary", () => {
it("verifies the exact raw body and rejects forged or stale Svix signatures", () => {
const secret = `whsec_${Buffer.from("a-test-secret-only").toString("base64")}`;
const body = JSON.stringify({
event_type: "message.received",
message: message(),
});
const timestamp = new Date();
const id = randomUUID();
const headers = {
"svix-id": id,
"svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)),
"svix-signature": new Webhook(secret).sign(id, timestamp, body),
};
expect(verifyAgentmailWebhook(Buffer.from(body), headers, secret)).toEqual(
JSON.parse(body),
);
expect(() =>
verifyAgentmailWebhook(Buffer.from(body + " "), headers, secret),
).toThrow();
expect(() =>
verifyAgentmailWebhook(
Buffer.from(body),
{ ...headers, "svix-timestamp": "1" },
secret,
),
).toThrow();
});
it("normalizes both transports and keeps delivery receipts separate from incoming mail", () => {
const m = { inbox_id: "inbox", message_id: "message" };
expect(
normalizeAgentmailEvent({ event_type: "message.received", message: m })
?.kind,
).toBe("message.received");
expect(
normalizeAgentmailEvent({ type: "message_received", message: m })?.kind,
).toBe("message.received");
expect(
normalizeAgentmailEvent({ type: "message_delivered", message: m })?.kind,
).toBe("message.delivered");
expect(normalizeAgentmailEvent({ type: "subscribed" })).toBeNull();
expect(() =>
normalizeAgentmailEvent({ event_type: "message.received", message: {} }),
).toThrow();
});
it.each([
["message.sent", "send"],
["message.delivered", "delivery"],
["message.bounced", "bounce"],
["message.complained", "complaint"],
["message.rejected", "reject"],
])("admits the documented %s receipt envelope through either transport", (kind, field) => {
for (const transport of [{ type: "event", event_type: kind }, { type: kind.replace(".", "_") }]) {
expect(normalizeAgentmailEvent({
...transport,
event_id: "provider-event",
[field]: { inbox_id: "inbox", thread_id: "thread", message_id: "sent-message" },
})).toEqual({ kind, inbox_id: "inbox", message_id: "sent-message", eventId: "provider-event" });
}
});
it("prefers extracted text, strips HTML and recognizes provider filtering and auto-replies", () => {
expect(
emailText(
message({ extracted_text: "New reply", text: "Quoted history" }),
),
).toBe("New reply");
expect(
emailText(
message({
html: '<script>alert(1)</script><img src="https://tracking.test"><p>Hello</p>',
}),
),
).not.toContain("tracking.test");
expect(
isAutomaticEmail(
message({ headers: { "Auto-Submitted": "auto-replied" } }),
),
).toBe(true);
expect(
isAutomaticEmail(message({ headers: { "Auto-Submitted": "no" } })),
).toBe(false);
for (const label of ["spam", "blocked", "unauthenticated"])
expect(isFilteredEmail(message({ labels: [label] }))).toBe(true);
});
it("pins the API host, encodes message IDs and preserves the provider idempotency key", async () => {
const fetcher = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ message_id: "sent", thread_id: "thread" }),
),
);
await agentmailApi("private-key", fetcher).send(
"agent@agentmail.to",
{ text: "Reply", reply_all: false },
"stable-key",
"<message@domain>",
);
expect(fetcher).toHaveBeenCalledWith(
"https://api.agentmail.to/v0/inboxes/agent%40agentmail.to/messages/%3Cmessage%40domain%3E/reply",
expect.objectContaining({
redirect: "error",
headers: expect.objectContaining({ "Idempotency-Key": "stable-key" }),
}),
);
});
it("redacts provider error bodies", async () => {
const fetcher = vi
.fn()
.mockResolvedValue(
new Response("private email and credentials", { status: 403 }),
);
await expect(agentmailApi("private-key", fetcher).whoami()).rejects.toThrow(
"AgentMail request failed (403)",
);
});
it("constructs deliberate reply-all from visible recipients, excluding self and Bcc", () => {
const envelope = {
from: "Sender <sender@example.test>",
to: ["agent@agentmail.to", "visible@example.test"],
cc: ["visible@example.test", "cc@example.test"],
bcc: ["private@example.test"],
subject: "Hello",
};
expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual(
{ to: ["sender@example.test"], cc: [], bcc: [], reply_all: false },
);
expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({
to: ["sender@example.test", "visible@example.test"],
cc: ["cc@example.test"],
bcc: [],
reply_all: false,
});
});
it("honors Reply-To for reply and reply-all without adding the forwarding sender or Bcc", () => {
const envelope = {
from: "Forwarder <forwarder@example.test>",
replyTo: ["Reply desk <reply@example.test>", "agent@agentmail.to"],
to: ["agent@agentmail.to", "visible@example.test"],
cc: ["reply@example.test", "cc@example.test"],
bcc: ["private@example.test"],
subject: "Forwarded request",
};
expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual({
to: ["reply@example.test"], cc: [], bcc: [], reply_all: false,
});
expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({
to: ["reply@example.test", "visible@example.test"], cc: ["cc@example.test"], bcc: [], reply_all: false,
});
expect(emailReplyRecipients({ ...envelope, replyTo: [] }, "agent@agentmail.to", false).to)
.toEqual(["forwarder@example.test"]);
});
it("validates explicit new-message and reply envelopes, rejecting header injection and Bcc reuse", () => {
const base = {
endpointId: randomUUID(),
idempotencyKey: randomUUID(),
text: "Hello",
};
expect(
emailSendSchema.safeParse({
...base,
parentIssueId: randomUUID(),
to: ["person@example.test"],
subject: "Hi\r\nBcc: hidden@example.test",
}).success,
).toBe(false);
expect(
emailSendSchema.safeParse({
...base,
conversationId: randomUUID(),
replyToMessageId: "message",
bcc: ["hidden@example.test"],
}).success,
).toBe(false);
expect(
emailSendSchema.parse({
...base,
conversationId: randomUUID(),
replyToMessageId: "message",
}).replyAll,
).toBe(false);
});
it("exposes explicit email actions in runtime API discovery and keeps credential setup board-only", () => {
const operations = buildRunnerApiCatalog();
const send = operations.find(o => o.path === "/api/companies/{companyId}/email/send");
expect(send?.method).toBe("POST"); expect(send?.requestBody).toBeDefined();
expect(send?.responses).toHaveProperty("202");
const setup = operations.find(o => o.path === "/api/companies/{companyId}/email/inspect");
expect(JSON.stringify(setup?.authorization)).toContain("board");
});
});

View File

@ -616,13 +616,13 @@ describeEmbeddedPostgres("attention service", () => {
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.totalCount).toBe(12);
expect(feed.totalCount).toBe(11);
expect(feed.countsBySourceKind).toMatchObject({
approval: 1,
issue_thread_interaction: 1,
join_request: 1,
recovery_action: 1,
productivity_review: 1,
productivity_review: 0,
blocker_attention: 1,
review: 2,
failed_run: 1,
@ -634,7 +634,6 @@ describeEmbeddedPostgres("attention service", () => {
"issue_thread_interaction",
"join_request",
"recovery_action",
"productivity_review",
"blocker_attention",
"review",
"failed_run",
@ -651,7 +650,14 @@ describeEmbeddedPostgres("attention service", () => {
expect(item.rank).toBeGreaterThan(0);
}
expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
expect(feed.items.some((item) => item.sourceKind === "productivity_review")).toBe(false);
expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
const legacyReviews = await db.select().from(issues).where(eq(issues.originKind, "issue_productivity_review"));
expect(legacyReviews).toHaveLength(2);
expect(legacyReviews).toEqual(expect.arrayContaining([
expect.objectContaining({ title: "Human productivity review", status: "todo", assigneeUserId: "board-user", parentId: productivitySourceIssueId }),
expect.objectContaining({ title: "Agent productivity review excluded", status: "todo", assigneeAgentId: workerId, parentId: agentProductivitySourceIssueId }),
]));
expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
expect(feed.items.some((item) =>
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"

View File

@ -3,10 +3,12 @@ import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
const html = '<html><body><div id="root"></div></body></html>';
const snippet = '<script src="https://example.com/widget.js"></script>';
const encoded = Buffer.from(snippet, "utf-8").toString("base64");
describe("Cloud UI snippet", () => {
it("leaves self-hosted HTML unchanged even when a snippet is configured", () => {
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html);
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded })).toBe(html);
});
it.each([
@ -27,4 +29,55 @@ describe("Cloud UI snippet", () => {
expect(result).toContain(script);
expect(result).not.toContain("test-token");
});
it("decodes a base64 snippet on a Cloud instance", () => {
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
})).toBe(html.replace("</body>", `${snippet}\n</body>`));
});
it("tolerates whitespace and line wrapping in the base64 value", () => {
const wrapped = ` ${encoded.slice(0, 20)}\n${encoded.slice(20)}\n`;
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: wrapped,
})).toBe(html.replace("</body>", `${snippet}\n</body>`));
});
it("prefers the plain snippet when both variables are set", () => {
const other = Buffer.from("<script>other()</script>", "utf-8").toString("base64");
const result = injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}",
PAPERCLIP_CLOUD_UI_SNIPPET: snippet,
PAPERCLIP_CLOUD_UI_SNIPPET_B64: other,
});
expect(result).toContain(snippet);
expect(result).not.toContain("other()");
});
it("treats a blank plain variable as disabled even when a base64 value is set", () => {
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}",
PAPERCLIP_CLOUD_UI_SNIPPET: " ",
PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
})).toBe(html);
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}",
PAPERCLIP_CLOUD_UI_SNIPPET: "",
PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded,
})).toBe(html);
});
it.each([
{ label: "invalid characters", value: "!!!not-base64!!!" },
{ label: "wrong length", value: "abcde" },
{ label: "unpadded", value: Buffer.from("<b>x</b>", "utf-8").toString("base64").replace(/=+$/, "") },
{ label: "carrying nonzero padding bits", value: "PB==" },
{ label: "not valid UTF-8 once decoded", value: "/w==" },
{ label: "blank once decoded", value: Buffer.from(" \n ", "utf-8").toString("base64") },
{ label: "blank", value: " " },
])("ignores a base64 value that is $label", ({ value }) => {
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: value,
})).toBe(html);
});
});

View File

@ -1440,6 +1440,68 @@ process.exit(1);
}
});
it("isolates connector skills by agent and revision without changing the selected model identity", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-connector-codex-"));
const workspace = path.join(root, "workspace");
const command = path.join(root, "codex");
const capture = path.join(root, "capture.json");
const sourceHome = path.join(root, "selected-account");
const skillSource = path.join(root, "skill-v1");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(sourceHome, "auth.json"), fakeCodexAuthJson);
await fs.writeFile(path.join(skillSource, "SKILL.md"), "# AgentMail\nAssigned inbox one.");
await writeFakeCodexCommand(command);
const keys = ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"] as const;
const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
process.env.PAPERCLIP_INSTANCE_ID = "connectors";
process.env.CODEX_HOME = sourceHome;
const invoke = async (agentId: string, digest: string | null, source = skillSource, connectorSkillInstructions = "") => {
const config = {
engine: "cli", command, cwd: workspace,
env: { CODEX_HOME: sourceHome, PAPERCLIP_TEST_CAPTURE_PATH: capture },
paperclipConnectorSkillDigest: digest,
paperclipSkillSync: { desiredSkills: digest ? ["paperclipai/paperclip/agentmail"] : [] },
paperclipRuntimeSkills: digest ? [{ key: "paperclipai/paperclip/agentmail", runtimeName: "agentmail", source }] : [],
};
const result = await execute({ runId: `run-${agentId}-${digest?.slice(0, 1) ?? "none"}`,
agent: { id: agentId, companyId: "company-1", name: "Email agent", adapterType: "codex_local", adapterConfig: config },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config, context: { paperclipWake: { connectorSkillInstructions } }, authToken: "test-token", onLog: async () => {},
});
expect(result.errorMessage).toBeNull();
expect(result.exitCode).toBe(0);
return JSON.parse(await fs.readFile(capture, "utf8")) as CapturePayload;
};
try {
const first = await invoke("agent-1", "a".repeat(64));
expect(first.codexHome).toContain("connector-runtimes/agent-1/");
expect(await fs.realpath(path.join(first.codexHome!, "auth.json"))).toBe(await fs.realpath(path.join(sourceHome, "auth.json")));
expect(await fs.readFile(path.join(first.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox one");
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
const other = await invoke("agent-2", "a".repeat(64));
expect(other.codexHome).not.toBe(first.codexHome);
const nextSource = path.join(root, "skill-v2");
await fs.mkdir(nextSource);
await fs.writeFile(path.join(nextSource, "SKILL.md"), "# AgentMail\nAssigned inbox two.");
const next = await invoke("agent-1", "b".repeat(64), nextSource);
expect(next.codexHome).not.toBe(first.codexHome);
expect(await fs.readFile(path.join(next.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox two");
const inline = await invoke("agent-1", null, skillSource, "# AgentMail\nAssigned inbox inline@example.test");
expect(inline.prompt).toContain("Assigned inbox inline@example.test");
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
const removed = await invoke("agent-1", null);
expect(removed.prompt).not.toContain("inline@example.test");
expect(removed.codexHome).toBe(sourceHome);
await expect(fs.stat(path.join(removed.codexHome!, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
} finally {
for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; }
await fs.rm(root, { recursive: true, force: true });
}
});
it("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
const workspace = path.join(root, "workspace");

View File

@ -418,8 +418,8 @@ describeEmbeddedPostgres("company import batches inserts", () => {
.from(issues)
.where(eq(issues.companyId, companyId));
expect(imported?.status).toBe("in_progress");
// A fabricated import-time startedAt made carried-over work look hours
// stale to duration-based sweeps (e.g. the productivity review).
// An import-time startedAt would misrepresent carried-over work
// as a newly started active episode.
expect(imported?.startedAt).toBeNull();
});

File diff suppressed because it is too large Load Diff

View File

@ -1244,7 +1244,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
};
}
if (method === "environmentDestroyLease") {
return undefined;
return { providerLeaseId: "plugin-lease-1", state: "destroyed" };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
@ -1276,6 +1276,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(leaseRows).toHaveLength(1);
expect(leaseRows[0]?.status).toBe("expired");
expect(leaseRows[0]?.cleanupStatus).toBe("success");
expect(leaseRows[0]?.metadata?.remoteExecutionTermination).toMatchObject({
companyId, runId, leaseId: leaseRows[0]!.id, providerLeaseId: "plugin-lease-1", state: "destroyed",
});
// The acquire provisioned the remote plugin sandbox, so it destroys the
// sandbox on the rejection. Without this teardown the rejected insert leaks a
@ -3125,7 +3128,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const lease = await environmentService(db).getLeaseById(orphan.id);
await runtimeWithPlugin.retryPendingSandboxTeardown({ environment: null, lease: lease! });
vi.mocked(workerManager.call).mockImplementationOnce(async (_pluginId, _method, args: any) => {
destroyConfigs.push(args.config);
return { providerLeaseId: args.providerLeaseId, state: "destroyed" };
});
await expect(runtimeWithPlugin.retryPendingSandboxTeardown({ environment: null, lease: lease! }))
.resolves.toEqual({ providerLeaseId: orphan.providerLeaseId, state: "destroyed" });
expect(destroyConfigs).toHaveLength(1);
// The recorded secret ref resolved to the old credential, and the resolved
// value reached the provider teardown instead of the secret ref.
@ -6339,7 +6347,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentDestroyLease") {
return undefined;
return { providerLeaseId: reusableLease.providerLeaseId, state: "destroyed" };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
@ -6366,6 +6374,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
status: "expired",
failureReason: "environment_deleted",
cleanupStatus: "success",
metadata: { remoteExecutionTermination: { schema: "paperclip.remote-termination.v1",
leaseId: reusableLease.id, runId, providerLeaseId: reusableLease.providerLeaseId, state: "destroyed" } },
});
});

View File

@ -19,6 +19,8 @@
// misdelivering it. `omitHostRouteId: true` sends the notification with no
// `hostRouteId` field at all, so a test proves the host warns about a plugin
// build old enough to omit the field, instead of silently dropping it.
// - `emitOnInput`: when true, wait for the first input before sending scripted
// notifications, so legacy routing tests know the worker session is bound.
// - `exitCode`: when set, the fixture emits an exit notification after the outputs.
// - `omitHostRouteIdOnExit`: when true, the main `exitCode` exit notification
// carries no `hostRouteId` field, so a test proves the host still resolves
@ -184,7 +186,13 @@ rl.on("line", (line) => {
const mode = directive.mode ?? "normal";
const workerSessionId = directive.workerSessionId ?? "ws-1";
const closeMode = directive.closeMode ?? "ack";
routes.set(params.hostRouteId, { workerSessionId, closeMode });
routes.set(params.hostRouteId, {
workerSessionId,
closeMode,
pendingOutput: directive.emitOnInput === true
? scriptedOutputLines(directive, params.hostRouteId, workerSessionId)
: null,
});
if (mode === "no-open-reply") {
// Never reply, so the host open call times out.
@ -230,6 +238,8 @@ rl.on("line", (line) => {
return;
}
if (directive.emitOnInput === true) return;
// Emit the scripted output and the exit after the open reply, so the host
// binds the route first.
setImmediate(() => {
@ -243,6 +253,11 @@ rl.on("line", (line) => {
// test proves the input reaches the worker and the output routes back.
for (const [hostRouteId, entry] of routes.entries()) {
if (entry.workerSessionId === params.workerSessionId) {
if (entry.pendingOutput !== null) {
process.stdout.write(entry.pendingOutput);
entry.pendingOutput = null;
continue;
}
send({
jsonrpc: "2.0",
method: "loginPty.output",

View File

@ -230,7 +230,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(manager?.status).toBe("idle");
}
it("keeps blocked and recovery-origin sources artifact-free", async () => {
it.each(["stale_active_run_evaluation", "issue_productivity_review"])("keeps blocked and %s sources artifact-free", async (originKind) => {
const now = new Date("2026-04-22T20:00:00.000Z");
const blocked = await seedRunningRun({
now,
@ -240,7 +240,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
const recursive = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceOriginKind: "stale_active_run_evaluation",
sourceOriginKind: originKind,
});
const { enqueueWakeup, recovery } = createRecovery();

View File

@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
import { issueService } from "../services/issues.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js";
import { spawn, type ChildProcess } from "node:child_process";
@ -10139,20 +10140,48 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(issue?.executionRunId).toBeNull();
});
it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => {
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "I will inspect the repo next and then implement the fix.",
provider: "test",
model: "test-model",
});
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
it.each([false, true])("enqueues one bounded plan-only continuation with legacy productivity review present: %s", async (withLegacyReview) => {
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
const legacyReviewId = randomUUID();
if (withLegacyReview) {
await db.insert(issues).values({
id: legacyReviewId,
companyId,
title: "Historical productivity review",
description: "Keep this review and its existing ownership unchanged.",
status: "todo",
assigneeUserId: "responsible-user",
parentId: issueId,
originKind: "issue_productivity_review",
originId: issueId,
originFingerprint: `productivity-review:${issueId}`,
});
}
const legacyReviewBefore = withLegacyReview
? await db.select().from(issues).where(eq(issues.id, legacyReviewId))
: [];
mockAdapterExecute.mockImplementationOnce(async () => {
if (withLegacyReview) {
// These pre-dispatch cancellations used to satisfy both the no-comment
// and churn thresholds and suppress an otherwise valid continuation.
await db.insert(heartbeatRuns).values(Array.from({ length: 10 }, (_, index) => ({
id: randomUUID(), companyId, agentId,
invocationSource: "automation", triggerDetail: "system", status: "cancelled",
errorCode: "execution_reconciliation_required",
contextSnapshot: { issueId, taskId: issueId },
createdAt: new Date(Date.now() - (index + 1) * 60_000),
finishedAt: new Date(),
})));
}
return {
exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "I will inspect the repo next and then implement the fix.",
provider: "test", model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.reconcileStrandedAssignedIssues();
@ -10189,6 +10218,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
expect(sourceRun?.id).not.toBe(runId);
expect(sourceRun?.livenessState).toBe("plan_only");
if (withLegacyReview) {
expect(await db.select().from(issues).where(eq(issues.id, legacyReviewId))).toEqual(legacyReviewBefore);
const source = (await issueService(db).list(companyId)).find((issue) => issue.id === issueId);
expect(source).toBeDefined();
expect(source).not.toHaveProperty("productivityReview");
}
});
it("treats a plan document update as progress and does not enqueue liveness continuation", async () => {

View File

@ -267,10 +267,10 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "productivity_review",
reason: "scheduled_maintenance",
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: { wakeReason: "productivity_review" },
contextSnapshot: { wakeReason: "scheduled_maintenance" },
});
expect(run).not.toBeNull();

View File

@ -7,6 +7,8 @@ import {
createDb,
heartbeatRunEvents,
heartbeatRuns,
environmentLeases,
environments,
issues,
} from "@paperclipai/db";
import {
@ -299,6 +301,23 @@ describeEmbeddedPostgres("heartbeat teardown terminalizes the run before releasi
expect(releaseRunLeases).not.toHaveBeenCalled();
});
it.each(["daytona", "local"])("destroy after failed checkpoint requires a terminal remote owner: %s", async provider => {
const { companyId, agentId, runId } = await seed({ issueStatus: "blocked", runStatus: "running" });
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
const [environment] = await db.insert(environments).values({ name: `cleanup-${runId}`, driver: "sandbox" }).returning();
await db.insert(environmentLeases).values({ companyId, environmentId: environment.id,
heartbeatRunId: runId, agentId, provider, providerLeaseId: "sandbox-owned",
status: "active", leasePolicy: "ephemeral" });
const releaseRunLeases = vi.fn(async () => []);
const heartbeat = heartbeatService(db, {
environmentRuntime: { releaseRunLeases } as unknown as HeartbeatEnvironmentRuntime,
closeWarmNativeSessionsForRun: async () => ({ closed: 0, busy: 0, failed: 1 }),
});
await heartbeat.releaseEnvironmentLeasesForRun({ runId, companyId, agentId,
status: "cancelled", providerResourceDisposition: "destroy" });
expect(releaseRunLeases).toHaveBeenCalledTimes(provider === "daytona" ? 1 : 0);
});
it("terminalizes a running run to succeeded before release when the issue reached done", async () => {
const { companyId, agentId, issueId, runId } = await seed({ issueStatus: "done", runStatus: "running" });

View File

@ -2026,11 +2026,12 @@ describe("agent issue mutation checkout ownership", () => {
expect.soft(mockLogActivity).not.toHaveBeenCalled();
// This is a route-boundary test, not a fake SQL engine: inspect the
// actual compiled predicate so a global or active-only query cannot pass.
// Only restricted chat bindings use this recovery gate; email uses normal agent work.
expect(db.chatBindingQueries).toHaveLength(1);
expect(db.chatBindingQueries[0].sql).toBe(
'("chat_conversations"."company_id" = $1 and "chat_conversations"."issue_id" = $2)',
'("chat_endpoints"."external_execution_policy" = $1 and "chat_conversations"."company_id" = $2 and "chat_conversations"."issue_id" = $3)',
);
expect(db.chatBindingQueries[0].params).toEqual([companyId, issueId]);
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
},
);
@ -2157,7 +2158,7 @@ describe("agent issue mutation checkout ownership", () => {
).toHaveBeenCalledExactlyOnceWith(chatRetryActionId);
expect(order).toEqual(["begin", "stage", "commit", "dispatch"]);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(db.chatBindingQueries[0].params).toEqual([companyId, issueId]);
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
});
it("keeps committed recovery resolution successful when immediate dispatch rejects", async () => {

View File

@ -13,7 +13,6 @@ const mockIssueService = vi.hoisted(() => ({
getComment: vi.fn(),
listBlockerAttention: vi.fn(),
listReviewAttention: vi.fn(),
listProductivityReviews: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
getActiveInboxArchiveFields: vi.fn(),
listAttachments: vi.fn(),
@ -212,7 +211,6 @@ describe.sequential("issue goal context routes", () => {
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.getActiveInboxArchiveFields.mockResolvedValue({});
mockIssueService.listAttachments.mockResolvedValue([]);
@ -270,6 +268,24 @@ describe.sequential("issue goal context routes", () => {
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
});
it.each(["", "/heartbeat-context"])("reads historical review tasks without computed productivity fields: %s", async (suffix) => {
mockIssueService.getById.mockResolvedValue({
...legacyProjectLinkedIssue,
originKind: "issue_productivity_review",
originId: "historical-source",
});
const res = await request(createApp()).get(`/api/issues/${legacyProjectLinkedIssue.id}${suffix}`);
expect(res.status).toBe(200);
const issue = suffix ? res.body.issue : res.body;
expect(issue).toMatchObject({
originKind: "issue_productivity_review",
originId: "historical-source",
assigneeAgentId: legacyProjectLinkedIssue.assigneeAgentId,
status: legacyProjectLinkedIssue.status,
});
expect(issue).not.toHaveProperty("productivityReview");
});
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import {
agents,
@ -63,6 +63,8 @@ import {
reconcileNativeFinalizations,
resolveNativeReconciliationStatus,
} from "../services/native-runtime/native-finalization-reconciler.js";
import * as activityLog from "../services/activity-log.js";
import { dismissObsoleteNativePolicyReviews } from "../services/native-runtime/obsolete-policy-reviews.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@ -242,11 +244,11 @@ const nativeStatusEffectKinds = new Set<NativeStatusEffect["kind"]>([
const supersedingDecisionStates = new Set([
"new_evidence_satisfies_contract", "dependency_now_done", "explicit_resume_capability",
"board_cancelled_before_cas", "new_policy_requires_review", "authorized_writer_incremented_version",
"board_cancelled_before_cas", "authorized_writer_incremented_version",
]);
const liveReconciliationStates = new Set([
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "new_policy_requires_review",
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "policy_version_changed",
]);
function initialRunStatus(fixture: Fixture) {
@ -319,7 +321,6 @@ function reconciliationFactsFor(completionState: string) {
case "new_evidence_satisfies_contract": return { newEvidenceSatisfiesContract: true };
case "dependency_now_done": return { dependencyResolved: true };
case "explicit_resume_capability": return { authorizedResume: true };
case "new_policy_requires_review": return { policyVersionChanged: true };
case "authorized_writer_incremented_version": return { statusVersionAdvanced: true };
default: return null;
}
@ -529,7 +530,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
triggerActorCompanyId: companyId,
priorIssueStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorStatus,
priorStatusVersion: 0,
policyVersion: completionState === "new_policy_requires_review"
policyVersion: completionState === "policy_version_changed"
? "phase6-v1"
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
assessmentJson: {
@ -955,7 +956,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
issueId: seeded.issueId,
assessmentId: seeded.assessmentId,
decisionVersion: 1,
policyVersion: completionState === "new_policy_requires_review"
policyVersion: completionState === "policy_version_changed"
? "phase6-v1"
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
fromStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorIssueStatus,
@ -980,20 +981,43 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
updatedAt: new Date(Date.now() + 1_000),
}).where(eq(issueWorkProducts.id, seeded.workProductId));
}
const [reconciled] = await reconcileNativeFinalizations(db, [seeded.runId]);
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
if (completionState === "policy_version_changed") {
await db.insert(workspaceOperations).values({
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
});
}
const [reconciled] = await reconcileNativeFinalizations(db, [seeded.runId]);
if (completionState === "policy_version_changed") {
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
const assessments = await db.select().from(workAssessments).where(eq(workAssessments.issueId, seeded.issueId));
expect(decisions).toHaveLength(1);
expect(decisions[0]!.id).toBe(priorDecision!.id);
expect(assessments).toHaveLength(1);
expect(assessments[0]!.policyVersion).toBe("phase6-v1");
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "preserve", toStatus: decisions[0]!.toStatus as NativeStatusDecision["toStatus"],
reasonCode: decisions[0]!.reasonCode, unblockDescriptor: null, effects: [],
});
liveEntrypointCommitted = true;
consumerExecutions.push({ consumer: "native-reconciliation-entrypoint", observed: { decisionId: priorDecision!.id } });
} else {
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
}
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
liveEntrypointCommitted = true;
consumerExecutions.push({
consumer: "native-reconciliation-entrypoint",
observed: {
action: reconciled.reconciliationAction,
decisionId: reconciled.decisionId,
},
});
}
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
liveEntrypointCommitted = true;
consumerExecutions.push({
consumer: "native-reconciliation-entrypoint",
observed: {
action: reconciled.reconciliationAction,
decisionId: reconciled.decisionId,
},
});
}
if (
seeded.nativeRecords
@ -1858,7 +1882,10 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
];
for (const [field, expected] of mutations) {
const mutated = { ...fixture, expected };
expect(comparisonFailures(mutated, observed), `${fixture.id}:${field}`).not.toEqual([]);
const mutationObserved = field === "forbiddenEffects" && observed.effects.length === 0
? { ...observed, effects: [observedEffect] }
: observed;
expect(comparisonFailures(mutated, mutationObserved), `${fixture.id}:${field}`).not.toEqual([]);
}
}
}, 60_000);
@ -1973,24 +2000,177 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
.rejects.toThrow("native_pending_effect_target_missing:enqueue_continuation");
}, 30_000);
it("preserves terminal issues when a newer reconciliation policy is available", () => {
expect(resolveNativeReconciliationStatus({
facts: { policyVersionChanged: true },
priorIssueStatus: "done",
agentId,
})).toMatchObject({
statusAction: "preserve",
toStatus: "done",
reasonCode: "prior_status_terminal_preserved",
effects: [{ kind: "append_superseding_assessment" }],
async function seedPolicyReview(options: { genuine?: boolean; priorStatus?: "in_progress" | "blocked" | "in_review" } = {}) {
const template = corpus.fixtures.find((candidate) => candidate.mode === "native")!;
const priorStatus = options.priorStatus ?? "in_progress";
const seeded = await seedFixture({
...template, id: `policy-review-${randomUUID()}`,
given: { ...template.given, priorIssueStatus: priorStatus, completionState: "policy_review_cleanup" },
});
const [assessment] = await db.select().from(workAssessments).where(eq(workAssessments.id, seeded.assessmentId));
const previousAssessmentId = randomUUID();
await db.insert(workAssessments).values({
...assessment!, id: previousAssessmentId, policyVersion: "previous-policy",
inputDigest: `previous-assessment:${seeded.issueId}`,
});
await db.update(workAssessments).set({ supersedesAssessmentId: previousAssessmentId })
.where(eq(workAssessments.id, seeded.assessmentId));
const committed = await commitNativeStatusDecision({
db, companyId, issueId: seeded.issueId, runId: seeded.runId,
assessmentId: seeded.assessmentId, priorStatus, priorStatusVersion: 0, priorDecisionId: null,
decision: {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_review", toStatus: "in_review", reasonCode: "completion_review_required", unblockDescriptor: null,
effects: options.genuine
? [{ kind: "bind_reviewer", prompt: "Review the release before publishing.", ownerUserId: null }]
: [
{ kind: "bind_reviewer", prompt: "Review the superseding native policy assessment.", ownerUserId: null },
{ kind: "append_superseding_assessment" },
],
},
});
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, seeded.issueId));
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, committed.decision.id));
await db.insert(workspaceOperations).values({
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
});
return { ...seeded, decision: decision!, interaction: interaction! };
}
it("withdraws obsolete policy reviews, restores the prior status, and is idempotent", async () => {
const seeded = await seedPolicyReview();
await reconcileNativeFinalizations(db, [seeded.runId]);
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(interaction).toMatchObject({ status: "cancelled", result: { outcome: "withdrawn" } });
expect(issue).toMatchObject({ status: "in_progress", statusVersion: 2, lastStatusDecisionId: null });
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
expect(decisions.find((row) => row.id === seeded.decision.id)).toEqual(seeded.decision);
await reconcileNativeFinalizations(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual([issue]);
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).toEqual(decisions);
}, 30_000);
it.each(["accepted", "rejected"])("leaves an already %s review untouched", async (status) => {
const seeded = await seedPolicyReview();
await db.update(issueThreadInteractions).set({ status }).where(eq(issueThreadInteractions.id, seeded.interaction.id));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue).toMatchObject({ status: "in_review", lastStatusDecisionId: seeded.decision.id });
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe(status);
});
it("preserves real completion reviews and limits cleanup to the requested runs", async () => {
const genuine = await seedPolicyReview({ genuine: true });
const other = await seedPolicyReview();
await dismissObsoleteNativePolicyReviews(db, [genuine.runId]);
for (const seeded of [genuine, other]) {
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe("pending");
}
});
it.each(["blocked", "done", "cancelled"])("dismisses the obsolete card without undoing a later %s status", async (status) => {
const seeded = await seedPolicyReview();
await issueService(db).update(seeded.issueId, { status });
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe(status === "blocked" ? "cancelled" : "expired");
});
it("does not restore status after a newer decision or a status change back to review", async () => {
for (const changedPointer of [false, true]) {
const seeded = await seedPolicyReview();
if (changedPointer) {
await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, seeded.issueId));
} else {
await issueService(db).update(seeded.issueId, { status: "in_progress" });
await issueService(db).update(seeded.issueId, { status: "in_review" });
}
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
}
});
it("keeps review status when a separate request still needs a response", async () => {
const seeded = await seedPolicyReview();
await db.insert(issueThreadInteractions).values({
companyId, issueId: seeded.issueId, kind: "request_confirmation", status: "pending",
payload: { version: 1, prompt: "Approve publishing the release." },
});
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue!.status).toBe("in_review");
});
it("isolates a failed cleanup candidate and retries it on the next pass", async () => {
const first = await seedPolicyReview();
const second = await seedPolicyReview();
const runIds = [first.runId, second.runId];
const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("injected cleanup failure"));
try {
await expect(dismissObsoleteNativePolicyReviews(db, runIds)).resolves.toBeUndefined();
} finally {
transaction.mockRestore();
}
const statuses = await Promise.all([first, second].map(async (seeded) => {
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
return interaction!.status;
}));
expect(statuses.sort()).toEqual(["cancelled", "pending"]);
await dismissObsoleteNativePolicyReviews(db, runIds);
for (const seeded of [first, second]) {
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue!.status).toBe("in_progress");
}
});
it("keeps committed cleanup and continues publication after a live event fails", async () => {
const first = await seedPolicyReview();
const second = await seedPolicyReview();
const publish = vi.spyOn(activityLog, "publishActivity").mockImplementationOnce(() => {
throw new Error("injected live publication failure");
});
try {
await dismissObsoleteNativePolicyReviews(db, [first.runId, second.runId]);
expect(publish.mock.calls.length).toBeGreaterThan(1);
for (const seeded of [first, second]) {
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(issue!.status).toBe("in_progress");
expect(interaction!.status).toBe("cancelled");
}
} finally {
publish.mockRestore();
}
});
it("continues native finalization when the obsolete-card lookup fails", async () => {
const seeded = await seedPolicyReview({ genuine: true });
const select = vi.spyOn(db, "select").mockImplementationOnce(() => {
throw new Error("injected cleanup lookup failure");
});
try {
const reconciled = await reconcileNativeFinalizations(db, [seeded.runId]);
expect(reconciled).toHaveLength(1);
expect(reconciled[0]!.phase).toBe("committed");
} finally {
select.mockRestore();
}
});
it("preserves a later authoritative status during reconciliation", () => {
expect(resolveNativeReconciliationStatus({
facts: { authoritativeStatusChanged: true, policyVersionChanged: true },
facts: { authoritativeStatusChanged: true },
priorIssueStatus: "blocked",
agentId,
})).toMatchObject({
statusAction: "preserve",
toStatus: "blocked",
statusAction: "preserve", toStatus: "blocked",
reasonCode: "prior_status_terminal_preserved",
});
});

View File

@ -27,6 +27,7 @@ const apiPrefixes: Record<string, string> = {
"board-chat.ts": "/api",
"built-in-agents.ts": "/api",
"chat-channels.ts": "/api",
"email.ts": "/api",
"cloud.ts": "/api/cloud",
"companies.ts": "/api/companies",
"company-skills.ts": "/api",
@ -90,6 +91,7 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
// This endpoint is authenticated by the provider signature rather than by a
// Paperclip board/agent credential. It intentionally stays out of the public
// board API document, while this exact exclusion keeps route coverage honest.
"POST /api/chat-webhooks/agentmail/{publicId}",
"POST /api/chat-webhooks/{publicId}/{provider}",
]);
@ -128,7 +130,7 @@ function normalizeExpressPath(routePath: string) {
function resolveMountedPath(file: string, prefix: string, routePath: string) {
if (
file === "chat-channels.ts" &&
(file === "chat-channels.ts" || file === "email.ts") &&
routePath.startsWith("/api/chat-webhooks/")
) {
return routePath;

View File

@ -1636,10 +1636,13 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
const route = await handle.openLoginPtySession(
ptyOpenInput({
workerSessionId: "ws-A",
emitOnInput: true,
outputs: [{ chunk: "legacy-output", omitHostRouteId: true }],
}),
);
route.onData((chunk) => chunks.push(chunk));
// Legacy notifications require the open reply to bind the worker ID.
route.write("emit-scripted-output");
await vi.waitFor(() => expect(chunks).toContain("legacy-output"));
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
@ -1661,8 +1664,9 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
try {
await handle.start();
const route = await handle.openLoginPtySession(
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true, emitOnInput: true }),
);
route.write("emit-scripted-exit");
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
} finally {
await handle.stop().catch(() => undefined);

Some files were not shown because too many files have changed in this diff Show More