Merge branch 'codex/work-folders-remote-recovery-refresh' into codex/work-folders-session-compat-refresh
* codex/work-folders-remote-recovery-refresh: fix(runner): keep healthy native sessions alive (#13261) feat(connections): add AgentMail inboxes and email tasks (#13256) fix: stop completion reviews caused by policy upgrades (#13266) fix(ci): avoid empty pnpm caches from lockfile refresh (#13267) feat: accept a base64-encoded Cloud UI snippet (#13245) ci: use reserved AWS capacity for post-merge cloud verification (#13257) Fix Codex API key authentication in tests and runs (#13260) refactor: remove automatic productivity reviews (#13263) fix: continue conversations after confirmed remote runner stop (#13254) ci: cache Rust dependencies used by post-merge typecheck (#13259) fix(ui): restore task page archive shortcut (#13253) feat(ui): show one rolling runner activity per commentary group (#13255) fix(ui): hide retry countdown after execution starts (#13258) test: isolate native controller takeover fixture (#13212)
This commit is contained in:
commit
fc54683eb6
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ Paperclip’s 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 connection’s 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 Paperclip’s 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 agent’s 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.
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -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 run’s saved adapter invocation or continuation policy, never the agent’s 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
|
||||
|
|
|
|||
|
|
@ -1549,6 +1549,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");
|
||||
|
|
@ -4855,13 +4879,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 },
|
||||
);
|
||||
|
|
@ -5427,13 +5451,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 },
|
||||
);
|
||||
|
|
@ -5455,6 +5479,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 () => {
|
||||
|
|
|
|||
|
|
@ -1983,6 +1983,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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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$/ },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2162,7 +2162,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;
|
||||
|
|
@ -3951,7 +3965,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')`),
|
||||
],
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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')`),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -331,7 +331,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)
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -4569,6 +4577,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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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-"),
|
||||
|
|
|
|||
|
|
@ -2076,6 +2076,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 ||
|
||||
|
|
@ -2087,6 +2090,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;
|
||||
|
|
@ -2162,6 +2169,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 {
|
||||
|
|
@ -2671,6 +2734,7 @@ export class DurablePrpControlPlane {
|
|||
payload: {
|
||||
selectedVersion: lease.protocolVersion,
|
||||
heartbeatIntervalMs: 250,
|
||||
connectionLeaseRenewalVersion: 1,
|
||||
connectionLeaseId: lease.leaseId,
|
||||
...(leaseToken === null ? {} : { connectionLeaseToken: leaseToken }),
|
||||
connectionLeaseExpiresAt: lease.expiresAt,
|
||||
|
|
|
|||
|
|
@ -4026,6 +4026,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({
|
||||
|
|
|
|||
|
|
@ -4639,7 +4639,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,
|
||||
|
|
@ -5263,7 +5263,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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1317,13 +1317,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",
|
||||
|
|
@ -1333,7 +1358,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",
|
||||
|
|
@ -1344,9 +1369,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 () => {
|
||||
|
|
@ -1393,7 +1420,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();
|
||||
});
|
||||
|
||||
|
|
@ -1738,7 +1765,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 () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type {
|
|||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentTerminationReceipt,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentStartInteractiveSetupParams,
|
||||
PluginEnvironmentSyncInParams,
|
||||
|
|
@ -2286,7 +2287,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 = {
|
||||
|
|
@ -2303,7 +2304,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);
|
||||
|
|
@ -2312,13 +2313,16 @@ 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") {
|
||||
// A failed stop says nothing about the safety of deleting the working
|
||||
// copy. Surface the failure so the host retains the lease for retry.
|
||||
await sandbox.stop(toTimeoutSeconds(config.timeoutMs));
|
||||
}
|
||||
return;
|
||||
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
||||
}
|
||||
|
||||
if (config.archiveOnRelease) {
|
||||
|
|
@ -2328,7 +2332,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.`,
|
||||
|
|
@ -2336,7 +2340,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);
|
||||
|
|
@ -2345,7 +2350,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 = {
|
||||
|
|
@ -2361,7 +2366,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);
|
||||
|
|
@ -2369,7 +2374,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);
|
||||
|
|
|
|||
|
|
@ -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?(
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export type {
|
|||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentTerminationReceipt,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)})});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1603,7 +1603,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
};
|
||||
}
|
||||
if (method === "environmentDestroyLease") {
|
||||
return undefined;
|
||||
return { providerLeaseId: "plugin-lease-1", state: "destroyed" };
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
|
|
@ -1635,6 +1635,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
|
||||
|
|
@ -3484,7 +3487,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.
|
||||
|
|
@ -6798,7 +6806,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}`);
|
||||
}),
|
||||
|
|
@ -6825,6 +6833,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" } },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -10194,20 +10195,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();
|
||||
|
|
@ -10244,6 +10273,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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1641,10 +1641,13 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
|
|||
ptyOpenInput({
|
||||
batchWithOpenReply,
|
||||
workerSessionId: "ws-A",
|
||||
emitOnInput: !batchWithOpenReply,
|
||||
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));
|
||||
|
|
@ -1666,8 +1669,9 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
|
|||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ batchWithOpenReply, workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
|
||||
ptyOpenInput({ batchWithOpenReply, workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true, emitOnInput: !batchWithOpenReply }),
|
||||
);
|
||||
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
Loading…
Reference in New Issue