Merge pinned master into work folders base and reconcile migration 0277

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 18:26:16 -05:00
commit f5bbc84ee2
293 changed files with 115893 additions and 1966 deletions

View File

@ -10,7 +10,7 @@ const base = {
ref: "refs/heads/master", event_name: "push", sha,
};
const expectedJobs = {
"cloud-readiness.yml": ["artifacts", "source_verified", "ready"],
"cloud-readiness.yml": [],
"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"],
@ -81,3 +81,27 @@ for (const [file, expectedNames] of Object.entries(expectedJobs)) {
});
}
}
test("Cloud readiness bookkeeping never waits for the AWS verification fleet", () => {
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
const bodies = new Map();
for (const [name, needs] of [
["artifacts", null],
["source_verified", "[verify]"],
["ready", "[verify, image, artifacts]"],
]) {
const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1];
assert.ok(body, `missing ${name} job`);
bodies.set(name, body);
assert.match(body, /^ runs-on: ubuntu-latest$/m);
assert.doesNotMatch(body, /^ +continue-on-error:|^ +if:.*always\(\)/m);
assert.match(body, /^ if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'$/m);
assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m);
assert.equal(body.match(/^ needs: (.+)$/m)?.[1] ?? null, needs, `${name} prerequisites`);
}
assert.match(bodies.get("artifacts"), /^ run: node scripts\/cloud-readiness.mjs "\$SOURCE_SHA"$/m);
assert.match(bodies.get("source_verified"), /^ run: node --test scripts\/cloud-source-verification.test.mjs$/m);
assert.match(bodies.get("source_verified"), /echo "Cloud source verified v1: \$SOURCE_SHA"/);
assert.match(bodies.get("ready"), /echo "Cloud deployable v1: \$SOURCE_SHA"/);
});

View File

@ -32,7 +32,8 @@ jobs:
artifacts:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
name: Wait for exact-source cloud artifacts
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' }}
# Bookkeeping must not wait for the AWS builders it observes.
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
@ -55,7 +56,8 @@ jobs:
name: Cloud source verified v1
needs: [verify]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
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' }}
# Bookkeeping must not wait for the AWS builders it observes.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
@ -81,7 +83,8 @@ jobs:
name: Cloud deployable v1
needs: [verify, image, artifacts]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
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' }}
# Bookkeeping must not wait for the AWS builders it observes.
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record cloud readiness

View File

@ -72,24 +72,6 @@ jobs:
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
# Each SHA exports its own cache. Import recent first-parent caches so
# a late older build cannot overwrite a newer build's cache manifest.
# The legacy ref keeps the first builds warm during the transition.
- name: Select cloud cache ancestry
id: cloud-cache
env:
CACHE_IMAGE: ghcr.io/${{ github.repository }}
run: |
set -euo pipefail
{
echo 'sources<<CACHE_SOURCES'
for commit in $(git rev-list --first-parent --max-count=10 HEAD); do
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud-$commit"
done
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud"
echo 'CACHE_SOURCES'
} >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
@ -175,6 +157,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Mixing several historical manifests missed otherwise reusable native
# layers on fresh builders. Import the nearest available complete cache.
- name: Select cloud cache ancestry
id: cloud-cache
env:
CACHE_IMAGE: ghcr.io/${{ github.repository }}
run: node scripts/select-cloud-cache.mjs
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
@ -239,7 +229,7 @@ jobs:
# Same-SHA builds serialize above; different SHAs never share a
# writable cache ref. Registry layers are content-addressed and
# shared even when cache manifests have separate tags.
cache-from: ${{ steps.cloud-cache.outputs.sources }}
cache-from: ${{ steps.cloud-cache.outputs.source }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}

View File

@ -32,7 +32,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Compile the real target, then change source in a disposable context.
# Require dependency-layer reuse and changed metadata from the real binary.
# A fresh builder must import dependencies and produce changed binary metadata.
# No registry credentials, external cache, or image publication.
- name: Verify native build and dependency cache reuse
run: bash scripts/check-docker-runner-cache.sh

View File

@ -352,6 +352,19 @@ These browser suites are intended for targeted local verification and CI, not th
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
### Task search evaluation
The task search relevance rubric and regression corpus are documented in
[SEARCH.md](SEARCH.md). Run the real PostgreSQL relevance suite with:
```sh
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
```
Set `SEARCH_EVAL_SCALE=1` to additionally measure a disposable 10,000-task,
30,000-comment dataset. `SEARCH_EVAL_REPORT=/tmp/search-quality.json` saves
per-query results and latency measurements; scale measurements are opt-in.
### Recent task ordering
The streamlined sidebar keeps five recent tasks per company and account in browser

View File

@ -357,6 +357,14 @@ own native build; no cross-architecture binary is reused. No additional GitHub
Actions cache is created. A cold build also installs the recipe generator and
compiles dependencies, so the savings apply after those layers are available.
Cloud builds import one registry cache: the first available full-SHA cache in
the current commit's ten-entry first-parent ancestry, with the legacy cache
as a final fallback. Each build still exports its own SHA cache with
`mode=max`. In fresh-builder checks, importing several historical manifests
missed native layers that a single matching manifest reused. The selector
inspects metadata after Docker login, stops at the first available cache, and
permits a cold build if no cache can be read.
The application build inherits that stage and still runs the normal server
build, including Cargo, binary staging, and generated-contract checks. Rust
input file times are normalized in both stages so fresh checkouts do not force
@ -367,12 +375,14 @@ directory as before. Cache misses only cost compilation time.
Pull requests that change the Dockerfile, Docker ignore rules, or Runner native
inputs also build the isolated `runner-build` target in `Docker Runner check`.
The check runs `bash scripts/check-docker-runner-cache.sh` against a disposable
copy of tracked source and the actual Docker ignore rules. It compiles a baseline,
changes a Rust metadata constant, and rebuilds. It requires a cached dependency
build, an unchanged dependency recipe, and changed metadata from the real binary.
It also verifies that a dependency declaration change alters the recipe. The
probe exports only small metadata files, avoiding a large image import into the
Docker daemon. It catches missing embedded inputs before the post-merge build.
It uses a GitHub-hosted runner with read-only repository access and does not
publish images or cache artifacts. Allow up to 20 minutes for its cold build and
copy of tracked source and the actual Docker ignore rules. It compiles a baseline
and exports a local cache, removes that builder, changes a Rust metadata constant,
and rebuilds on a fresh builder using only the exported cache. It requires a
cached dependency build, an unchanged dependency recipe, and changed metadata
from the real binary. It also verifies that a dependency declaration change
alters the recipe. The probe exports small metadata results instead of importing
a large test image into the Docker daemon. Temporary builders and cache files
are removed afterward. It catches missing embedded inputs before the post-merge
build. It uses a GitHub-hosted runner with read-only repository access and never
publishes images or registry caches. Allow up to 20 minutes for its cold build and
source rebuild.

View File

@ -161,6 +161,24 @@ Paperclips core identity is a **control plane for autonomous AI companies**,
9. **Thin core, rich edges**
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
### Experimental iMessage Photon channel
A Photon Cloud project can represent one agent through the existing
experimental channel subsystem. DMs and explicitly enabled groups create or
continue task-bound conversations. Linked sender identity is the default;
telephone numbers, email addresses, names, and group membership do not grant
Paperclip authority. Photos/files and ordinary questions/confirmations use the
existing attachment, interaction, continuation, and publication contracts.
Pause and Disconnect govern runtime behavior independently of the UI gate.
Local Mac access, unsolicited conversations, and SMS/RCS
fallback are excluded. Live qualification is required before release readiness.
Pro shared allocation supports DMs only, with sender enrollment in Photon and
separate identity linking in Paperclip. Shared channels reserve one project, not
a pool phone number; group admission and publication are disabled. Dedicated
allocation retains one selected number and individually enabled groups.
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
contract, setup, recovery, boundaries, and qualification status.
### Experimental persistent agent conversations
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.

View File

@ -399,3 +399,18 @@ fixture setup; it does not make a single test faster.
The file-duration manifest also records the native Codex Runner integration
suite's measured import and execution cost, so the existing file balancer
accounts for it in both ordinary PR and release verification.
## Cloud readiness runner placement
When AWS routing is enabled, Cloud image builds use `paperclip-cloud-build-x64`
and source verification uses `paperclip-post-merge-x64`. The artifact wait and
the `Cloud source verified v1` and `Cloud deployable v1` marker jobs run on
GitHub-hosted runners. These small jobs must not hold or wait for capacity in
the source-verification fleet. During a merge
burst, even a completed build must wait for its marker before consumers can
recognize readiness.
Runner placement does not change readiness requirements: exact-source artifacts,
all source checks, and the image verification must still pass. The versioned
markers and their dependency gates are unchanged.

200
doc/SEARCH.md Normal file
View File

@ -0,0 +1,200 @@
# Task search relevance
## Product rubric
Search should help someone reopen work they remember, using whatever fragment
stuck in memory: an ID, a few title words, a technical name, or something in the
conversation. The first screen should contain plausible answers, with enough
context to explain each match.
| Intent | Good result | Failure |
|---|---|---|
| Known task ID | Exact ID first, case-insensitive; accept `PAP-42`, `pap42`, `PAP 42` | A mention or neighboring ID beats the task |
| Remembered title | Exact title, phrase, then all title words in any order | A recent comment mentioning those words beats the title |
| Several concepts | Every meaningful query term contributes, including short terms such as API/UI | A task matches only one common word |
| Exact phrase | Quoted text stays together and literal | Quotes silently behave like OR or fuzzy search |
| Thread memory | Find words across task text, comments and current documents | Relevant content exists but the task cannot be found |
| Technical text | Preserve underscores, percent signs, paths and numbers | SQL wildcard expansion or fuzzy IDs return unrelated work |
| Typo | Conservative title-word correction; all other terms still required | Ignoring a short term changes the query's meaning |
| Result explanation | Show the best evidence and link to its source | A title hit jumps into an unrelated comment |
| Old work | Strong completed-task matches remain ahead of weak recent hits | Recency/activity replaces relevance |
| Boundaries | Company, visibility, deletion and explicit filters always apply | Content leaks through counts, snippets or typo matches |
| Operations | PostgreSQL only, synchronous current-row reads, bounded query/page sizes | A worker, remote index or eventual-consistency repair is required |
Judge results on a 03 scale: **3** directly answers the remembered task intent,
**2** is useful related work, **1** is only an incidental mention, **0** is
irrelevant. Ambiguous short queries may have several grade-3 answers; do not
invent a unique intended task for them.
Acceptance gates:
- Every unambiguous known-task case returns its intended task first.
- Every grade-3 result in the small judged corpus appears in the first five.
- All explicit negative, visibility, filter, freshness and literal-query cases pass.
- Report mean reciprocal rank (first grade-3 result) and nDCG@5 (graded ordering
and recall). Target MRR ≥ 0.95 and nDCG@5 ≥ 0.90 on the authored corpus.
- Measure both the full search page and the command-palette/task-list API.
- Measure database-backed latency separately from relevance. Report dataset
size, warm/cold assumptions and hardware; a small fixture is not scale proof.
Initial target: warm p95 ≤ 250 ms at 10,000 tasks and 30,000 short comments.
A regression greater than 20% from baseline requires investigation and an
explicit explanation of the cost; do not describe a quality improvement as
latency-neutral when it is not.
The initial corpus is synthetic and deliberately adversarial. It includes
plausible distractors and gives older completed tasks strong relevance labels.
It is not evidence that every real user's search is solved. Add real failed
queries and human judgments as they become available. Do not adjust judgments
just to improve a score.
## Previous behavior
The command palette calls the issue-list endpoint. It searched one literal
substring across title, identifier, description and comments, prioritized titles
before identifiers, and did not search documents or recover typos. Reordered
words commonly returned no result.
Company search used a different algorithm: any token admitted a result, bonuses
from titles, comments and documents accumulated, and title-only token coverage
was indistinguishable from words scattered across a long thread. It ran edit
distance for title words, discarded short terms from fuzzy matching, and also
fuzzed identifiers. Quotes were tokenized but did not constrain other matches.
## Matching contract
Both task search paths use `server/src/services/task-search.ts`. Search is lexical:
trim/collapse whitespace, normalize case, keep quoted phrases, remove a small
set of unquoted grammatical filler words, deduplicate terms, and retain up to
8 terms within the existing 200-character query bound. All-filler queries keep
their terms. No synonym service, embedding model or language-specific stemming
is involved.
All retained terms must match. Full search and task lists allow terms to occur
across task text and current, undeleted conversation/document content. The
Tasks scope requires coverage in task text. Comments and Documents require a
participating match in that source, while retaining the task context. Exact/prefix
identifiers and conservative title-word typo matches are additional task matches.
Typo matching runs only when no literal match satisfies the requested filters.
It never guesses task numbers, loosens a quoted phrase or drops a short query
term. Alphabetic terms of one to three characters must begin a word, so `UI`
does not match `build`, while incomplete longer words still support typeahead.
Ranking uses disjoint bands: exact ID, ID prefix, exact title, title phrase,
all title terms, all task-text terms, all thread terms, then title typo recovery.
Whole-word title matches and title prefixes break close ties; status has only a
small effect within a band. Full search uses recency and stable IDs for remaining
ties; task lists retain their existing priority/activity tie-breaking. Explicit
created/updated/priority sort modes retain their documented behavior. Other
entity types retain their existing scoring rules, rescaled to keep exact names
ahead of speculative task typo matches. The UI displays the server's order
without regrouping results by source.
The existing `pg_trgm` indexes support literal substring retrieval. Tagged
comment/document match sets are computed once per search with separate indexed
patterns. Ranking stages carry compact flags; descriptions and matching snippets
are fetched for the result window. The database reads current rows, so creates, edits, deletions and
hidden-task changes take effect without indexing jobs. Bounded edit-distance
checks operate on titles only, run only as a zero-result fallback, and guard
fuzzystrmatch's 255-character argument limit. There is no schema migration or
new extension in this change.
PostgreSQL documents the existing index support in
[pg_trgm](https://www.postgresql.org/docs/17/pgtrgm.html).
## Reproduce the evaluation
```sh
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
# Also write per-query rankings and metrics for inspection:
SEARCH_EVAL_REPORT=/tmp/search-quality.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
# Include the larger latency dataset and query plans:
SEARCH_EVAL_SCALE=1 SEARCH_EVAL_REPORT=/tmp/search-scale.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
```
The fixture is `server/src/__tests__/fixtures/task-search-corpus.ts`. Tests run
the real services against a temporary embedded PostgreSQL database with the
normal migrations. `SEARCH_EVAL_BASELINE=1` records judgments without asserting
improved behavior. To compare another revision, copy this test, its fixture and
`task-search.ts` into a separate worktree for that revision, leave its actual
`company-search.ts` and `issues.ts` services unchanged, and run with
`SEARCH_EVAL_BASELINE=1`. The copied helper is not used by the baseline services;
its query-plan branch is disabled in baseline mode.
## Initial evaluation — 2026-09-12
Compared against `2083bf6f9` using the same 31-task corpus and 24 queries (23
queries with intended answers, plus one no-result query).
| Surface | Intended answer first, before → after | MRR, before → after | nDCG@5, before → after |
|---|---|---|---|
| Full search | 17/23 → 23/23 | 0.828 → 1.000 | 0.904 → 0.999 |
| Quick search / task list | 5/23 → 23/23 | 0.268 → 1.000 | 0.339 → 0.999 |
The relevance gates pass. These results measure the authored corpus, not general
search accuracy. The no-result query also returns no tasks in both surfaces.
The scale run adds 10,000 tasks with ~300-character descriptions and 30,000
~345-character comments. Measurements call the real service methods (including
facets/snippets or task-list hydration), excluding HTTP and UI debounce. Each
query has one separately recorded first request and 20 warm repetitions; p95
is the 19th sorted warm sample. This is not a cold-disk test. Both revisions used
PostgreSQL 18.1, default planner/memory settings, and `ANALYZE` after seeding.
The host was an Apple M5 Max with 128 GiB RAM, running an x86_64 PostgreSQL
binary and other development tests concurrently. Treat timing deltas as local
measurements, not production capacity or a controlled concurrency benchmark.
| Query | Full p95 before → after (ms) | Quick p95 before → after (ms) |
|---|---|---|
| `GitHub OAuth` | 139 → 95 | 39 → 128 |
| `OAuth callback GitHub` | 209 → 81 | 26 → 134 |
| `mibile api` | 153 → 131 | 19 → 111 |
| `search` | 172 → 41 | 22 → 67 |
| `quasarxylophone` | 154 → 105 | 18 → 218 |
| `routine` (matches all 10,000 added tasks) | 239 → 253 | 152 → 371 |
Selective full searches improved. Quick search is more expensive: it now
evaluates term coverage, searches documents, and can scan company titles for
typo recovery. The old quick search returned no answers for the reordered and
typo queries, so its lower cost did not deliver equivalent results. The relative
regression threshold is triggered, and broad-query p95 does **not** meet the
initial 250 ms target. This is an explicit performance limitation of this pass.
The implementation adds no operational service, but it is not latency-neutral.
`EXPLAIN (ANALYZE, BUFFERS)` confirmed existing trigram indexes on selective
comment/document retrieval and zero fuzzy-branch executions for successful
literal searches. Removing descriptions from intermediate materialized rows
eliminated 1,699 temporary blocks (~13 MiB) of writes in the broad-query core
plan; its final measured execution was 139 ms with no temporary writes. The
quick-search endpoint also performs its existing activity sorting and task
hydration. Larger companies, long threads and sustained concurrent searches
still need production-shaped measurement before a stronger latency claim.
Browser acceptance used the real built app against an isolated PostgreSQL
database containing this corpus. Starting from the dashboard, the Search link
found an older completed task from reordered title words and opened that task.
Command-K ranked `PAP-42` first for `pap42`; `mibile api` recovered only the
intended mobile API task and carried the query into full search. Quoted
`"connection timeout"` excluded scattered words. `Hermes parser` showed the
document title as evidence and opened the correct plan in the task's side panel.
The desktop result layout was visually inspected. Mobile layout, continuous
transition timing and production data were not part of this walkthrough.
For a future failed search, record the query, what the person remembered, and
the intended task IDs. Grade the old top five and any missed intended tasks
before changing the ranker, add realistic distractors, then run both entry
points. Keep these judgments independent of the ranking constants.
Verification: 78 search/parser tests (including the real PostgreSQL scale run),
14 existing task-list search/filter tests, and 29 Search/CommandPalette UI tests
passed. Workspace typecheck, the final server typecheck, production build,
Storybook build and token gates passed. The full repository test run was stopped
after `chat-channels.integration.test.ts` reported one failure in “publishes a
closed-choice question, settles its Slack card, and delivers its exact
continuation response”; that test passed when rerun alone. The remaining broad
suite was not completed, so this is not a claim of a green repository-wide run.
The API response contracts and company authorization stay unchanged. Artifact,
agent and project ranking are separate from the task relevance rubric. Extraction
search and the specialized blocked-attention queue retain their existing
literal matching. Pure semantic paraphrases and
language-specific word inflections are outside this first lexical rubric.

View File

@ -1148,6 +1148,9 @@ The current app also exposes V1-supporting surfaces for:
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- task search uses shared PostgreSQL matching/ranking for company search and task-list quick search;
all query terms contribute, quoted phrases stay literal, exact identifiers and direct title matches
lead relevance ordering, and the UI preserves server result order (see `doc/SEARCH.md`)
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
server-owned URL expansion, issue/comment/document scopes, status/date filters, issue-level pagination, a
@ -1610,6 +1613,16 @@ retry budget. Existing pause, approval, budget, ownership, and dependency gates
remain in effect. See `doc/execution-semantics.md` for admission and stop-proof
requirements.
### Managed AI authentication
AI credentials can be adopted into the existing Connections system. A typed
`runtimeConfig.aiConnection` selects the responsible users personal default, an
explicit shared grant. The existing human-audience and agent-access permissions
apply; AI credentials have no separate agent-delegation exception. Selection preserves
harness/model routing and fails closed without ambient credential fallback.
Legacy agents retain their authentication until validated adoption. See
[AI Connections](connections/AI-CONNECTIONS.md) for company isolation, compatible
methods, lifecycle, runtime enforcement, and migration details.
### Experimental task-bound email
AgentMail channel connections extend the experimental conversation/task pipeline
@ -1623,6 +1636,30 @@ outcomes without a separate email composer. See
[AgentMail connections](connections/AGENTMAIL.md) for setup, transports, recovery,
authorization, and the API/CLI contract.
### Experimental iMessage Photon channel
A Photon Cloud project can represent one agent through the existing
experimental channel subsystem. DMs and explicitly enabled groups create or
continue task-bound conversations. Linked sender identity is the default;
telephone numbers, email addresses, names, and group membership do not grant
Paperclip authority. Photos/files and ordinary questions/confirmations use the
existing attachment, interaction, continuation, and publication contracts.
Pause and Disconnect govern runtime behavior independently of the UI gate.
Local Mac access, unsolicited conversations, and SMS/RCS
fallback are excluded. Live qualification is required before release readiness.
Pro shared allocation supports DMs only, with sender enrollment in Photon and
separate identity linking in Paperclip. Shared channels reserve one project, not
a pool phone number; group admission and publication are disabled. Dedicated
allocation retains one selected number and individually enabled groups.
iMessage task completion ends a turn, not its conversation. Subsequent messages
reopen the same task, including after restart; only explicit `/new` or `/close`
allows the next message to start another task. The open task receives committed
inbound comments live, with “Sent from iMessage” attribution on user bubbles.
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
contract, setup, recovery, boundaries, and qualification status.
### Native task completion
For ordinary low-risk tasks, accept the current agent's structured `done` claim

View File

@ -567,3 +567,31 @@ A paused task takes over the composer with an amber notice and a Resume action.
Operators must release the effective task or ancestor pause before sending a new
message. The draft stays intact. This applies to both task interfaces and to
board comment API requests; an agent may still report interrupted work.
### Experimental iMessage Photon channel
A Photon Cloud project can represent one agent through the existing
experimental channel subsystem. DMs and explicitly enabled groups create or
continue task-bound conversations. Linked sender identity is the default;
telephone numbers, email addresses, names, and group membership do not grant
Paperclip authority. Photos/files and ordinary questions/confirmations use the
existing attachment, interaction, continuation, and publication contracts.
Pause and Disconnect govern runtime behavior independently of the UI gate.
Local Mac access, unsolicited conversations, and SMS/RCS
fallback are excluded. Live qualification is required before release readiness.
Pro shared allocation supports DMs only, with sender enrollment in Photon and
separate identity linking in Paperclip. Shared channels reserve one project, not
a pool phone number; group admission and publication are disabled. Dedicated
allocation retains one selected number and individually enabled groups.
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
contract, setup, recovery, boundaries, and qualification status.
## Task search relevance
Task discovery uses PostgreSQL and the existing search indexes, with no external
search service or background indexing job. The task-list quick search and full
company search share lexical matching and ranking. Known identifiers and direct
title matches lead; current conversation and document content supplies supporting
evidence. See [Task search relevance](SEARCH.md) for the evaluation rubric,
matching contract and reproducible quality tests.

View File

@ -0,0 +1,231 @@
# AI Connections
AI accounts use the existing Apps/Connections substrate. Manage them at
`/:company/apps`; select their use beside an agent's harness/model settings.
Onboarding, new-agent setup, account creation/reconnect, and inline task requests
reuse `AdapterLoginPanel`, its existing login controllers, and `AdapterLoginChrome`.
Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and
`useSavedProviderKeys`, including saved-key references and account-specific Codex
homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
Storybook's simulated controllers and page annotations do not run in the app.
## Compatibility and selection
The shared `AI_CONNECTION_CAPABILITIES` contract defines these combinations:
| Provider | Sign-in method | Existing harness |
| --- | --- | --- |
| Claude / Anthropic | Claude subscription token or Anthropic API key | Claude |
| OpenAI | ChatGPT/Codex subscription or OpenAI API key | Codex |
| OpenRouter | API key | OpenCode, with an `openrouter/` model |
| Grok / xAI | Grok subscription or xAI API key | Grok |
Native runner supports the corresponding existing Codex, OpenCode, and Claude
ACP profiles. Connections creation and reconnect mount `AgentProviderConnection`,
the same provider tiles, method controls, API entry, and `AdapterLoginPanel` used
by agent setup. Supported sandbox environments use onboarding's existing browser
sign-in controllers. Self-hosted installations use the shared terminal sign-in
instructions described below and require no sandbox. Environment selection does
not change agent execution settings.
API keys are validated against fixed provider endpoints; redirects
and caller-supplied validation URLs are rejected.
`runtimeConfig.aiConnection` contains `provider`, `method`, and `mode`:
- `responsible_user`: resolve the run's responsible user's personal default.
- `shared`: use the named `connectionId` and `grantId`, with audience and agent
access checks.
- `delegated`: retained only to read legacy bindings. It cannot bypass human
access; a personal credential remains available only for its owner's tasks.
New configuration offers personal defaults or shared accounts.
“Which humans can use this credential?” is the sole permission for whose work
can use the account. “Just me” means the personal owner; shared accounts allow
selected company members or every company member. The separate agent-access
setting determines which agents can use it. There is no additional AI agent
authorization, and old delegation records do not override the human audience.
A connection choice never changes the harness, model, or provider routing.
Changing those separately may make a binding incompatible; saving then requires
a compatible choice. Agent configuration cannot grant access to another account.
Personal defaults are unique per company, user, provider, and sign-in method.
The first successful personal connection sets a default only when none exists.
Revocation retains the unavailable default; connecting another account does not
silently replace it. Change it explicitly on the account detail page.
## Storage and API
AI connections pair `connectionPurpose: ai` with `transport: runtime_auth`.
Database checks and the shared discriminator enforce the pair. These entries
cannot participate in tool discovery, MCP gateways, execution, or channels.
Anthropic retains its existing tool methods alongside its AI methods. Catalog
validation also pairs AI metadata with runtime authentication and rejects unsupported
sign-in methods. Provider artwork and source provenance live in
`ui/public/brands/apps/manifest.json`; OpenRouter uses its official sign-in assets,
and OpenAI/Grok reuse the repository's pinned Lobe Icons source and license.
Provider/method metadata lives in `config.ai`. Credentials live on the existing
grant through encrypted vault secret references, with existing consumer bindings.
Safe provider-reported account identity is optional; secret references, tokens,
and authentication paths are never account labels.
Company-scoped `/api/companies/:companyId/ai-connections` operations provide list,
API-key creation/reconnect, personal defaults, completed login references, and
active-run attribution. Existing Connections operations handle naming, access,
and revocation. Mutation authorization is enforced server-side. OpenAPI documents the new board-only
operations. Agent-originated configuration and environment tests resolve the
authenticated requests responsible user; an agent ID is never a personal-account
owner. A missing responsible identity blocks personal-default resolution.
Subscription login attempts retain their company, owner, method, access intent,
and reconnect target in the existing durable authentication session. Duplicate
completion returns the same connection/grant. Abandoned or expired attempts cannot
save a healthy connection. Reconnect preserves the connection ID, bindings,
customized name, and access settings. A completed connection remains even if
subsequent agent creation fails or is cancelled.
## Runtime isolation
`prepareManagedAiRuntime` is shared by runs, environment tests, and adoption.
It checks responsible identity, membership, compatibility, connection health,
human audience and agent installation before reading credentials.
Missing credentials produce an actionable configuration failure; responsible-user
task runs use the existing connection-request interaction, marked `purpose: ai`.
A runtime-auth request cannot satisfy, reuse, or supersede a tool request for
the same provider. AI-only methods are excluded from agent tool discovery.
Each invocation receives a private authentication home and only the selected
grant's credentials. Inherited credential variables are cleared. Conflicting
project authentication and provider-routing overrides are rejected. Managed
failure cannot reactivate host or legacy credentials.
Subscription invocations take a grant-scoped database advisory lease. Two
different users' grants can run concurrently; a second invocation of the same
subscription receives a retryable busy response while it is in use. Refreshes
are merged only into the originating active grant, with reconnect/revocation
version checks. Temporary homes are removed on normal completion or failure.
Session reuse includes grant identity, responsible user, and credential
generation. A changed identity starts a fresh provider session. Managed native
executions use per-turn lifecycle cleanup; a suspended native execution whose
credential identity changed must restart as a new execution.
Revocation blocks new invocations and refresh persistence. A running provider
process may already hold credentials. The revoke confirmation lists attributed
active runs and exposes the existing Stop action; it does not promise immediate
provider-side revocation.
## Legacy adoption
Migration `0273` indexes only explicitly owned personal secrets with a recognized
provider/method and matching agent configuration. It keeps original secret
references and leaves every agent's legacy authentication unchanged. Reconnecting
an indexed account creates a private grant credential instead of rotating the
legacy secret. Subsequent reconnects rotate that private credential. Unknown
ownership and filesystem-only subscriptions remain unresolved. The migration is
repeatable and does not classify unknown credentials as company-shared.
Imported accounts initially need validation. Agent settings show “Existing
authentication — not managed by Connections” until adoption. The adoption
confirmation names the binding and affected agent. Saving runs a provider hello
test in that agent's environment before replacing authentication. After adoption,
the server preserves the managed binding and will not restore legacy fallback.
## Local subscription sign-in
Local installations do not need a sandbox to connect a subscription. Connections,
onboarding, and agent setup share `LocalProviderLoginInstructions` and
`useLocalAiLogin`. In local-trusted mode, Claude checks the operators existing
Claude Code login. Authenticated self-hosted users instead get a separate
`CLAUDE_CONFIG_DIR` for `claude auth login`; checking and saving only read that
attempts credential files, never the server operators account or Keychain.
Codex and Grok start a separate terminal sign-in for each connection or reconnect.
The shared component shows a server-generated command with a fresh `CODEX_HOME`
or `GROK_HOME`. Codex uses file credential storage in that home and `login --device-auth`, so
signing in from another computer does not depend on a localhost callback. The home is never
seeded with the operator's existing login: copying a rotating refresh token would
allow managed runs to invalidate credentials still used by legacy agents or the
operator's terminal. The user completes browser sign-in from that command, then
clicks Connect. This does not require a sandbox or change the host login.
Attempts reuse `adapter_auth_sessions`, binding company, owner, provider, access
intent, reconnect target, and a 30-minute expiry. Validation and completion are
serialized; duplicate completion returns the saved connection. Restart retains
the attempt. Cancellation and expiry remove the attempt home, and the startup/
periodic cleanup sweep retries expired directories. Successful completion persists
credentials to the encrypted grant and removes the temporary login home. Refreshes
subsequently update only that grant. Reconnect preserves IDs and access settings.
Starting an isolated attempt requires normal company-scoped AI-connection creation
permission. Checks, completion, cancellation, and resumption are owner-bound.
Authenticated users cannot import host credentials or use another users attempt.
Claude Keychain reads remain limited to the explicit local-trusted default-home import. A failed verification creates
no healthy connection. Preview-era Codex/Grok managed connections without the
isolated-subscription marker require reconnect before another managed execution;
unmanaged legacy agents retain their existing authentication paths.
## Verification
`server/src/__tests__/ai-connections.test.ts` exercises storage, isolation,
defaults, human audiences, agent access, reconnect races, refresh ownership,
subscription locking, migration replay, and redacted API failures against a real
embedded database. Existing login, adapter, tool, and channel suites cover their
shared integration paths. The onboarding tests cover managed reuse and keeping a
successfully connected account after failed agent creation.
The [Storybook review index](http://localhost:6116/?path=/story/ai-connections-review--review-index)
retains deterministic authentication states and interaction checks. Run
`pnpm build-storybook`, then
`pnpm exec playwright test --config tests/ai-connections-review/playwright.config.ts`.
Also run token gates, repository typecheck, tests, and build before handoff.
Live connect → reuse → run → reconnect verification still requires valid provider
credentials and a supported login/runtime environment; fixtures do not prove it.
For an isolated running test drive, also run:
```sh
AI_CONNECTIONS_TEST_COMPANY_ID=<company-id> pnpm exec playwright test --config tests/ai-connections-app/playwright.config.ts
```
Set `AI_CONNECTIONS_TEST_URL` when the test drive uses a port other than 3100.
These browser checks exercise the production list/detail pages, rejected API-key
validation, cancellation, focus restoration, and adoption without saving agent
changes. They submit an explicitly invalid fixture key and do not prove successful
authentication with a live account.
### Local sign-in checks
Local subscription screens share the same credential check on entry and when the
window regains focus. Waiting screens also poll until sign-in verifies. A successful
check shows the account is signed in; only **Connect** creates or reconnects the grant.
In local-trusted mode, Claude checks the local operators Claude Code login.
Authenticated Claude users, plus all Codex and Grok users, check only their
connection-specific login home. The health response selects credential isolation,
not whether a self-hosted user may sign in.
Leaving and returning to a local sign-in screen resumes its active attempt. Navigation
does not delete a directory referenced by a copied command. **Start sign-in again**
explicitly cancels the old attempt; abandoned attempts expire after 30 minutes.
Commands create their directory if necessary, and completed/expired attempts are
cleaned up through the existing lifecycle.
### Disposable live inline-repair test
The normal app test configuration excludes `*.live.spec.ts`. To run the destructive
inline-repair scenario, set `AI_REPAIR_TEST_ALLOW_DESTRUCTIVE=1` and use a separate
loopback `local_trusted` instance. Set `AI_REPAIR_TEST_DISPOSABLE_MARKER` to a fresh
32-character lowercase hexadecimal value. The company, single Codex agent, single
personal OpenAI API connection, and issue must all be named `AI Repair QA <marker>`
(the issue uses that title). Supply their IDs with `AI_CONNECTIONS_TEST_COMPANY_ID`,
`AI_REPAIR_TEST_CONNECTION_ID`, and `AI_REPAIR_TEST_ISSUE_ID`, and the disposable
provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries before
revoking credentials or submitting work. Delete the disposable instance and revoke
its provider key after the test; failed tests may leave a paused task for inspection.
Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key instead of an unusable terminal command. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.

View File

@ -35,6 +35,13 @@ Paperclip resolves short-lived tokens at invocation time. Before writing a
connector, read [Identity vs. connections](./README.md#identity-vs-connections)
for the P1/P2/P3 boundary and the D7 standing rule.
AI provider credentials use the same vault, applications, grants, installations,
and delegation model with `connectionPurpose: ai` and `transport: runtime_auth`.
They authenticate provider execution and never enter MCP discovery or tool/channel
execution. Extend the provider's existing catalog entry with typed AI methods;
reuse the existing login controllers. See [AI Connections](./AI-CONNECTIONS.md)
for compatibility, personal defaults, resolver isolation, and legacy adoption.
## Contents
- [Mental model and support matrix](#mental-model-five-independent-axes)

View File

@ -0,0 +1,286 @@
# iMessage Photon verification
Date: 2026-09-11. Branch: `codex/imessage-photon`.
Base inspected: `1c4bcff2b`; updated through master `ab15aff39`.
Initial implementation checked: `7ada38eb7ef5dff5441f23c02131798b11d57712`.
**Status: experimental; Pro shared-DM live journeys verified below. Dedicated groups and the remaining release matrix are not yet qualified.**
[PR #13299](https://github.com/paperclipai/paperclip/pull/13299) carries the current
CI and review results. The Photon migration is `0275_easy_dragon_man.sql`, regenerated after master added its own 0274 agent-chat migration. Greptile reviewed the implementation commit at 5/5 with no
actionable comments. This record distinguishes local evidence from live proof.
## Environment and versions
- Fresh worktree: `imessage-photon`; separate worktree configuration, instance,
database/storage home, and application port 3109.
- Browser tests use a disposable local-trusted instance on port 3319 with a new
database and storage home. They mock the provider/control-plane responses.
- Database integration tests use disposable embedded PostgreSQL with real channel,
identity, task, attachment, publication, and interaction services.
- Advanced SDK 2.1.0; grpc-js 1.14.4; nice-grpc 2.1.17; nice-grpc-common 2.0.4;
heif2jpeg 0.1.6. Local converter execution: macOS arm64.
- The synthetic HEIC fixture is generated from a solid-color 16×16 image. It has
no personal photo content and does not qualify real iPhone HEIC/Live Photos.
- Production credentials, line tokens, phone numbers, and participant identifiers
are absent from this record. Test numbers/IDs in fixtures are synthetic.
The primary-instance seed attempt encountered existing source schema drift
(`tool_connections_transport_check` missing), so the isolated worktree uses a clean
instance. The primary database was not modified. Several test starts also reached
macOS's 32-segment System V shared-memory limit. Only unattached IPC from this task's
exited browser-test databases was eligible for cleanup; running instances were not
stopped or altered.
## Deterministic acceptance evidence
`server/src/__tests__/photon/photon.test.ts` exercises Basic Cloud authentication,
token redaction, dedicated/shared/missing allocation, immutable line identity,
Unicode multipart publication, receipt recovery, unknown sends and explicit retry,
upload receipt reuse, quota classification, per-part authorization, contiguous
checkpoint recovery, ignored event frames, cutoff history, lease loss, real local
gRPC framing/authentication, scoped state, duplicate-title poll IDs, poll creation
before a local crash, answer parsing, source ownership, image bounds, and actual
synthetic HEIC conversion.
`server/src/__tests__/photon/channel.integration.test.ts` composes the real channel
service with the Photon adapter and synthetic provider responses. It proves the
fresh linked-message/task/agent-publication setup requirement, restored DM reply,
echo filtering, identity reservation, explicit group enablement, authorized poll
resolution, per-person answer drafts, rejection reasons, exactly one canonical
continuation record, delayed HEIC retry after restart, attachment provenance,
quoted context, task generations, stale controls, retained pending input through
pause, group removal, and a native continuation proof for a second group person.
The checkpoint takeover test verifies the database lease and checkpoint update
share one transaction.
The native continuation test caught a JSON key-order mismatch after JSONB storage.
Both the recorded answer digest and reconstructed proof now use the existing
canonical hash. This is a native authorization composition test, not evidence of
a live model turn through Photon.
The Photon/OpenAPI follow-up also verifies safe setup credential, quota, network,
and invalid-response errors, the complete board-only inspection contract, group
participant response fields, and the unchanged credential binding after rejected
replacement. All 39 Photon/OpenAPI tests and the server build passed after the
review fix separating provider outages from invalid setup input.
The Photon browser cases in `tests/e2e/chat-adapters-ui.spec.ts` cover catalog
discovery, multiple-line selection, password input, keyboard selection, vaulted
credential payload shape, setup completion, group enablement, light/dark themes,
mobile navigation/layout, and pause/resume. The surrounding suite covers existing
Slack, Discord, GitHub, Teams, and Telegram surfaces.
| Check | Result |
| --- | --- |
| Photon targeted tests | 31 passed, including checkpoint takeover, Live Photo companion retention, and native continuation authorization. |
| Token gates | Passed. All four gates clean. |
| Workspace typecheck | Full `pnpm -r typecheck` passed before and after rebase. |
| Full chat-adapters browser suite | 38 passed, including Photon light/dark/mobile coverage and existing providers. |
| OpenAPI contract | 8 passed, including mounted-route completeness, board-only inspection, and token-free response schemas. |
| Post-rebase channel/native checks | 96 passed across Photon, OpenAPI, explicit native continuation, and chat-control admission retry. |
| Native session resume | 37 passed after building the required local fake-provider binary. |
| UI Vitest project | 6,008 passed across 582 files after rebase. |
| Shared catalog project | 727 passed, including exact catalog and branding coverage. |
| Repository Vitest suite | The initial `pnpm test:run` overlapped edits/rebase and was stopped; it is not a final-commit pass. Fresh targeted and CI checks supersede it. The serialized route run found the missing Photon OpenAPI contract, which is fixed and passes its 8-case suite. Full gate status is recorded in the linked PR. |
| Build | Full `pnpm build` passed before and after rebase. |
| Generated forward migration | Generated through `pnpm db:generate`; `@paperclipai/db check:migrations` passed. Disposable database migrations exercised by integration tests. |
| Native HEIF platform packages | macOS arm64 executed; other published platforms not executed. |
### Local test prerequisites
The standard `pnpm test:run` launcher isolates `PAPERCLIP_CONFIG`, `PAPERCLIP_HOME`,
and temporary files. Direct heartbeat/continuation tests must use equivalent
isolation; otherwise the worktree preview configuration suppresses execution.
The actual runner-driver fixture also requires:
```sh
cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin fake-codex-app-server
```
A run without that binary failed at provider startup; the complete 37-case native
session-resume suite passed after building it. Catalog assertions were updated
for the 42nd visible app, and the focused catalog/Browse/board-gallery tests pass.
Some broad package runs encountered host embedded-Postgres startup limits during
concurrent local development. These startup failures are not provider proof;
inspect the linked PR for the current complete gate results.
## Pro shared-DM live qualification (2026-09-12)
The operator approved Pro-compatible shared DMs with groups disabled. The live
test uses the isolated instance on port 3109, a Photon Pro project, its enrolled
test participant, and the participant's actual iPhone. The test source was fully
seeded through the worktree CLI; the primary instance remains untouched.
Observed with SDK 2.1.0 on implementation base `e556f7dbefd3ee738bde7830d69d5e30c4e96872`
plus the shared-DM changes in this PR:
- Project inspection and vaulted setup succeeded against Photon Cloud's actual
shared allocation. Shared credentials select the fixed shared gateway and a
project-scoped identity, without inventing an owned phone number.
- At 13:16 UTC, the participant sent a fresh iMessage from their iPhone. Photon
delivered it through authenticated recovery. The project-filtered event feed
jumped from an empty cursor to a non-adjacent sequence; the dedicated-only
adjacency check initially stopped in Attention.
- After the shared recovery fix and an isolated server restart, reconnect replayed
the original message at 13:26 UTC. Paperclip discovered the exact sender but
created no conversation/task while the identity was unlinked. The normal private
confirmation flow then linked that identity to the isolated Board account.
- At 13:2713:28 UTC, the fresh linked request created a task, ran the native
Codex runner, and delivered the requested response back to Apple Messages.
- A native poll created at 13:29 UTC survived restart. Setup initially rejected
interaction answers until the endpoint was active, deadlocking a clarifying
question before final-reply qualification. Photon now permits those responses
during its verified test step with the same identity, generation, and permission
checks. After restart, a fresh vote at 13:33 UTC produced exactly one canonical
answer and one native continuation. Its final reply arrived in Messages. A late
unvote did not undo the answer. Setup then completed normally.
- At 13:3513:37 UTC, two free-text answers were collected sequentially. Early
submission stayed pending; explicit submission of both drafts resumed the
native agent with both exact values. The test also corrected the missing-answer
hint to identify the unanswered question rather than always question 1.
- At 13:38 UTC, the initial PNG/document import failed visibly because the shared
gateway returns project attachment aliases in metadata and native UUIDs in
stream headers. Shared downloads now retain authenticated source-message/chat
checks and the exact alias-addressed RPC, validate the header metadata, and
retain project aliases for provenance and restart. At 13:4313:44 UTC, a fresh
two-file message sent during the outage was recovered, imported, inspected by
the agent, and returned as actual PNG and text-file attachments in Messages.
The agent correctly identified the image and the document's verification word.
- At 13:4613:49 UTC, a canonical `request_confirmation` rejected a bare Reject
reply with an actionable reason request. A correlated rejection with a reason
resolved the canonical interaction and resumed the native agent, which returned
the exact reason and confirmed that no further action ran.
- At 13:4913:50 UTC, a synthetic HEIC was sent through Apple Messages. Paperclip
retained the 676-byte original and created a 633-byte JPEG derivative. The agent
correctly described the solid blue 16×16 image and returned the original HEIC
through Photon; the file appeared in Messages. This tests the real transport and
converter together, but does not substitute for an actual iPhone camera photo.
- At 13:5113:53 UTC, Pause suppressed a delivered test message without creating
a task. Resume did not replay it as work; a fresh request created the next task
and received a reply. Reconnect reused the vaulted credentials and preserved
project/allocation identity, then completed its fresh-message/reply test.
- At 13:5313:54 UTC, revoking the linked identity caused the next live message to
be filtered with no task or agent run. The normal private confirmation flow
restored the link. Completed tasks remained idle between fresh requests, and
`/status` correctly reported no active task. `/new` requested a fresh message,
and `/close` closed the next active conversation. Its late correlated answer
left the old interaction unresolved and did not start another task.
- At 13:56 UTC, Remove connection archived the test endpoint and its connection,
cleared saved secret bindings, and stopped intake. A message sent while removed
created no task. The same Photon project remained eligible in new setup.
A replacement endpoint was linked normally and completed a fresh native
task/reply test at 13:58 UTC. The test channel was left active.
- At 18:1318:14 UTC, an operator-supplied iPhone camera HEIC passed the same
authorized Apple Messages conversation on code commit `fc4e4f0a3` (documentation
head `a2a9319f3`). Messages transformed the 1,432,391-byte source into a
1,132,602-byte HEIC before ingestion. Paperclip retained those received bytes
and generated a 783,443-byte, 3024×4032 JPEG preview. The native agent correctly
described the photo, then staged the HEIC with the same SHA-256 as the received
original. Text and file publications each succeeded on their first attempt with
provider receipts, and the returned photo appeared in Apple Messages. The native
run succeeded and the task completed. The personal photo is not included in the
repository or this report. This closes the real camera HEIC round-trip gap;
Live Photo reassembly remains outside scope.
- An identical published test send was repeated with its original key, exact
payload digest, and reply target. Photon suppressed it but returned gRPC 6 with
SDK `internalError` and an empty context, saying the operation was already
processed. No new bubble appeared. Contrary to the documented original-result
behavior, the shared gateway supplied no receipt. A regression test preserves
delivery-unknown state in this case; no text matching or new key is used.
- The shared receiver now commits only after the complete ordered replay barrier.
Regression cases cover sparse events, interrupted/out-of-order replay, and cursor
resets without advancing the saved checkpoint. Dedicated recovery remains strict.
- All 39 chat-adapters browser tests passed, including shared setup after reload
and existing provider coverage. The 20 Photon unit cases passed. An integration
rerun initially hit the host's embedded-Postgres startup limit; this is a test
environment failure, not a provider result.
The expanded unit suite has 22 passing cases, including shared attachment alias
ownership, header validation, and missing duplicate receipts. After merging master
and regenerating migration 0275, all 16 integration cases passed at code commit
`fc4e4f0a32d35e41e56f6698404fca64cee3f32b`. Full workspace typecheck, build, token
gates, and migration checks passed on that commit. The isolated instance then
restarted successfully, reported startup ready on that commit, and retained the
active shared-DM endpoint and linked identity. A broad `pnpm test:run` was started and stopped
when the host's shared-memory limit prevented the live isolated PostgreSQL from
restarting. Only this task's exited test database resources were removed. This
interrupted run is not a full-suite pass; current CI must qualify the final commit.
All 30 applicable CI checks passed on `a2a9319f3`, with two skipped checks. One
unchanged Cursor sandbox command-selection case initially exceeded its 10-second
timeout. The exact case passed locally in 735 ms, and the failed CI server shard
passed on its single rerun. Greptile rated that head 5/5 with no unresolved review
threads. The 22 Photon unit cases also passed in Linux CI, including native HEIC
conversion. Subsequent changes to this record add qualification evidence only;
the linked PR shows their current check status.
Photon's CLI manages projects and users; its terminal provider simulates chat UI.
Neither substitutes for actual Cloud iMessage delivery. The local Mac initially
classified the assigned number as RCS, while the participant's iPhone sent the
observed iMessage. No RCS/SMS fallback was enabled.
## Live qualification still required
Dedicated-line credentials were unavailable during the initial implementation.
The Pro shared-DM journeys above passed; the remaining matrix must be completed
before release readiness. Live inbound receipt alone is not full qualification.
Record the tested commit, package versions, redacted project/line/chat IDs,
participants, timestamps, and observable results when running it.
| Live case | Status |
| --- | --- |
| Linked DM creates task and receives actual agent response | Passed with Pro shared DMs and the native Codex runner. |
| Enabled group with two linked people preserves attribution | Disabled for the approved Pro scope; dedicated-line live qualification remains unrun. |
| Unlinked sender cannot start work | Passed for the live shared-DM probe; sender discovered, zero conversations/tasks created. |
| Inbound/outbound photos and real iPhone HEIC | Passed for PNG, text file, synthetic HEIC, and an operator-supplied iPhone camera HEIC. The real photo produced a full-resolution JPEG preview and a byte-identical return of the received HEIC. |
| Native poll and text answer resume correct interaction | Passed, including sequential drafts, incomplete submission, explicit submission, and one poll continuation. |
| Approval rejection reason reaches canonical interaction | Passed, including missing-reason correction and native continuation. |
| Restart preserves DM/group replies and pending questions | Shared DM recovery and pending native poll passed; dedicated groups remain unrun. |
| Pause/resume/reconnect/removal enforce authority | Passed for Pro DMs. Removal archived the endpoint and connection, cleared secret bindings, and stopped intake. |
| Completed turn stays idle until fresh input | Passed. September 12 correction: two successive real follow-ups reopened PHOTON-17, with no new task. |
| Provider ambiguous-send/idempotency behavior | Real repeated key suppressed duplicates but returned no original receipt. Unknown-send recovery remains an operator action; no induced network-timeout test. |
| HEIF conversion on Linux glibc/Windows and deployment packaging | macOS arm64 and Linux CI conversion passed. Windows execution remains unrun. Linux musl has no packaged converter. |
Keep this channel behind the existing experimental gate. Mocked tests, synthetic
gRPC, and a visible catalog card do not establish these live results.
### September 12: persistent conversation and live task bubbles
The operator reported three messages creating PHOTON-15, PHOTON-16, and
PHOTON-17. Task completion had incorrectly been treated as the end of an
iMessage conversation, and channel admission did not emit the comment event
used by an open task page. The fix preserves the latest task until an explicit
`/new` or `/close`, publishes comment activity after its transaction commits,
and labels inbound human bubbles in both task-chat renderers.
Tested the fix in the isolated `codex/imessage-photon` worktree on September 12,
2026 at 13:5613:57 America/Chicago, against the operator's existing Pro DM
endpoint (`99bebf95…3884`) and PHOTON-17 (`bd6d8379…ba15`). Left the task page
open and sent two authorized messages through Apple Messages to the same Photon
conversation, waiting for completion between sends. Both appeared without a
page reload and both reopened PHOTON-17. Its bubbles showed “Sent from
iMessage”; the agent returned “PHOTON-17 live follow-up received” and
“PHOTON-17 still one conversation” through Photon. The earlier task records
were preserved as history. No live `/new` was sent to replace the operator's
current conversation; explicit reset, close, stale controls, duplicate delivery,
restart, and dedicated-group continuity are covered by integration fixtures.
The live server was then restarted on `4d7222110`. A third message asked the
agent to repeat its previous reply. It appeared live on PHOTON-17 with its
iMessage label, and the agent returned the exact previous reply through Photon.
All 304 focused tests passed on that commit, and the existing Teams completion
boundary passed its separate regression test.
Interactive Storybook coverage lives under **Connections / iMessage Photon**.
It uses the production catalog card, three-step channel wizard, access and
management pages, and task message bubbles with explicitly simulated provider
actions. Thirteen stories cover catalog discovery, agent selection, credentials,
shared setup, multiple dedicated lines, missing allocation, loading, connecting,
outage recovery, reconnect, identity access, and persistent task follow-ups.
All 26 light/dark Playwright cases passed, including the 390px mobile layout.
The credential and mobile screenshots were inspected. Run with:
```sh
pnpm build-storybook
pnpm exec playwright test --config tests/storybook-visual/imessage-photon.config.ts
```

View File

@ -0,0 +1,233 @@
# iMessage Photon
**Status: experimental. Live-provider qualification is pending.**
This channel connects one agent to Photon Cloud. Pro shared allocation supports
DMs; dedicated lines also support explicitly enabled groups. A linked
Paperclip person can send a DM, exchange files, answer questions, and respond to
ordinary confirmations. Each conversation remains attached to a Paperclip task.
The connection is a channel with `chat_sdk` transport, not an MCP tool connection.
## Prerequisites and setup
1. Enable the existing experimental chat-connectors setting. Open **Apps →
iMessage Photon**, or the agent's **Channels** panel.
2. In the [Photon dashboard](https://app.photon.codes/), obtain a project ID
and project secret. Paperclip checks the project's actual allocation. Pro
shared allocation is eligible for DMs only. Enroll each sender in the Photon
project's **Users** page and find their assigned number in **Get started**.
This enrollment does not authorize them in Paperclip. See Photon's
[line model](https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing).
3. Choose one invokable agent. Enter the project ID and secret, inspect the
allocation. Connect shared DMs, or select a dedicated line. A single eligible dedicated line is selected
automatically. A number already reserved by any non-archived endpoint in
this instance cannot be selected, including a paused or revoked endpoint.
Shared projects have the same exclusive reservation by project ID. Their
assigned numbers may differ by sender and are not represented as owned numbers.
4. Send a fresh message to the displayed dedicated number, or to the sender's
assigned number from Photon for shared DMs. Link the discovered Messages
identity through Paperclip's identity confirmation flow. Send another fresh
message from that linked person. Setup completes only after a task is created
and an actual agent response is published successfully.
5. With a dedicated line, to use a group, add the number in Apple Messages and send a message to discover
it. Enable the group in Paperclip's Settings page, then send a fresh request.
Discovery does not enable a group or replay the discovery message as work.
The server needs outbound HTTPS to `spectrum.photon.codes` and TLS gRPC to the
selected `<line-id>.imsg.photon.codes:443` endpoint, or
`imessage.spectrum.photon.codes:443` for a shared project. No public webhook, Mac Messages
permissions, Spectrum application runtime, or additional agent loop is needed.
Project secrets are write-only and vaulted. Inspection is restricted to connection
managers and returns project identity, line IDs, phone numbers, and eligibility;
it does not return credentials or line tokens. Agents never receive the project
secret. The server holds short-lived line tokens in memory, renews before expiry,
and checks that the project, line, and number have not changed. Every operation
uses that selected line. Replacing credentials must preserve the same identity;
connect a different identity with a new endpoint.
Setup and inspection distinguish credential/allocation errors (HTTP 422), quota
limits (429), temporary provider outages (503), and invalid upstream responses
(502). An outage does not mean valid credentials need replacement. A failed
reconnect leaves the existing credential binding intact.
## Conversation and access rules
DMs are enabled by default. Dedicated groups start disabled; shared channels
reject groups at admission, publication, and settings changes. Unlinked people cannot
start work unless an operator explicitly enables that setting. Identity links
use the provider-authenticated sender address and service. A phone number and an
Apple-account email are separate identities; names and group membership do not
grant Paperclip authority. Revoked links and inactive/viewer memberships cannot
answer interactions. Guest work retains the shared channel restrictions.
Enabling a group makes the agent's responses visible to everyone in that group.
It does not authorize every participant to start work. Every authorized message
in an enabled group can start or continue work without a mention. Group names
and participants are displayed in Settings. If the agent's number leaves the
group, that destination becomes unavailable and publication is blocked.
DMs and groups are linear conversations. An authorized request starts a task;
follow-ups append to the current generation through the ordered delivery queue.
Completing a task ends the current turn. The next message reopens that same task,
including after a server restart. Incoming messages appear live on the open task
as user bubbles labeled “Sent from iMessage.” `/status` shows the current task,
`/close` closes the conversation,
and `/new` closes the current generation so the next request starts a new task.
Quoted message GUIDs and multipart references are retained as task context.
Quotes do not create separate tasks. A quoted control from an older generation
cannot close a newer task. Outgoing echoes, reactions, read receipts, typing,
and nonhuman system messages do not start agent work.
Messages, tasks, assets, publications, identities, and state remain company-scoped.
Number and shared-project reservations are deliberately instance-wide. Task assignment, budget
limits, pauses, approvals, and native/legacy execution continue through the
existing Paperclip services.
## Questions and confirmations
Ordinary `ask_user_questions` uses native polls for closed single-choice questions
with 210 options. Prompts include a text alternative. Correlation uses the returned
poll message GUID and option IDs; duplicate titles and option labels are not lookup
keys. Responses from other devices, added options, missing actors, expired prompts,
and later vote changes cannot undo a completed decision.
Reply to the exact prompt, or use `/answer <reference>[.<question>] <value>`.
Numbered choices, comma-separated multiple choices, custom text, and optional
`skip` answers are supported. Questions appear sequentially. Multiple-question
sets save a separate draft for each person and require `/submit <reference>`.
Paperclip's canonical validators check required answers and selection/numerical
rules before resolution. Different people cannot contribute to the same draft.
Ordinary `request_confirmation` offers explicit Accept/Reject. A required rejection
reason is collected through a correlated text response. Target revision, audience,
current identity, task generation, endpoint status, and permissions are rechecked
at submission. Responses resolve through the canonical interaction service and
its durable continuation delivery. A terminal acknowledgement is published once.
Arbitrary “yes” messages and tapbacks never constitute approval.
Credential proposals, connection authorization, governed tool actions, and review
kinds that need the full review surface remain in Paperclip. The channel supplies
a task link and instructions. No individual-iMessage web permalinks are fabricated.
## Photos and files
Text, JPEG/PNG/WebP/GIF, allowed documents, audio, and video use Paperclip's existing
attachment policy and byte limits. Provider upload allowances do not raise those
limits. Attachments are source-bound to the selected line, chat, message, and
attachment GUID before downloading. The server verifies that ownership again on
recovery, bounds metadata, streamed bytes, time, and decoded image dimensions,
and reports rejected/unavailable files in the task. A not-yet-ready attachment
retries before waking the agent, without creating another comment.
HEIC/HEIF are included in the default attachment policy; operator overrides still
win. The original remains downloadable and a JPEG derivative supplies browser
preview and image input to the agent. The derivative records its source attachment
and hashes. Conversion runs in a separate process with input/output/pixel limits
and a deadline. `heif2jpeg@0.1.6` publishes macOS, Windows, and Linux glibc packages
for x64/arm64; it does not publish Linux musl binaries. A missing or failed converter
retains the original and reports preview unavailability. Only macOS arm64 has been
executed locally for this change; other platform binaries still require qualification.
Live Photo stills and policy-allowed companion videos are retained as attachments
on the same message. Native Live Photo reconstruction is not implemented. Outbound
files require the existing task/company/agent/originating-run authorization. The
server uploads actual bytes; it never sends private storage URLs to Photon.
## Publication and recovery
Only output classified for external publication is sent. Internal commentary,
reasoning, raw tool output, and credentials stay internal. Final responses use
normal bubbles and the channel refreshes typing while work runs. Text is split at
paragraph boundaries with a 4,000-Unicode-code-point target and preserved order.
Source-message reply references are used when the originating run identifies one.
Every text part, attachment message, poll, and explicitly staged correction has a
stable `clientMessageId` and immutable payload. Upload completion is recorded before
the attachment message is sent. Native edits have a bounded window; ordinary final
responses and acknowledgements are separate messages, never token-by-token edits.
A timeout after transmission is **delivery unknown**. Inspect the activity record
and known Photon receipts, then use Paperclip's operator resolution/retry controls.
Do not retry by creating another publication or changing its key. Explicit retries
reuse the original key and payload. Similar text is not evidence of delivery. An
ambiguous upload without a recorded receipt also needs operator review.
One elected receiver holds the endpoint lease. Live streams notify a serial
catch-up reader. The reader advances its checkpoint only after preceding events
are durably admitted or classified, including irrelevant events. It deduplicates
provider sequence and message identity independently and reconstructs chats,
attachments, and poll mappings from persisted state after restart.
Dedicated recovery requires adjacent sequence numbers. The shared gateway's
project-filtered feed has increasing, non-adjacent sequences. Shared recovery
commits its checkpoint only after the complete replay barrier and every preceding
admission succeed. Interrupted or out-of-order replay retains the previous cursor.
Shared channels do not subscribe to the unsupported group stream.
The pinned SDK's public catch-up iterator discards sequence-only/unknown-variant
frames. Paperclip's small authenticated gRPC recovery transport retains their
sequence while delegating known event decoding to the SDK. This prevents false
history gaps without silently skipping a frame. A missing/reset cursor or an
actual history gap stops in Attention. Initial historical messages establish a
checkpoint but do not create old tasks automatically.
Pause stops execution and external publication while retaining already accepted
pending work. Resume establishes a new intake cutoff, so messages deliberately
suppressed during pause do not become work. Outage recovery catches up eligible
missed messages. Disconnect archives the endpoint, stops streams, invalidates
interaction authority, and removes owned secret bindings. It does not delete the
Photon project, number, subscription, or Messages history. Hiding experimental
UI alone does not disconnect existing channels.
## Troubleshooting
| State or symptom | Action |
| --- | --- |
| Invalid project credentials | Replace the vaulted secret for the same project/number and reconnect. |
| Shared allocation | Connect shared DMs, enroll the sender in Photon, and use their assigned number. Groups require a dedicated line. |
| No eligible dedicated lines | Review the project's line allocation in Photon, then inspect again. |
| Number already owned | Use its existing endpoint or remove that endpoint before reconnecting the number. Pause retains the reservation. |
| Number changes/disappears | Review the Photon allocation. Restore the original identity or create a new endpoint. |
| No task from a group message | Groups are disabled for shared channels. For a dedicated channel, enable the discovered group, link the sender, and send a fresh request. |
| Setup remains Verifying | Complete the linked fresh-message → task → actual agent reply loop; a credential check is insufficient. |
| Quota/network interruption | Review Activity. Transient errors retry with bounded backoff; quotas are distinct from authentication failures. |
| Attachment preparing | Let the durable delivery retry; do not resend the message to force another task. |
| Preview unavailable | Download the original and verify converter support/policy on this deployment platform. |
| Delivery unknown | Reconcile the exact provider receipt or explicitly retry the same immutable publication. |
| Missing/reset cursor or history gap | Review the affected period before operator recovery. The service does not silently skip it. |
| Old poll no longer works | Open the task's current interaction. Completed/expired polls cannot reverse a decision. |
Diagnostics use existing local activity and run records. This change adds no
first-party Telemetry events. Persisted receipts/checkpoints are required for
recovery; do not manually delete provider state to resolve an outage.
## Qualification and source versions
See [implementation and acceptance plan](../plans/2026-09-11-imessage-photon.md)
and [verification record](IMESSAGE-PHOTON-VERIFICATION.md). Deterministic fixtures
and synthetic gRPC are not live-provider proof. A dedicated test line, known
participants, real iPhone HEIC, and native polls are required before claiming the
full live acceptance loop.
Pinned dependencies: `@photon-ai/advanced-imessage@2.1.0`, `@grpc/grpc-js@1.14.4`,
`nice-grpc@2.1.17`, `nice-grpc-common@2.0.4`, `heif2jpeg@0.1.6`.
First-party references inspected on 2026-09-11:
[Cloud authentication](https://github.com/photon-hq/spectrum-ts/blob/main/packages/core/src/utils/cloud.ts),
[SDK](https://github.com/photon-hq/advanced-imessage-ts),
[events](https://photon.codes/docs/advanced-kits/imessage/events),
[polls](https://photon.codes/docs/advanced-kits/imessage/polls),
[attachments](https://photon.codes/docs/advanced-kits/imessage/attachments),
[idempotency](https://photon.codes/docs/advanced-kits/imessage/error-handling), and
[HEIF converter](https://photon.codes/docs/utilities/heif2jpeg).
### Shared-gateway duplicate receipts
The Pro shared gateway has been observed returning gRPC `ALREADY_EXISTS` as SDK
`internalError`, without a receipt, when an identical `clientMessageId` is repeated.
Paperclip retains delivery-unknown state if no stored receipt exists. Inspect the
original conversation and use the existing operator resolution action. Do not
create another idempotency key or infer delivery from matching text. Photons
[documented idempotency behavior](https://photon.codes/docs/advanced-kits/imessage/error-handling)
says repeated writes return the original result; the live shared-gateway result
is recorded separately in the verification report.

View File

@ -8,8 +8,11 @@ agent tutorial from provider research and protocol classification through
manifest generation, branding, secrets, deterministic tests, real-account
proof, and PR submission.
Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md). Optional credential custody:
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md),
[AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody:
[Vercel Connect](./VERCEL-CONNECT.md).
Post-read action: classify a new integration request, pick the right Paperclip

View File

@ -1009,3 +1009,67 @@ Admission atomically settles an unclaimed coordinator and admits one fresh turn,
preserving history, unknown action outcomes, and attempt counts. Pauses, approvals,
budgets, task ownership, and terminal task status still gate admission. No
automatic provider replay is authorized by a cancelled startup.
### Delivering queued messages after a legacy run stops
The legacy queued-message Interrupt action accepts a null `targetRunId` when
there is no active turn. It validates the queue identity and revision under
the task lock and records durable board intent to send the saved queue. A
run that stops between the queue read and the click is also accepted. The
server never redirects interruption to an unrelated active run.
Intentional interruption does not show the global cancelled/failed run toast;
the queue control supplies its own delivery feedback.
This click can authorize a fresh conversation for messages written before
the prior run stopped. It preserves the original message content and authors,
and retains process/lease stop proofs, task ownership, pauses, approvals, and
budget checks. Queue edits and discards remain authoritative until dispatch.
Dispatch revalidates the consumed queue receipt against the operator, task,
agent, message, and successor run; the operator need not be the message author.
Repeated delivery attempts cannot create another successor after the queue
is consumed. Native same-turn steering retains its active-target contract.
Legacy finalization retries deferred input after adapter and lease cleanup.
The scheduler also revisits bounded batches of stranded queues after restart
or a late enqueue. Both use normal admission; an existing queued successor
owns the next turn even before it acquires the task execution lock. A recovery
hold or a plain operator Stop does not by itself authorize old input. The
successor guard is scoped to the same agent so another agent's review
participation keeps its independent recovery path.
An explicit queued-message Interrupt also grants one scoped cleanup retry for
the stopped run. Old ephemeral leases whose cleanup predates provider stop
receipts are rechecked through the recorded provider teardown path. Retained
resources and sandboxes owned by another lease are not rechecked this way.
Delivery still requires the provider's verified stop receipt. Periodic queue
retries do not gain extra cleanup attempts, and the queue displays the server's
waiting reason while cleanup remains unresolved.
### Operator identity and permission for manual dispatch
A legacy queued-message Interrupt is a new instruction from the user who clicks
it. The new run uses that user's execution identity, including when someone else
wrote the queued messages. Message bodies and historical authors stay unchanged.
The task page and pipeline conversations both permit Interrupt after the target
run stops and submit the queue's current revision.
Startup validates the consumed queue receipt against the new run, company,
agent, task, clicking user, and delivered message IDs. Automatic retries inherit
the resulting execution identity through the ordinary run identity history.
Starting an existing agent requires `agent:wake`, which active non-viewer board
members have within their company. Both wake endpoints use this action instead
of `agents:create`. An exact task retry also checks `issue:comment` on the task
from the stored failed run and verifies that its assigned agent has not changed.
External chat retries retain their additional conversation authorization.
Ordinary board wake requests also persist the clicking user's identity, so
adopting another author's queued message cannot change their execution authority.
If that wake merges into an older deferred request, the same transaction updates
the request's execution requester to the clicking user.
Manual wake requests wait for their own run and execution identity. They do not
merge into an agent's active run, with or without a task.
Private agent conversations retain their owner-only wake and retry checks.
These actions do not grant permission to hire agents or change their settings.
Each action during execution still checks the agent's authority and the
responsible user's authority. A denied retry returns before dispatch; it does
not create a new failed run or change the task's state.

View File

@ -0,0 +1,81 @@
# AI Connections — Storybook review milestone
Status: UI review approved. The app integration is implemented; see [AI Connections](../connections/AI-CONNECTIONS.md) for contracts, runtime selection, adoption, and verification.
## Start here
- [Existing Connectors page with AI accounts](http://localhost:6116/?path=/story/ai-connections-review--provider-catalog)
- [Account details in the existing page](http://localhost:6116/?path=/story/ai-connections-review--management)
- [Add account through the existing setup flow](http://localhost:6116/?path=/story/ai-connections-review--connect-from-existing-catalog)
- [Existing inline task connection host](http://localhost:6116/?path=/story/ai-connections-review--inline-task-connection)
- [Review index](http://localhost:6116/?path=/story/ai-connections-review--review-index)
The old `provider-catalog` URL is retained so links still work. It now renders the real `Browse` page, with AI accounts alongside GitHub and Gmail. It is not another product screen or another provider catalog.
Build and serve with `pnpm build-storybook` and `node scripts/serve-storybook-static.mjs --port 6116`. Alternatively run `pnpm storybook` on its normal development port.
The agent picker omits the personal-account inventory and its default/authorization actions. It retains the responsible-user default preview and compatible shared or already-authorized selections. **Change Personal Default** now exercises the existing account detail page; **Authorize Personal** isolates the owner-consent dialog.
Account details are deliberately compact: a personal-default row with a colored star/check when active, plus credential identity and reconnect/revoke actions. Agent usage and the redundant back button are removed. The preview includes the existing BreadcrumbBar for navigation. Revocation details remain in the confirmation dialog.
## Reading the story frames
Every AI review story has a **Storybook only · Review guide** above the preview. It identifies the intended app location, existing app components, proposed components, and simulated wrapper/state. Dashed boundaries mark review annotations, not product UI.
Agent preview headings, harness/model values, form buttons, and provider simulation controls are labeled **Storybook only**. The picker/authentication composition has its own marked component boundary. Real Connectors pages identify the new AI-only section inside the existing page; task stories mark the existing request component. These annotations live exclusively under `ui/storybook/` and do not appear in production.
## Existing components investigated and reused
The current `/:company/apps` route renders **Browse**, not the older Connections page. Its actual provider groups, account rows, search, Add account buttons, status icons, owner identities, and management menus are mounted in the stories. A small optional account-detail slot adds AI sign-in method, personal/shared identity, default, and delegation metadata to its existing rows.
| Existing component | Reuse in this milestone |
| --- | --- |
| `pages/apps/Browse.tsx` | Real Connectors list; existing provider groups and account rows. No standalone AI list. |
| `pages/apps/AppDetail.tsx` | Existing header, naming, identities, permission loading and account status. |
| `app-detail/IdentitiesSection.tsx` | Existing personal/company ownership display, member audience selection, and revoke confirmation dialog. |
| `app-detail/PermissionsPanel.tsx` | Existing agent access radio cards and agent selector. Only the irrelevant tool-action section is replaced with AI account/default controls. |
| `app-detail/AdvancedPanel.tsx` | Existing reconnect banner with an optional provider-auth callback, behind its existing permission check. |
| `features/connections/ConnectionSetupFlow.tsx` | Existing branded setup shell, human/agent access step, navigation, cancellation and reuse flow. Provider login is composed in a credential-content slot. |
| `features/connections/ConnectionIntentInteractionBody.tsx` | Real task card, modal, existing-account choice, completion and return-focus lifecycle. |
| `ConnectionChoiceList` | Extracted from the existing setup flow's account-reuse rows. Both that flow and the AI agent picker render this component. |
| `pages/apps/AppLogo.tsx` | Existing branding component in AI identity summaries; handles local and dark assets. |
| `AdapterLoginChrome`, `AgentConfigForm`, `AgentProviderConnection` | Existing subscription card/code/input presentation extracted into shared wrappers; live lifecycle hooks remain owned by the existing hosts. |
| `OnboardingWizard`, `ModelSourceTiles`, `CredentialModeLink` | Existing onboarding provider/method controls and API credential card remain shared. |
The separate `AiProviderPicker`, `AiConnectionRow`, and standalone AI management form have been removed. New AI-specific presentation is limited to agent binding selection, personal defaults/delegation, AI account controls, and controlled auth states. The design guide explains these boundaries and shows the shared picker and credential presentation.
## Fixture boundaries
`AiConnectorPages` mounts real route components against an isolated in-memory API. `AiTaskConnectionReview` mounts the real connection-request host. They use the production provider catalog, including the existing `anthropic` entry, and the `ai` / `runtime_auth` discriminator. Accounts and provider responses remain fixtures.
`AiConnectionsReview` covers proposed agent binding/default behavior with deterministic configuration data. Its agent/onboarding hosts are composition previews, not production route integration. The new-agent and onboarding login presentation uses the extracted existing authentication components.
No fixture contains credential material. Input values are cleared after submission; provider completion is simulated. No story signs in to a provider or grants real access. Personal-default resolution and delegation checks in `model.ts` are presentation validation, not server authorization.
## Review and verification
Review the Connectors list first, then open an account, add one, reconnect, reuse it in a task, and exercise the AI binding picker. Theme and viewport controls cover desktop/narrow and light/dark. Keyboard coverage includes the shared chooser buttons, dialogs, and return focus after cancellation/completion. Harness/model values are asserted unchanged by connection selection.
```sh
pnpm --filter @paperclipai/ui typecheck
pnpm check:token-gates
pnpm --filter @paperclipai/ui exec vitest run src/pages/apps/Browse.test.tsx src/pages/apps/AppDetail.test.tsx src/pages/apps/AppsConnect.test.tsx src/features/connections/ConnectionIntentInteractionBody.test.tsx src/components/ai-connections src/components/AdapterLoginChrome.test.tsx src/components/OnboardingWizard.adapters.test.tsx src/components/OnboardingWizard.test.tsx
pnpm build-storybook
pnpm exec playwright test --config tests/ai-connections-review/playwright.config.ts
```
Verified: 277 focused Vitest checks, 53 browser checks across 48 stories, UI typecheck, token gates, and Storybook build. Desktop and narrow screenshots were inspected in light/dark themes.
Browser checks load every AI review story, await its interaction assertions, reject rendering/play errors, verify review-index links, test keyboard selection, and capture light/dark layouts at desktop and narrow widths while checking overflow. Screenshots remain ignored local test artifacts.
The counts above record the original UI review. Integration verification is recorded in the implementation handoff; live provider verification requires valid accounts and a supported sign-in environment.
## Agreed implementation after review
- Extend existing applications/connections/grants/installations/delegations with an AI purpose and runtime-auth transport. Reuse encrypted secret storage and company access checks; keep tool/channel execution separate.
- Add typed agent bindings and personal defaults keyed by company, user, provider, and sign-in method. First successful personal connection becomes default only when none exists. Additional defaults require explicit selection; revocation never chooses a replacement.
- Resolve personal defaults from the run's responsible user. Shared and dedicated personal bindings are explicit. Only the owner can authorize their personal account across responsible users.
- Resolve the chosen grant before local, sandbox, native-runner, and test execution. Prevent inherited credentials or cached homes from overriding it; partition session/auth reuse by grant identity, preserve refresh ownership, and report actionable missing-access blockers.
- Keep provider, method, harness, and model routing fixed during connection selection. Start with Claude, OpenAI/ChatGPT, OpenRouter, and Grok/xAI, using only supported existing authentication methods and harness integrations.
- Preserve legacy execution until adoption. Index only credentials with reliable ownership; never infer ownership from account-home paths. Test and explicitly save the replacement binding; do not restore legacy fallback after adoption.
- Schema/API, runtime integration, adoption, and production UI wiring are implemented after approval. There is no separate AI Connections feature flag.

View File

@ -0,0 +1,239 @@
# iMessage Photon channel
Date: 2026-09-11. Status: approved for implementation; qualification tracked below.
Base: origin/master, 1c4bcff2b. Branch: codex/imessage-photon.
## Approved scope update — 2026-09-12
The operator approved Pro-compatible shared DMs with groups disabled. This
supersedes the dedicated-only exclusions below for DMs. Shared setup uses the
project token and fixed `imessage.spectrum.photon.codes:443` gateway, reserves the
project across non-archived endpoints, and derives a project-scoped conversation
and checkpoint namespace. It never claims ownership of a pool phone number.
Sender enrollment in Photon and identity linking in Paperclip are separate gates.
Dedicated lines retain the original behavior. Allocation changes require a new
channel. Native groups remain unavailable on shared channels at every boundary.
The real qualification uses an isolated clean database, a test-only agent, the
operator's enrolled Messages identity, and the existing Apps wizard. Photon offers
a terminal development provider and control-plane CLI; neither substitutes for
an iMessage Cloud round trip. Record actual live results separately from fixtures.
## Outcome and defaults
Add **iMessage Photon** (`imessage-photon`) behind the existing experimental
chat-connectors UI gate, in Apps and each agent's Channels panel. Use Photon
Cloud and one dedicated number per channel/agent. People initiate DMs and
explicitly enabled groups; every authorized group message can start or continue
work without a mention. Require linked Paperclip people by default. Unlinked
senders require an explicit operator opt-in. Never infer authority from a phone
number, email address, display name, or group membership, or merge those identities.
Messages remain bound to tasks through the existing chat subsystem: company
scope, vaulted credentials, durable admission, endpoint/conversation leases,
ordered wakeups, external principals, task generations, publication outbox,
attachment provenance, budgets, pauses, native and legacy execution. Keep
`connectionPurpose: channel` and `chat_sdk` transport. Chat-approved external
responses publish automatically; AgentMail's explicit email-send policy stays
specific to email. Do not add a second agent loop, Spectrum application runtime,
generic Photon MCP connection, or arbitrary send API.
Excluded initially: local Mac access, shared-pool numbers, SMS/RCS, unsolicited
new conversations, agent-created groups, calls, location, stickers, backgrounds,
custom iMessage apps, and native Live Photo reassembly. Hiding experimental UI
must not stop existing channels; Pause/Disconnect control runtime behavior.
## Provider and credentials
Implement an in-repo Chat SDK adapter using `@photon-ai/advanced-imessage@2.1.0`
with explicit gRPC dependencies. Reference upstream Photon adapter behavior but
persist correlation by IDs, not in-memory poll titles. Separate Cloud client,
receiver/recovery, adapter, attachment, and interaction helpers.
Accept project ID and write-only project secret. Inspect Photon using Basic
project authentication and its project/token endpoints. Return only project
identity, allocation eligibility, line IDs, and numbers to managers through
`POST /api/chat-endpoints/:endpointId/photon/inspect`; never return minted tokens.
Verify dedicated allocation from actual token data. Auto-select a sole eligible
number; require explicit selection for multiple numbers. Reject shared allocation.
Vault the secret; store validated project/selected-line config separately. Mint
line tokens in memory, renew before expiry, bind every RPC and stream to the
selected instance, and recheck number/ownership on renewal. Missing, changed,
or ineligible line enters Attention. Rotation preserves project and number;
a new identity needs a new endpoint. Retire old ownership before replacement.
Extend provider contracts across db/shared/server/UI and generate a forward
migration. Reserve the Photon number across companies for every non-archived
endpoint, including paused/revoked endpoints. Persist checkpoints, typed poll
bindings, per-person drafts, immutable send identities, upload receipts, and
side effects in company/endpoint-scoped state/action stores.
## Setup and management
Three steps: choose an invokable agent; connect and inspect Photon credentials
and select a dedicated line; test from Apple Messages. Link Photon dashboard and
dedicated-line documentation. Defaults: DMs on, unlinked people off, individual
groups disabled. Show actionable missing/shared/duplicate/invalid credential
errors. Show the copyable number and discovered sender, support the existing
identity-link confirmation. Only a linked sender's fresh message that creates a
task and receives a successful publication completes setup. Credential verification
alone does not. Optional group test: add number in Messages, discover group,
enable it in Paperclip, send a fresh authorized request.
Management shows agent/project/number, health, receive/send timestamps, discovered
groups and participants/availability/enablement, linked people/revocation,
delivery retry/unknown outcomes, pause/resume/reconnect/disconnect. Explain that
group replies are visible to all members while sender authorization is separate.
Use existing design tokens/components, official branding with provenance, themes,
keyboard/loading/error/narrow-screen states, task links and copyable number;
never invent individual iMessage web permalinks.
## Conversations and authorization
DMs/groups are linear: one active task generation per endpoint/chat. Fresh
authorized input creates work when none is active; follow-ups append and use
existing ordered queue/coalescing. Per the September 12 product correction,
terminal tasks reopen on the next message in the same conversation. Only an
explicit `/new` or `/close` followed by a fresh message creates a new generation.
Inbound comments appear live with “Sent from iMessage” attribution. Support `/status`.
Preserve native reply GUID/part as context; it does not create a separate task.
Chronology guards protect later generations from stale controls. Rename/avatar
changes affect presentation only. Bot removal marks a group unavailable and
blocks sends. Ignore outgoing echoes and system/read/typing/reaction/metadata
events as work triggers. Linked identity/current membership and resource/endpoint
policy are rechecked at admission and interaction resolution.
## Questions and approvals
Support ordinary ask_user_questions and eligible request_confirmation. Single
select 210 canonical options uses native polls with persistent returned poll
GUID/option-ID bindings, plus text fallback. Never match by title/label. Questions
appear sequentially with a short reference. Accept exact native prompt replies
or `/answer <reference> <value>`; support free text, custom/numbered multi-select,
optional skipping, and canonical validation including numerical constraints.
Per-person drafts cannot mix; multiple-question sets require `/submit <reference>`.
A single question resolves on first valid authorized submission.
Confirmations show the approved external summary and Accept/Reject, collecting a
required rejection reason via correlated text. Revalidate target revision,
audience, linked user permission, generation and endpoint before using canonical
interaction/continuation services. Arbitrary yes/tapbacks are never approvals.
Credential disclosure, connection authorization, governed tool execution, and
review kinds requiring the full Board surface get an explanation/task link.
Handle recognized responses before normal comment ingestion. Duplicate votes,
missing actors, stale/expired links, changed membership, late poll changes and
participant-added options cannot resolve. Unvotes only clear unsubmitted drafts.
Exactly one canonical resolution and continuation; terminal acknowledgement;
old polls inert. Test provider poll creation before local binding crash and votes
before binding finalization without title matching or duplicate resolution.
## Photos, attachments, and publication
Support text and policy-allowed images/documents/audio/video. Persist a closed
line/chat/message/attachment/optional-part locator before fetching. Authenticate
over selected line and verify attachment ownership via message/chat. Bound
metadata, bytes, time and decoded dimensions. Keep Paperclip's configured limits,
not Photon's larger allowance. Retry attachmentNotReady before agent wake,
without duplicate comments. Preserve attachment-only input, captions, multiple
images and multipart order; surface unavailable/rejected files in the task.
Add HEIC/HEIF to default policy while honoring overrides. Preserve originals and
produce a labeled JPEG derivative for preview/agent image input through bounded
`heif2jpeg@0.1.6`; verify packaged platform binaries. Preserve provenance. Keep
Live Photo still/allowed companion video as related files.
Outbound files need existing company/task/agent/originating-run authorization.
Send bytes, never private storage URLs. Immutable outbox carries stable
clientMessageId for each text part/file/poll/correction; retry same key/payload.
Persist upload receipt before send. Split plain readable text at paragraph
boundaries near 4,000 Unicode-safe characters, preserving order/URLs/code.
Final messages plus typing, not token bubbles. Use native reply references.
Edits within provider limits; expired edits fail visibly or require staged correction.
Internal reasoning/commentary/tools/credentials remain internal.
Transmission timeout is delivery_unknown. Reconcile exact receipts, never
similar text; operator resolution handles unresolved results, never silently
mint a new idempotency key. Do not assume undocumented provider key retention.
## Receiving and recovery
Elect one receiver per endpoint with lease renewal/generation fencing. Subscribe
to live message/chat/group/poll events concurrently with catch-up. Dedupe by line
instance/event sequence and message GUID. Advance checkpoint only after all
preceding events are durably admitted/classified, including irrelevant events.
Bound intake; execution follows durable admission. Rebuild chats, attachments,
and poll mappings after restart without SDK caches.
Initial activation cutoff suppresses historical work while allowing metadata and
checkpoint establishment. Pause stops execution/publication and retains accepted
work; record pause boundaries to suppress intentional pause interval input.
Outages catch up eligible missed events. Missing/reset/gapped history enters
Attention, never silently skips. Distinguish auth/line/quota/network/preparation/
ambiguous send failures, bound retries, expose recovery actions. Disconnect stops
streams, invalidates interaction authority, archives endpoint/removes owned secret
bindings; never deletes Photon project/number/subscription/history. No webhook.
Diagnostics stay local; any Telemetry change needs separate strict review.
## Delivery and verification
1. Worktree/isolation, source versions, shared contracts/state, generated migration.
2. Cloud inspection, line auth/renewal, adapter lifecycle, synthetic gRPC fixtures.
3. Durable ingress, authorization/linking/resources/generations/queue/publication.
4. Attachments/recovery/HEIC, polls/text drafts/confirmations/continuation.
5. Catalog/setup/management, browser checks, docs, live qualification, PR preparation.
Keep logical commits; existing Slack/Discord/AgentMail and native/legacy paths
remain functional. Acceptance covers invalid/missing/shared/multiple/duplicate
lines; interrupted setup; unlinked/revoked/viewer/wrong-company/wrong-line inputs;
disabled/removed groups; concurrent/ordered bursts and generation commands;
restart/duplicate/out-of-order/takeover/renewal/catch-up/pause; stable multipart
keys, Unicode, rate limits, successful-send crash/unknown/partial uploads/edit
expiry; image-only/multiple/HEIC/corrupt/oversize/delayed files; exact poll IDs,
free/custom/multiselect/partial/submitted/invalid drafts; accept/reject/reason,
stale target, Board race and one continuation; accessible responsive UI.
Run targeted suites, migration/package checks, affected chat-adapters browser
suite, then `pnpm check:token-gates`, `pnpm -r typecheck`, `pnpm test:run`,
`pnpm build`. Leave lockfile to the repository bot. Update channel setup,
troubleshooting/feature boundaries and relevant spec addendum; regenerate catalog.
Prepare every PR-template section and explicitly report unavailable credentials,
failed checks and unqualified behavior.
Live proof requires a dedicated test line and known participants: linked DM and
agent reply; enabled group with two linked participants/attribution; unlinked
denial; both-direction photos including real iPhone HEIC; poll/text continuation;
rejection reason; restart retaining DMs/groups/questions; pause/resume/reconnect/
remove; terminal conversations idle until fresh input. Record tested commit,
versions, redacted IDs, observable outcomes and limitations. Mocks are not live
qualification. Full DM/group/media/interaction/restart/auth loop is required.
## Primary sources (verified during planning 2026-09-11)
- https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing
- https://github.com/photon-hq/advanced-imessage-ts
- https://github.com/photon-hq/spectrum-ts/blob/main/packages/core/src/utils/cloud.ts
- https://photon.codes/docs/advanced-kits/imessage/polls
- https://photon.codes/docs/advanced-kits/imessage/attachments
- https://photon.codes/docs/advanced-kits/imessage/events
- https://photon.codes/docs/advanced-kits/imessage/error-handling
- https://photon.codes/docs/utilities/heif2jpeg
Inspected versions: advanced-imessage 2.1.0, Photon chat adapter 3.2.0,
Spectrum core/iMessage 12.8.0, heif2jpeg 0.1.6. Cloud Basic auth project endpoint:
`https://spectrum.photon.codes/projects/{projectId}/`; token exchange:
`POST .../imessage/tokens`; dedicated response has `auth` and `numbers` maps keyed
by instance ID and `expiresIn`. Line gRPC host: `{instanceId}.imsg.photon.codes:443`.
## Implementation evidence
Implemented in the fresh `codex/imessage-photon` worktree. Provider contracts,
forward migration, Cloud inspection, leased recovery, source-bound media,
immutable publication, native interaction continuation, and the setup/management
UI are present. The channel remains experimental.
The [channel runbook](../connections/IMESSAGE-PHOTON.md) documents operation and
supported boundaries. The [verification record](../connections/IMESSAGE-PHOTON-VERIFICATION.md)
records automated results and the still-unrun live qualification matrix. No live
Photon credentials or approved test participants were available. Implementation
must not be represented as live-provider qualification.

View File

@ -59,7 +59,7 @@
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/select-cloud-cache.test.mjs",
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
@ -117,7 +117,9 @@
"@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.263",
"rollup": ">=4.59.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"@codemirror/state": "^6.7.2",
"@codemirror/view": "^6.43.11"
}
}
}

View File

@ -2070,7 +2070,7 @@ async function buildRuntime(input: {
// device login wrote. This never touches `prepareCodexSkillRuntime` above
// — that function stays Codex-only — and every other custom ACPX agent
// (for example `kimi`) falls through this branch unaffected.
if (acpxAgent === "grok") {
if (acpxAgent === "grok" && !config.managedAiConnection) {
env.GROK_HOME = resolveManagedGrokHomeDir(agent.companyId);
}
const desired = resolveLegacyPaperclipDesiredSkillNames(

View File

@ -1,6 +1,6 @@
export interface PaperclipChatFilePreparationDelivery {
readonly provider:
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | null;
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon" | null;
readonly mode: "provider_attachment" | "paperclip_task_only" | "unknown";
readonly preparationState: "prepared";
readonly providerDeliveryConfirmed: false;
@ -35,7 +35,8 @@ export function paperclipChatFilePreparationDelivery(
if (
authenticatedProvider === "slack" ||
authenticatedProvider === "discord" ||
authenticatedProvider === "telegram"
authenticatedProvider === "telegram" ||
authenticatedProvider === "imessage-photon"
) {
return {
...common,

View File

@ -797,7 +797,7 @@ type PaperclipWakeRecovery = {
};
export type PaperclipExternalChatProvider =
"slack" | "github" | "discord" | "microsoft-teams" | "telegram";
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon";
type PaperclipWakePayload = {
executionContinuation: ExecutionContinuationEnvelope | null;
@ -1665,6 +1665,7 @@ const PAPERCLIP_EXTERNAL_CHAT_PROVIDERS =
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
function normalizePaperclipExternalChatProvider(

View File

@ -227,7 +227,7 @@ async function prepareClaudeRemoteManagedHome(
typeof envConfig.CLAUDE_CONFIG_DIR === "string" && envConfig.CLAUDE_CONFIG_DIR.trim().length > 0
? envConfig.CLAUDE_CONFIG_DIR.trim()
: "";
if (explicitClaudeConfigDir) {
if (explicitClaudeConfigDir && !input.config.managedAiConnection) {
// User-managed escape hatch. Unlike the Claude CLI lane
// (`claude-local/execute.ts`), which runs the process on the same host and can
// forward the operator's path verbatim, the remote ACP lane spawns Claude
@ -267,7 +267,9 @@ async function prepareClaudeRemoteManagedHome(
// Content-addressed sanitized seed (managed cache under the instance root, not
// a temp dir — reused across runs, so no teardown cleanup).
const claudeConfigSeedDir = await prepareClaudeConfigSeed(process.env, onLog, input.companyId);
const claudeConfigSeedDir = input.config.managedAiConnection
? explicitClaudeConfigDir
: await prepareClaudeConfigSeed(process.env, onLog, input.companyId);
// Ship the per-run skill bundle, staged only when the run selected at
// least one skill. The bundle directory holds a plain copy of each
// selected skill's files (`materializePaperclipSkillCopy` never copies a
@ -625,6 +627,7 @@ export async function probeClaudeAcpSandboxLogin(input: {
}
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (config.managedAiConnection) args.push("--setting-sources", "user");
args.push(
...buildClaudeProbePermissionArgs({
dangerouslySkipPermissions: asBoolean(config.dangerouslySkipPermissions, true),
@ -760,7 +763,7 @@ export async function testClaudeAcpEnvironment(
});
const envConfig = parseObject(config.env);
const considerHostEnv = !targetIsRemote;
const considerHostEnv = !targetIsRemote && !config.managedAiConnection;
const hasBedrock =
envConfig.CLAUDE_CODE_USE_BEDROCK === "1" ||
envConfig.CLAUDE_CODE_USE_BEDROCK === "true" ||
@ -784,10 +787,10 @@ export async function testClaudeAcpEnvironment(
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "claude_acp_anthropic_api_key_detected",
level: "warn",
message: "ANTHROPIC_API_KEY is set. Claude ACP will use API-key auth instead of subscription credentials.",
level: config.managedAiConnection ? "info" : "warn",
message: config.managedAiConnection ? "Using the selected Claude API connection." : "ANTHROPIC_API_KEY is set. Claude ACP will use API-key auth instead of subscription credentials.",
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
hint: config.managedAiConnection ? undefined : "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else if (
isNonEmpty(envConfig.CLAUDE_CODE_OAUTH_TOKEN) ||
@ -860,6 +863,7 @@ export async function testClaudeAcpEnvironment(
const runId = `claude-acp-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
checks.push(
...(await prepareSandboxClaudeProbeRuntime({
managedAiConnection: Boolean(config.managedAiConnection),
runId,
target,
cwd,

View File

@ -269,6 +269,7 @@ function isNonEmptyString(value: unknown): value is string {
* the function keeps it and skips the managed materialization.
*/
export async function prepareSandboxClaudeProbeRuntime(input: {
managedAiConnection?: boolean;
runId: string;
target: AdapterExecutionTarget | null;
cwd: string;
@ -295,12 +296,12 @@ export async function prepareSandboxClaudeProbeRuntime(input: {
if (
input.targetIsRemote &&
adapterExecutionTargetUsesManagedHome(input.target) &&
!hasExplicitClaudeConfigDir
(!hasExplicitClaudeConfigDir || input.managedAiConnection)
) {
let tempWorkspaceDir: string | null = null;
let preparedRuntime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | null = null;
try {
const seedDir = await prepareClaudeConfigSeed(process.env, async () => {}, input.companyId);
const seedDir = input.managedAiConnection ? input.env.CLAUDE_CONFIG_DIR : await prepareClaudeConfigSeed(process.env, async () => {}, input.companyId);
const managedRemoteCwd =
input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd;
tempWorkspaceDir = await fs.mkdtemp(

View File

@ -567,7 +567,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
servers: runtimeMcpServers,
});
const localMcpConfigDir = path.dirname(localMcpConfigPath);
const sharedClaudeConfigDir = resolveSharedClaudeConfigDir(process.env);
const sharedClaudeConfigDir = config.managedAiConnection ? asString(configEnv.CLAUDE_CONFIG_DIR, "") : resolveSharedClaudeConfigDir(process.env);
const networkScope = parseLocalProcessNetworkScope(config.networkScope);
const filesystemScope = parseLocalProcessFilesystemScope(config.filesystemScope);
const localProcessSandbox: LocalProcessSandboxOptions | null =
@ -605,9 +605,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useManagedRemoteClaudeConfig =
executionTargetIsRemote &&
adapterExecutionTargetUsesManagedHome(executionTarget) &&
!hasExplicitClaudeConfigDir;
(!hasExplicitClaudeConfigDir || Boolean(config.managedAiConnection));
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
? config.managedAiConnection ? sharedClaudeConfigDir : await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
@ -884,6 +884,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
attemptInstructionsFilePath: string | undefined,
) => {
const args = ["--print", "--output-format", "stream-json", "--verbose"];
if (config.managedAiConnection) args.push("--setting-sources", "user");
if (resumeSessionId) args.push("--resume", resumeSessionId);
args.push(...buildClaudeExecutionPermissionArgs({
dangerouslySkipPermissions,

View File

@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readClaudeToken } from "./quota.js";
const mocks = vi.hoisted(() => ({ read: vi.fn(), exec: vi.fn() }));
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.read } }));
vi.mock("node:child_process", () => ({ execFile: Object.assign(vi.fn(), { [Symbol.for("nodejs.util.promisify.custom")]: mocks.exec }) }));
afterEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
describe("explicit Claude Keychain import", () => {
it("does not consult Keychain during passive reads", async () => {
mocks.read.mockRejectedValue(new Error("missing"));
await expect(readClaudeToken()).resolves.toBeNull();
expect(mocks.exec).not.toHaveBeenCalled();
});
it("reads the macOS login only after explicit opt-in", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.stubEnv("CLAUDE_CONFIG_DIR", "");
mocks.read.mockRejectedValue(new Error("missing"));
mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "fixture" } }) });
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("fixture");
expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], expect.any(Object));
});
it("never substitutes Keychain credentials for a custom auth home", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.stubEnv("CLAUDE_CONFIG_DIR", "/isolated/auth");
mocks.read.mockRejectedValue(new Error("missing"));
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBeNull();
expect(mocks.exec).not.toHaveBeenCalled();
});
it("does not surface a credential-bearing subprocess error", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.stubEnv("CLAUDE_CONFIG_DIR", "");
mocks.read.mockRejectedValue(new Error("missing"));
mocks.exec.mockRejectedValue(new Error("fixture-secret"));
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBeNull();
});
});

View File

@ -92,6 +92,10 @@ async function readClaudeTokenFromFile(credPath: string): Promise<string | null>
} catch {
return null;
}
return parseClaudeCredentialToken(raw);
}
function parseClaudeCredentialToken(raw: string): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
@ -137,12 +141,20 @@ function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string
: "Claude is logged in via claude.ai";
}
export async function readClaudeToken(): Promise<string | null> {
export async function readClaudeToken(options: { allowKeychain?: boolean } = {}): Promise<string | null> {
const configDir = claudeConfigDir();
for (const filename of [".credentials.json", "credentials.json"]) {
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
if (token) return token;
}
// Only an explicit local-account import may consult the user's Keychain.
// A custom auth home must never fall through to a different account.
if (options.allowKeychain && process.platform === "darwin" && !process.env.CLAUDE_CONFIG_DIR?.trim()) {
try {
const { stdout } = await execFileAsync("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { timeout: 10000, maxBuffer: 1024 * 1024 });
return parseClaudeCredentialToken(stdout);
} catch { return null; }
}
return null;
}

View File

@ -512,6 +512,16 @@ describe("claude auth mode hints", () => {
).toBe(false);
});
it("reports an intentionally selected managed API account without a subscription warning", async () => {
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };
const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local",
config: { engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } },
executionTarget: sandboxTarget, environmentName: "Daytona",
});
expect(result.checks.find(check => check.code === "claude_anthropic_api_key_overrides_subscription")).toMatchObject({ level: "info", message: "Using the selected Claude API connection." });
expect(JSON.stringify(result.checks)).not.toContain("Unset ANTHROPIC_API_KEY");
});
it("keeps the API-key warning authoritative when both ANTHROPIC_API_KEY and the token are set", async () => {
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };

View File

@ -135,6 +135,7 @@ export async function testEnvironment(
: await buildLocalAdapterTestProbeEnv({ callerEnv: env, trustedEnv: process.env });
checks.push(
...(await prepareSandboxClaudeProbeRuntime({
managedAiConnection: Boolean(config.managedAiConnection),
runId,
target,
cwd,
@ -177,7 +178,7 @@ export async function testEnvironment(
// reflect what the agent will actually see at runtime. Only consider env
// vars from the adapter config in that case; the probe itself will surface
// any auth issues on the remote box.
const considerHostEnv = !targetIsRemote;
const considerHostEnv = !targetIsRemote && !config.managedAiConnection;
const hasBedrock =
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
env.CLAUDE_CODE_USE_BEDROCK === "true" ||
@ -206,11 +207,11 @@ export async function testEnvironment(
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "claude_anthropic_api_key_overrides_subscription",
level: "warn",
level: config.managedAiConnection ? "info" : "warn",
message:
"ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
config.managedAiConnection ? "Using the selected Claude API connection." : "ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
hint: config.managedAiConnection ? undefined : "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else if (
isNonEmpty(env.CLAUDE_CODE_OAUTH_TOKEN) ||
@ -350,6 +351,7 @@ export async function testEnvironment(
}
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (config.managedAiConnection) args.push("--setting-sources", "user");
args.push(...buildClaudeProbePermissionArgs({
dangerouslySkipPermissions,
targetIsRemote,

View File

@ -206,7 +206,7 @@ async function prepareCodexRemoteManagedHome(
restore: async ({ assetDir, readFile }) =>
void (await copyBackCodexAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
hostAuthPath: path.join(input.config.managedAiConnection ? effectiveCodexHome : resolveSharedCodexHomeDir(process.env), "auth.json"),
log: (line) => onLog("stdout", `${line}\n`),
})),
},

View File

@ -675,7 +675,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// holds no credential it does nothing (no random pick). This keeps the change
// additive: the managed home still symlinks the shared `auth.json`, now at its
// freshest same-identity copy. The off-switch (default on) skips the vend.
if (isCodexAuthCacheEnabled(process.env)) {
if (!config.managedAiConnection && isCodexAuthCacheEnabled(process.env)) {
const sharedHomeAuthPath = path.join(resolveSharedCodexHomeDir(process.env), "auth.json");
// This caller reads `process.env` directly and holds no separate `env`
// object, so `selectVendCredential` falls back to its own `process.env`
@ -848,14 +848,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
restore: async ({ assetDir, readFile }) =>
void (await copyBackCodexAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
hostAuthPath: path.join(config.managedAiConnection ? effectiveCodexHome : resolveSharedCodexHomeDir(process.env), "auth.json"),
log: (line) => onLog("stdout", `${line}\n`),
// Additive cache write (sandbox to host): also cache the
// sandbox subscription credential in its per-identity slot,
// keyed by the real `account_id`. Company-scoped root; the
// helper ensures the slot directory private and containment-
// guarded. The off-switch (default on) is read inside.
resolveCacheEntryPath: (accountId) =>
resolveCacheEntryPath: config.managedAiConnection ? undefined : (accountId) =>
ensureCodexAuthCacheEntryDir(process.env, accountId, agent.companyId),
env: process.env,
})),

View File

@ -120,3 +120,7 @@ export const sessionCodec: AdapterSessionCodec = {
);
},
};
export { decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
export { copyBackCodexAuth } from "./codex-auth-copyback.js";

View File

@ -83,6 +83,7 @@ async function prepareCodexHelloProbe(input: {
args: string[];
env: Record<string, string>;
probeApiKey: string | null;
managedAiConnection?: boolean;
}): Promise<{
command: string;
args: string[];
@ -118,7 +119,7 @@ async function prepareCodexHelloProbe(input: {
const configuredHomeIsManaged =
configuredCodexHome != null &&
isManagedCodexHomePath(process.env, input.companyId, configuredCodexHome);
if (isCodexAuthCacheEnabled(process.env)) {
if (!input.managedAiConnection && isCodexAuthCacheEnabled(process.env)) {
// Identity-anchored cache vend, exactly as execute runs it before the
// seeding below. Best-effort: a vend failure never blocks the probe, and
// the probe then stages the shared credential as-is.
@ -253,6 +254,9 @@ export async function testEnvironment(
code: "adapter_engine_unavailable",
level: "error",
message: engineSelection.unavailableReason,
hint: ctx.executionTarget?.kind === "remote"
? "In the agents runtime settings, select the CLI engine, or use a sandbox image with the Codex ACP server installed."
: undefined,
}],
testedAt: new Date().toISOString(),
};
@ -409,6 +413,7 @@ export async function testEnvironment(
? hostOpenAiKey
: null;
const preparedProbe = await prepareCodexHelloProbe({
managedAiConnection: Boolean(config.managedAiConnection),
runId,
companyId: ctx.companyId,
target,

View File

@ -313,12 +313,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
// Held before the remote block below, so the remote lane can stage this
// same host home into the sandbox without re-resolving it.
const hostGrokHome = resolveManagedGrokHomeDir(process.env, agent.companyId);
const hostGrokHome = config.managedAiConnection ? asString(env.GROK_HOME, "") : resolveManagedGrokHomeDir(process.env, agent.companyId);
// Subscription mode (no XAI_API_KEY): point the run at the company-scoped
// Grok home a completed device login wrote. Leaves the API-key path below
// (`resolveBillingType`) unchanged when the key exists.
const isGrokSubscriptionMode =
!hasNonEmptyEnvValue(env, "XAI_API_KEY") && !hasNonEmptyEnvValue(process.env as Record<string, string>, "XAI_API_KEY");
!hasNonEmptyEnvValue(env, "XAI_API_KEY") && (Boolean(config.managedAiConnection) || !hasNonEmptyEnvValue(process.env as Record<string, string>, "XAI_API_KEY"));
if (isGrokSubscriptionMode) {
env.GROK_HOME = hostGrokHome;
}

View File

@ -80,3 +80,7 @@ export {
type PromoteGrokDeviceLoginCredentialInput,
type PromoteGrokDeviceLoginCredentialOutcome,
} from "./adapter-auth-promotion.js";
export { decideGrokAuthMerge } from "./grok-auth-merge-decision.js";
export { parseGrokAuthPayload, hasUsableGrokAuthValue } from "./grok-home.js";

View File

@ -12,11 +12,16 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
describeAdapterExecutionTarget,
prepareAdapterExecutionTargetRuntime,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
import { rm } from "node:fs/promises";
import path from "node:path";
import { stageGrokHomeForSync } from "./grok-home.js";
import { copyBackGrokAuth } from "./grok-auth-copyback.js";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { parseGrokJsonl } from "./parse.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
@ -145,6 +150,24 @@ export async function testEnvironment(
}
const env = normalizeEnv(config.env);
let stagedHome: string | undefined;
let restore: (() => Promise<void>) | undefined;
try {
if (config.managedAiConnection && targetIsRemote) {
const hostHome = env.GROK_HOME;
stagedHome = await stageGrokHomeForSync(hostHome, { runId });
const prepared = await prepareAdapterExecutionTargetRuntime({
runId, target, adapterKey: "grok", workspaceLocalDir: cwd,
assets: [{ key: "home", localDir: stagedHome, followSymlinks: true,
restore: async ({ assetDir, readFile }) => { await copyBackGrokAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostHomeDir: hostHome, log: () => {},
}); },
}],
});
env.GROK_HOME = prepared.assetDirs.home;
restore = () => prepared.restoreWorkspace(() => {});
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
@ -343,4 +366,5 @@ export async function testEnvironment(
checks,
testedAt: new Date().toISOString(),
};
} finally { try { await restore?.(); } finally { if (stagedHome) await rm(stagedHome, { recursive: true, force: true }); } }
}

View File

@ -126,7 +126,7 @@ describe("opencode remote execution", () => {
}
});
it("prepares the workspace, syncs OpenCode skills, and restores workspace changes for remote SSH execution", async () => {
it.each([false, true])("prepares the workspace, syncs OpenCode skills, and restores workspace changes for remote SSH execution (managed=%s)", async (managed) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
@ -153,6 +153,13 @@ describe("opencode remote execution", () => {
config: {
command: "opencode",
model: "opencode/gpt-5-nano",
...(managed ? {
managedAiConnection: { provider: "openrouter", method: "api_key" },
} : {}),
env: {
XDG_CONFIG_HOME: path.join(rootDir, "config"),
...(managed ? { HOME: "/var/folders/qa-managed", XDG_DATA_HOME: "/var/folders/qa-managed/data" } : {}),
},
},
context: {
paperclipWorkspace: {
@ -233,6 +240,17 @@ describe("opencode remote execution", () => {
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
if (managed) {
const home = `${managedRemoteWorkspace}/.paperclip-runtime/opencode/managed-auth/run-1`;
expect(call?.[3].env.HOME).toBe(home);
expect(call?.[3].env.XDG_DATA_HOME).toBe(`${home}/data`);
expect(modelProbeCall?.[3].env.XDG_DATA_HOME).toBe(`${home}/data`);
expect(runSshCommand).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(`${home}/.claude/skills`),
expect.anything(),
);
}
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",

View File

@ -59,7 +59,7 @@ import {
requireOpenCodeModelId,
} from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveOpenCodeSkillsHome } from "./skills.js";
@ -438,9 +438,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
}
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
prepareManagedOpenCodeRemoteHomes({
env: preparedRuntimeConfig.env,
config,
runtimeRootDir: preparedExecutionTargetRuntime.runtimeRootDir,
runId,
configDir: preparedExecutionTargetRuntime.assetDirs.xdgConfig,
});
const remoteHomeDir = config.managedAiConnection
? preparedRuntimeConfig.env.HOME
: managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,

View File

@ -240,3 +240,23 @@ export async function prepareOpenCodeRuntimeConfig(input: {
},
};
}
/** Managed credentials must never leave host-only homes in a remote process. */
export function prepareManagedOpenCodeRemoteHomes(input: {
env: Record<string, string>;
config: Record<string, unknown>;
runtimeRootDir: string | null | undefined;
runId: string;
configDir?: string;
}): void {
if (!input.config.managedAiConnection) return;
if (!input.runtimeRootDir) throw new Error("Managed OpenCode authentication requires an isolated remote runtime directory.");
const home = path.posix.join(input.runtimeRootDir, "managed-auth", input.runId);
Object.assign(input.env, {
HOME: home,
XDG_CONFIG_HOME: input.configDir ?? path.posix.join(home, "config"),
XDG_DATA_HOME: path.posix.join(home, "data"),
XDG_CACHE_HOME: path.posix.join(home, "cache"),
XDG_STATE_HOME: path.posix.join(home, "state"),
});
}

View File

@ -74,6 +74,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
import { testEnvironment } from "./test.js";
describe("opencode remote environment diagnostics", () => {
const configHomes: string[] = [];
let configHome: string;
beforeEach(async () => {
@ -85,9 +86,12 @@ describe("opencode remote environment diagnostics", () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
await rm(configHome, { recursive: true, force: true });
await Promise.all(configHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
});
it("stages remote runtime config assets for sandbox hello probes", async () => {
it.each([false, true])("stages remote runtime config assets for sandbox hello probes (managed=%s)", async (managed) => {
const configHome = await mkdtemp(path.join(os.tmpdir(), "opencode-remote-test-config-"));
configHomes.push(configHome);
const remoteTarget: AdapterExecutionTarget = {
kind: "remote",
transport: "sandbox",
@ -112,6 +116,13 @@ describe("opencode remote environment diagnostics", () => {
config: {
command: "opencode",
model: "anthropic/claude-sonnet-4-5",
...(managed ? {
managedAiConnection: { provider: "openrouter", method: "api_key" },
} : {}),
env: {
XDG_CONFIG_HOME: configHome,
...(managed ? { OPENAI_API_KEY: "", OPENROUTER_API_KEY: "fixture", HOME: "/var/folders/qa-managed", XDG_DATA_HOME: "/var/folders/qa-managed/data" } : {}),
},
},
executionTarget: remoteTarget,
environmentName: "QA Cloudflare",
@ -134,6 +145,11 @@ describe("opencode remote environment diagnostics", () => {
| [string, AdapterExecutionTarget, string, string[], { cwd: string; env: Record<string, string> }]
| undefined;
expect(probeCall?.[4].cwd).toBe("/remote/workspace/.paperclip-runtime/runs/test/workspace");
if (managed) {
expect(probeCall?.[4].env.HOME).toContain("/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/managed-auth/");
expect(probeCall?.[4].env.XDG_DATA_HOME).toBe(`${probeCall?.[4].env.HOME}/data`);
expect(probeCall?.[4].env.XDG_CACHE_HOME).toBe(`${probeCall?.[4].env.HOME}/cache`);
}
expect(probeCall?.[4].env.XDG_CONFIG_HOME).toBe(
"/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/xdgConfig",
);

View File

@ -28,7 +28,7 @@ import {
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -115,7 +115,7 @@ export async function testEnvironment(
}
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
if (!config.managedAiConnection && openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
checks.push({
code: "opencode_openai_api_key_missing",
level: "warn",
@ -172,6 +172,13 @@ export async function testEnvironment(
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
}
prepareManagedOpenCodeRemoteHomes({
env: preparedRuntimeConfig.env,
config,
runtimeRootDir: preparedExecutionTargetRuntime.runtimeRootDir,
runId,
configDir: preparedExecutionTargetRuntime.assetDirs.xdgConfig,
});
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));

View File

@ -36,6 +36,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
// AI defaults arrive in 0273 and depend on the composite grant key from
// 0232. Rewind that later table before recreating the 0182 grant schema.
await sql`DROP TABLE IF EXISTS "ai_connection_defaults"`;
await sql`DROP TABLE IF EXISTS "connection_grant_delegations"`;
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
await sql`DROP TABLE IF EXISTS "connection_grants"`;

View File

@ -0,0 +1,5 @@
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
CREATE UNIQUE INDEX IF NOT EXISTS "chat_endpoints_photon_number_uq" ON "chat_endpoints" USING btree ("bot_external_id") WHERE "chat_endpoints"."provider" = 'imessage-photon' and "chat_endpoints"."status" <> 'archived' and "chat_endpoints"."bot_external_id" is not null;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_provider_check" CHECK ("chat_endpoints"."provider" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon'));--> statement-breakpoint
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', 'imessage-photon'));

View File

@ -0,0 +1,77 @@
CREATE TABLE IF NOT EXISTS "ai_connection_defaults" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"method" text NOT NULL,
"grant_id" uuid,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ai_connection_defaults_provider_check" CHECK ("ai_connection_defaults"."provider" in ('anthropic','openai','openrouter','xai')),
CONSTRAINT "ai_connection_defaults_method_check" CHECK ("ai_connection_defaults"."method" in ('subscription','api_key'))
);
--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_transport_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_purpose_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_channel_transport_check";--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "ai_connection" jsonb;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_id" uuid;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_grant_id" uuid;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_method" text;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_connection_defaults_owner_method_uq" ON "ai_connection_defaults" USING btree ("company_id","user_id","provider","method");--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_transport_check" CHECK ("tool_connections"."transport" in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk', 'runtime_auth'));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_purpose_check" CHECK ("tool_connections"."connection_purpose" in ('tool', 'channel', 'ai'));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_channel_transport_check" CHECK ((
("tool_connections"."connection_purpose" = 'tool' and "tool_connections"."transport" not in ('chat_sdk', 'runtime_auth'))
or
("tool_connections"."connection_purpose" = 'channel' and ("tool_connections"."transport" = 'chat_sdk' or ("tool_connections"."transport" = 'rest_api' and "tool_connections"."config"->>'provider' = 'agentmail')))
or
("tool_connections"."connection_purpose" = 'ai' and "tool_connections"."transport" = 'runtime_auth')
));--> statement-breakpoint
-- Only declared per-user credentials have reliable ownership. Host auth homes
-- and company secrets are deliberately left untouched. No agent binding changes.
DO $$
DECLARE candidate record; application_id uuid; connection_id uuid; grant_id uuid;
BEGIN
FOR candidate IN
SELECT DISTINCT s.id AS secret_id, s.company_id, s.owner_user_id, s.user_secret_definition_id,
s.name, s.created_at, a.id AS agent_id,
CASE d.env_key WHEN 'ANTHROPIC_API_KEY' THEN 'anthropic' WHEN 'CLAUDE_CODE_OAUTH_TOKEN' THEN 'anthropic'
WHEN 'OPENAI_API_KEY' THEN 'openai' WHEN 'OPENROUTER_API_KEY' THEN 'openrouter' WHEN 'XAI_API_KEY' THEN 'xai' END AS provider,
CASE WHEN d.env_key = 'CLAUDE_CODE_OAUTH_TOKEN' THEN 'subscription' ELSE 'api_key' END AS method
FROM company_secrets s
JOIN user_secret_declarations d ON d.company_id = s.company_id AND d.user_secret_definition_id = s.user_secret_definition_id
JOIN agents a ON a.company_id = s.company_id AND a.id::text = d.target_id AND d.target_type = 'agent'
WHERE s.scope = 'user' AND s.owner_user_id IS NOT NULL AND s.status = 'active' AND s.deleted_at IS NULL
AND ((a.adapter_type = 'claude_local' AND d.env_key IN ('ANTHROPIC_API_KEY','CLAUDE_CODE_OAUTH_TOKEN'))
OR (a.adapter_type = 'codex_local' AND d.env_key = 'OPENAI_API_KEY')
OR (a.adapter_type = 'opencode_local' AND d.env_key = 'OPENROUTER_API_KEY')
OR (a.adapter_type = 'grok_local' AND d.env_key = 'XAI_API_KEY'))
ORDER BY s.created_at
LOOP
connection_id := overlay(overlay(md5('ai-connection:' || candidate.secret_id::text || ':' || candidate.provider || ':' || candidate.method) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid;
grant_id := overlay(overlay(md5('ai-grant:' || connection_id::text) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid;
INSERT INTO tool_applications(company_id,application_key,name,type,owner_user_id,metadata)
VALUES(candidate.company_id,'app-gallery:' || candidate.provider,
CASE candidate.provider WHEN 'anthropic' THEN 'Claude' WHEN 'openai' THEN 'OpenAI' WHEN 'openrouter' THEN 'OpenRouter' ELSE 'Grok' END,
'mcp_http',candidate.owner_user_id,jsonb_build_object('sourceTemplateKey',candidate.provider)) ON CONFLICT DO NOTHING;
SELECT id INTO application_id FROM tool_applications WHERE company_id = candidate.company_id
AND (application_key = 'app-gallery:' || candidate.provider OR name = CASE candidate.provider WHEN 'anthropic' THEN 'Claude' WHEN 'openai' THEN 'OpenAI' WHEN 'openrouter' THEN 'OpenRouter' ELSE 'Grok' END) LIMIT 1;
INSERT INTO tool_connections(id,company_id,application_id,name,uid,connection_purpose,transport,auth_kind,credential_policy,status,enabled,health_status,config,created_by_user_id)
VALUES(connection_id,candidate.company_id,application_id,candidate.name,'ai-' || connection_id::text,'ai','runtime_auth',
CASE WHEN candidate.method = 'subscription' THEN 'oauth' ELSE 'api_key' END,'per_user','active',true,'unknown',
jsonb_build_object('sourceTemplateKey',candidate.provider,'ai',jsonb_build_object('provider',candidate.provider,'method',candidate.method),'aiLegacyAdoption',true),candidate.owner_user_id)
ON CONFLICT DO NOTHING;
INSERT INTO connection_grants(id,company_id,connection_id,kind,subject_user_id,credential_secret_refs,created_by_user_id)
VALUES(grant_id,candidate.company_id,connection_id,'user',candidate.owner_user_id,
jsonb_build_array(jsonb_build_object('secretId',candidate.secret_id,'configPath','ai.credential','required',true,'versionSelector','latest')),candidate.owner_user_id)
ON CONFLICT DO NOTHING;
INSERT INTO user_secret_declarations(company_id,user_secret_definition_id,target_type,target_id,config_path,env_key)
VALUES(candidate.company_id,candidate.user_secret_definition_id,'tool_connection',connection_id::text,'ai.credential','ai.credential') ON CONFLICT DO NOTHING;
INSERT INTO ai_connection_defaults(company_id,user_id,provider,method,grant_id)
VALUES(candidate.company_id,candidate.owner_user_id,candidate.provider,candidate.method,grant_id) ON CONFLICT DO NOTHING;
INSERT INTO tool_connection_installs(company_id,connection_id,target_type,target_id,created_by_user_id)
VALUES(candidate.company_id,connection_id,'agent',candidate.agent_id::text,candidate.owner_user_id) ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

@ -1,5 +1,5 @@
{
"id": "91aa55be-5067-4db5-b8e5-1eb670910029",
"id": "d1a3e2cb-5f5f-4bb2-98ec-8475b445d6e6",
"prevId": "02bc1b0f-2d2b-4c3e-9320-b085bade9f09",
"version": "7",
"dialect": "postgresql",
@ -6505,6 +6505,22 @@
"method": "btree",
"with": {}
},
"chat_endpoints_photon_number_uq": {
"name": "chat_endpoints_photon_number_uq",
"columns": [
{
"expression": "bot_external_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"chat_endpoints\".\"provider\" = 'imessage-photon' and \"chat_endpoints\".\"status\" <> 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_endpoints_live_discord_bot_external_uq": {
"name": "chat_endpoints_live_discord_bot_external_uq",
"columns": [
@ -6663,7 +6679,7 @@
},
"chat_endpoints_provider_check": {
"name": "chat_endpoints_provider_check",
"value": "\"chat_endpoints\".\"provider\" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail')"
"value": "\"chat_endpoints\".\"provider\" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')"
},
"chat_endpoints_status_check": {
"name": "chat_endpoints_status_check",
@ -6848,7 +6864,7 @@
"checkConstraints": {
"chat_external_principals_provider_check": {
"name": "chat_external_principals_provider_check",
"value": "\"chat_external_principals\".\"provider\" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail')"
"value": "\"chat_external_principals\".\"provider\" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')"
},
"chat_external_principals_kind_check": {
"name": "chat_external_principals_kind_check",
@ -47434,655 +47450,6 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.task_repository_bindings": {
"name": "task_repository_bindings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"workspace_id": {
"name": "workspace_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo_url": {
"name": "repo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"repo_ref": {
"name": "repo_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"setup_complete": {
"name": "setup_complete",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"retired_at": {
"name": "retired_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"checkpoint_key": {
"name": "checkpoint_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"checkpoint_sha256": {
"name": "checkpoint_sha256",
"type": "text",
"primaryKey": false,
"notNull": false
},
"checkpoint_at": {
"name": "checkpoint_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"task_repository_bindings_company_id_companies_id_fk": {
"name": "task_repository_bindings_company_id_companies_id_fk",
"tableFrom": "task_repository_bindings",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_repository_bindings_task_id_issues_id_fk": {
"name": "task_repository_bindings_task_id_issues_id_fk",
"tableFrom": "task_repository_bindings",
"tableTo": "issues",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"task_repository_bindings_workspace_uq": {
"name": "task_repository_bindings_workspace_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"task_id",
"workspace_id"
]
},
"task_repository_bindings_name_uq": {
"name": "task_repository_bindings_name_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"task_id",
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.work_file_operations": {
"name": "work_file_operations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"folder_id": {
"name": "folder_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"operation_id": {
"name": "operation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
"tableFrom": "work_file_operations",
"tableTo": "work_folders",
"columnsFrom": [
"company_id",
"folder_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"work_file_operations_receipt_uq": {
"name": "work_file_operations_receipt_uq",
"nullsNotDistinct": false,
"columns": [
"folder_id",
"operation_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.work_files": {
"name": "work_files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"folder_id": {
"name": "folder_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'file'"
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"byte_size": {
"name": "byte_size",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"default": 0
},
"sha256": {
"name": "sha256",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'application/octet-stream'"
},
"executable": {
"name": "executable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"work_files_folder_path_uq": {
"name": "work_files_folder_path_uq",
"columns": [
{
"expression": "folder_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "path",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"work_files\".\"deleted_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
},
"work_files_company_folder_idx": {
"name": "work_files_company_folder_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "folder_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
"tableFrom": "work_files",
"tableTo": "work_folders",
"columnsFrom": [
"company_id",
"folder_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.work_folder_objects": {
"name": "work_folder_objects",
"schema": "",
"columns": {
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"folder_id": {
"name": "folder_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"repository_binding_id": {
"name": "repository_binding_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"work_folder_objects_cleanup_idx": {
"name": "work_folder_objects_cleanup_idx",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.work_folder_runs": {
"name": "work_folder_runs",
"schema": "",
"columns": {
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"manifest": {
"name": "manifest",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"baselines": {
"name": "baselines",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pending_operations": {
"name": "pending_operations",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'starting'"
},
"last_saved_at": {
"name": "last_saved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_requested": {
"name": "refresh_requested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"work_folder_runs_company_idx": {
"name": "work_folder_runs_company_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
"tableFrom": "work_folder_runs",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"work_folder_runs_company_id_companies_id_fk": {
"name": "work_folder_runs_company_id_companies_id_fk",
"tableFrom": "work_folder_runs",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.work_folders": {
"name": "work_folders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imported_at": {
"name": "imported_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"work_folders_company_id_companies_id_fk": {
"name": "work_folders_company_id_companies_id_fk",
"tableFrom": "work_folders",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"work_folders_owner_uq": {
"name": "work_folders_owner_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"scope",
"owner_id"
]
},
"work_folders_company_id_uq": {
"name": "work_folders_company_id_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1916,8 +1916,22 @@
{
"idx": 275,
"version": "7",
"when": 1789237657148,
"tag": "0275_sandbox_work_folders",
"when": 1789221632037,
"tag": "0275_easy_dragon_man",
"breakpoints": true
},
{
"idx": 276,
"version": "7",
"when": 1789245055965,
"tag": "0276_hard_mandroid",
"breakpoints": true
},
{
"idx": 277,
"version": "7",
"when": 1789254591985,
"tag": "0277_sandbox_work_folders",
"breakpoints": true
}
]

View File

@ -48,6 +48,10 @@ export const adapterAuthSessions = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
aiConnection: jsonb("ai_connection").$type<import("@paperclipai/shared").AiConnectionLoginIntent>(),
connectionId: uuid("connection_id"),
connectionGrantId: uuid("connection_grant_id"),
connectionMethod: text("connection_method"),
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
// The immutable owner principal. The service sets this column one time at
// create and never updates it. The service returns the prompt only to this

View File

@ -0,0 +1,52 @@
import {
foreignKey,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
check,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import type { AiProvider, AiAuthMethod } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { connectionGrants } from "./tool_access.js";
/** A retained row with a null/revoked grant is an unavailable default, never permission to auto-select. */
export const aiConnectionDefaults = pgTable(
"ai_connection_defaults",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
provider: text("provider").$type<AiProvider>().notNull(),
method: text("method").$type<AiAuthMethod>().notNull(),
grantId: uuid("grant_id"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
uniqueIndex("ai_connection_defaults_owner_method_uq").on(
t.companyId,
t.userId,
t.provider,
t.method,
),
foreignKey({
columns: [t.companyId, t.grantId],
foreignColumns: [connectionGrants.companyId, connectionGrants.id],
name: "ai_connection_defaults_company_grant_fk",
}),
check(
"ai_connection_defaults_provider_check",
sql`${t.provider} in ('anthropic','openai','openrouter','xai')`,
),
check(
"ai_connection_defaults_method_check",
sql`${t.method} in ('subscription','api_key')`,
),
],
);

View File

@ -116,7 +116,7 @@ export const chatEndpoints = pgTable(
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', 'agentmail')`,
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')`,
),
check(
"chat_endpoints_status_check",
@ -155,6 +155,9 @@ export const chatEndpoints = pgTable(
// one native bot identity. Excluding providerAccountId closes the race
// where concurrent setup in two guilds could otherwise claim that bot for
// two Paperclip agents after both application-level prechecks passed.
uniqueIndex("chat_endpoints_photon_number_uq")
.on(table.botExternalId)
.where(sql`${table.provider} = 'imessage-photon' and ${table.status} <> 'archived' and ${table.botExternalId} is not null`),
uniqueIndex("chat_endpoints_live_discord_bot_external_uq")
.on(table.provider, table.botExternalId)
.where(
@ -278,7 +281,7 @@ export const chatExternalPrincipals = pgTable(
(table) => [
check(
"chat_external_principals_provider_check",
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail')`,
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')`,
),
check(
"chat_external_principals_kind_check",

View File

@ -34,7 +34,7 @@ export { environments } from "./environments.js";
export { environmentLeases } from "./environment_leases.js";
export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js";
export { adapterAuthSessions } from "./adapter_auth_sessions.js";
export { adapterAuthSessions, ADAPTER_AUTH_SESSION_ACTIVE_STATES } from "./adapter_auth_sessions.js";
export { workspaceOperations } from "./workspace_operations.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
@ -206,4 +206,5 @@ 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 { aiConnectionDefaults } from "./ai_connection_defaults.js";
export * from "./email.js";

View File

@ -146,12 +146,14 @@ export const toolConnections = pgTable(
},
(table) => [
check("tool_connections_ownership_check", sql`${table.ownership} in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')`),
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk')`),
check("tool_connections_purpose_check", sql`${table.connectionPurpose} in ('tool', 'channel')`),
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk', 'runtime_auth')`),
check("tool_connections_purpose_check", sql`${table.connectionPurpose} in ('tool', 'channel', 'ai')`),
check("tool_connections_channel_transport_check", sql`(
(${table.connectionPurpose} = 'tool' and ${table.transport} <> 'chat_sdk')
(${table.connectionPurpose} = 'tool' and ${table.transport} not in ('chat_sdk', 'runtime_auth'))
or
(${table.connectionPurpose} = 'channel' and (${table.transport} = 'chat_sdk' or (${table.transport} = 'rest_api' and ${table.config}->>'provider' = 'agentmail')))
or
(${table.connectionPurpose} = 'ai' and ${table.transport} = 'runtime_auth')
)`),
check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`),
check("tool_connections_credential_source_check", sql`${table.credentialSource} in ('paperclip_vault', 'vercel_connect')`),

View File

@ -168,12 +168,10 @@ describe("managed Codex credentials", () => {
] as const)(
"tolerates one unrelated silent %s quorum listener",
async (_label, occupiedIndex) => {
const prepared =
occupiedIndex === 0 ? await silentPrimaryQuorumFixture() : null;
const fixture = prepared?.fixture ?? (await credentialFixture());
const ports = credentialLeasePorts(await realpath(fixture.home));
const occupied =
prepared?.occupied ?? (await listenSilently(ports[occupiedIndex]));
const { fixture, occupied } = await silentPrimaryQuorumFixture(
credentialFixture,
occupiedIndex,
);
try {
const lease = await stageManagedCodexCredential({
agentHomeDirectory: fixture.home,

View File

@ -87,15 +87,27 @@ createInterface({ input: process.stdin }).on("line", (line) => {
modelProvider: "fixture-no-provider",
thread: {
id: state.threadId,
status: { type: Object.values(state.turns).some(turn => turn.status === "inProgress") ? "active" : "idle" },
sessionId: "final-burst-fixture",
turns: Object.entries(state.turns).map(([turnId, turn]) => ({
id: turnId,
status: turn.status,
})),
status: {
type: Object.values(state.turns).some((turn) => turn.status === "inProgress") ? "active" : "idle",
},
...(params.includeTurns ? {
turns: Object.entries(state.turns).map(([turnId, turn]) => ({ id: turnId, status: turn.status })),
} : {}),
},
},
});
} else if (method === "thread/turns/list") {
const turns = Object.entries(state.turns).map(([turnId, turn]) => ({
id: turnId, status: turn.status, items: [], itemsView: "notLoaded",
}));
if (params.sortDirection === "desc") turns.reverse();
const offset = Number(params.cursor ?? 0);
const limit = params.limit ?? 100;
send({ id, result: {
data: turns.slice(offset, offset + limit),
nextCursor: offset + limit < turns.length ? String(offset + limit) : null,
} });
} else if (method === "turn/start") {
const turnId = `final-burst-turn-${++state.nextTurn}`;
state.turns[turnId] = { status: "inProgress", startedAtMs: Date.now() };

View File

@ -0,0 +1,6 @@
packages:
- '.'
# The SDK ships generated protobuf code; its optional install script is not needed.
allowBuilds:
protobufjs: false

View File

@ -0,0 +1,231 @@
import { z } from "zod";
/** Runtime authentication is a separate transport, never a tool or channel. */
export const connectionPurposeTransportSchema = z.discriminatedUnion(
"connectionPurpose",
[
z.object({
connectionPurpose: z.literal("tool"),
transport: z.enum(["mcp_remote", "rest_api", "local_stdio"]),
}),
z.object({
connectionPurpose: z.literal("channel"),
transport: z.enum(["chat_sdk", "rest_api"]),
config: z.object({ provider: z.string().optional() }).passthrough().optional(),
}).refine(
(connection) => connection.transport === "chat_sdk" || connection.config?.provider === "agentmail",
{ message: "REST channel connections require the AgentMail provider", path: ["config", "provider"] },
),
z.object({
connectionPurpose: z.literal("ai"),
transport: z.literal("runtime_auth"),
}),
],
);
export type ConnectionPurposeTransport = z.infer<
typeof connectionPurposeTransportSchema
>;
export const AI_PROVIDERS = [
"anthropic",
"openai",
"openrouter",
"xai",
] as const;
export const aiProviderSchema = z.enum(AI_PROVIDERS);
export const aiAuthMethodSchema = z.enum(["subscription", "api_key"]);
export type AiProvider = z.infer<typeof aiProviderSchema>;
export type AiAuthMethod = z.infer<typeof aiAuthMethodSchema>;
const requirement = { provider: aiProviderSchema, method: aiAuthMethodSchema };
export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [
z.object({ ...requirement, mode: z.literal("responsible_user") }).strict(),
z
.object({
...requirement,
mode: z.literal("shared"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
z
.object({
...requirement,
// Legacy wire format only; human access still applies. New UI never creates it.
mode: z.literal("delegated"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
]);
export type AiConnectionBinding = z.infer<typeof aiConnectionBindingSchema>;
export const aiConnectionMetadataSchema = z.object(requirement).strict();
export type AiConnectionMetadata = z.infer<typeof aiConnectionMetadataSchema>;
/** Existing integrations only. This table describes compatibility, never routing. */
export const AI_CONNECTION_CAPABILITIES: Record<
AiProvider,
{
name: string;
methods: Partial<
Record<AiAuthMethod, { adapters: readonly string[]; envKey: string }>
>;
}
> = {
anthropic: {
name: "Claude",
methods: {
subscription: {
adapters: ["claude_local"],
envKey: "CLAUDE_CODE_OAUTH_TOKEN",
},
api_key: { adapters: ["claude_local"], envKey: "ANTHROPIC_API_KEY" },
},
},
openai: {
name: "OpenAI",
methods: {
subscription: { adapters: ["codex_local"], envKey: "CODEX_HOME" },
api_key: { adapters: ["codex_local"], envKey: "OPENAI_API_KEY" },
},
},
openrouter: {
name: "OpenRouter",
methods: {
api_key: { adapters: ["opencode_local"], envKey: "OPENROUTER_API_KEY" },
},
},
xai: {
name: "Grok",
methods: {
subscription: { adapters: ["grok_local"], envKey: "GROK_HOME" },
api_key: { adapters: ["grok_local"], envKey: "XAI_API_KEY" },
},
},
};
export function isAiConnectionCompatible(
requirement: AiConnectionMetadata,
adapterType: string,
model?: unknown,
runnerProvider?: unknown,
acpxAgent?: unknown,
): boolean {
if (adapterType === "paperclip_runner")
adapterType =
runnerProvider === "claude" ||
(runnerProvider === "acpx" && acpxAgent === "claude")
? "claude_local"
: runnerProvider === "codex"
? "codex_local"
: runnerProvider === "opencode"
? "opencode_local"
: "unsupported";
const method =
AI_CONNECTION_CAPABILITIES[requirement.provider].methods[
requirement.method
];
return (
Boolean(method?.adapters.includes(adapterType)) &&
(requirement.provider !== "openrouter" ||
(typeof model === "string" && model.startsWith("openrouter/")))
);
}
export type AiConnectionUnavailableReason =
| "responsible_user_missing"
| "membership_missing"
| "default_missing"
| "connection_missing"
| "connection_unavailable"
| "incompatible"
| "access_denied"
| "credential_missing";
export interface AiConnectionAttribution {
connectionId: string;
grantId: string;
provider: AiProvider;
method: AiAuthMethod;
mode: AiConnectionBinding["mode"];
responsibleUserId: string | null;
}
export type AiConnectionResolution =
| { ok: true; attribution: AiConnectionAttribution }
| { ok: false; reason: AiConnectionUnavailableReason; message: string };
export interface AiManagedConnectionSummary {
id: string;
grantId: string;
companyId: string;
provider: AiProvider;
method: AiAuthMethod;
name: string;
accountLabel?: string;
ownership: "personal" | "shared";
ownerUserId?: string;
ownerName?: string;
isDefault: boolean;
status: "connected" | "needs_attention" | "expired" | "revoked";
unavailableReason?: string;
}
export const createAiConnectionSchema = z
.object({
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
apiKey: z.string().trim().min(1).max(32768).optional(),
loginSessionId: z.string().max(128).optional(),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict()
.superRefine((v, ctx) => {
if (!AI_CONNECTION_CAPABILITIES[v.provider].methods[v.method])
ctx.addIssue({ code: "custom", message: "Unsupported sign-in method" });
if (
v.method === "api_key"
? !v.apiKey || Boolean(v.loginSessionId)
: !v.loginSessionId || Boolean(v.apiKey)
) {
ctx.addIssue({
code: "custom",
message:
"Provide exactly the credential for the selected sign-in method",
});
}
});
export type CreateAiConnection = z.infer<typeof createAiConnectionSchema>;
export const aiConnectionLoginIntentSchema = z
.object({
provider: aiProviderSchema,
method: z.literal("subscription"),
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict();
export type AiConnectionLoginIntent = z.infer<
typeof aiConnectionLoginIntentSchema
>;
export const localAiConnectionSchema = aiConnectionLoginIntentSchema.extend({
localSessionId: z.string().uuid().optional(),
});
export const localAiLoginStartSchema = aiConnectionLoginIntentSchema.extend({ restart: z.boolean().optional() });
export interface LocalAiLoginStatus {
status: "ready" | "sign_in_required" | "expired";
}
export interface LocalAiLoginAttempt {
sessionId: string;
command: string;
expiresAt: string;
}
/** Preview-era copies of rotating local credentials must be reconnected. */
export function aiSubscriptionNeedsIsolatedLogin(config: Record<string, unknown> | undefined): boolean {
const metadata = aiConnectionMetadataSchema.safeParse(config?.ai);
return metadata.success && metadata.data.method === "subscription" &&
(metadata.data.provider === "openai" || metadata.data.provider === "xai") &&
config?.aiIsolatedSubscription !== true;
}

View File

@ -3,66 +3,70 @@ 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 a5 from "./app-definitions/imessage-photon.json" with { type: "json" };
import a6 from "./app-definitions/telegram.json" with { type: "json" };
import a7 from "./app-definitions/discord.json" with { type: "json" };
import a8 from "./app-definitions/notion.json" with { type: "json" };
import a9 from "./app-definitions/posthog.json" with { type: "json" };
import a10 from "./app-definitions/linear.json" with { type: "json" };
import a11 from "./app-definitions/context7.json" with { type: "json" };
import a12 from "./app-definitions/shopify.json" with { type: "json" };
import a13 from "./app-definitions/composio.json" with { type: "json" };
import a14 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a15 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a16 from "./app-definitions/sentry.json" with { type: "json" };
import a17 from "./app-definitions/vercel.json" with { type: "json" };
import a18 from "./app-definitions/anthropic.json" with { type: "json" };
import a19 from "./app-definitions/jira.json" with { type: "json" };
import a20 from "./app-definitions/airtable.json" with { type: "json" };
import a21 from "./app-definitions/beehiiv.json" with { type: "json" };
import a22 from "./app-definitions/bitly.json" with { type: "json" };
import a23 from "./app-definitions/candid.json" with { type: "json" };
import a24 from "./app-definitions/cloudflare.json" with { type: "json" };
import a25 from "./app-definitions/cloudinary.json" with { type: "json" };
import a26 from "./app-definitions/coda.json" with { type: "json" };
import a27 from "./app-definitions/hugging-face.json" with { type: "json" };
import a28 from "./app-definitions/kernel.json" with { type: "json" };
import a29 from "./app-definitions/local-falcon.json" with { type: "json" };
import a30 from "./app-definitions/make.json" with { type: "json" };
import a31 from "./app-definitions/manufact.json" with { type: "json" };
import a32 from "./app-definitions/miro.json" with { type: "json" };
import a33 from "./app-definitions/netlify.json" with { type: "json" };
import a34 from "./app-definitions/oreilly.json" with { type: "json" };
import a35 from "./app-definitions/planetscale.json" with { type: "json" };
import a36 from "./app-definitions/resend.json" with { type: "json" };
import a37 from "./app-definitions/ticktick.json" with { type: "json" };
import a38 from "./app-definitions/todoist.json" with { type: "json" };
import a39 from "./app-definitions/webflow.json" with { type: "json" };
import a40 from "./app-definitions/wix.json" with { type: "json" };
import a41 from "./app-definitions/brex.json" with { type: "json" };
import a42 from "./app-definitions/clickhouse.json" with { type: "json" };
import a43 from "./app-definitions/egnyte.json" with { type: "json" };
import a44 from "./app-definitions/embat.json" with { type: "json" };
import a45 from "./app-definitions/mixpanel.json" with { type: "json" };
import a46 from "./app-definitions/postman.json" with { type: "json" };
import a47 from "./app-definitions/razorpay.json" with { type: "json" };
import a48 from "./app-definitions/sanity.json" with { type: "json" };
import a49 from "./app-definitions/stripe.json" with { type: "json" };
import a50 from "./app-definitions/supabase.json" with { type: "json" };
import a51 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a52 from "./app-definitions/asana.json" with { type: "json" };
import a53 from "./app-definitions/box.json" with { type: "json" };
import a54 from "./app-definitions/mem0.json" with { type: "json" };
import a55 from "./app-definitions/pagerduty.json" with { type: "json" };
import a56 from "./app-definitions/similarweb.json" with { type: "json" };
import a57 from "./app-definitions/xero.json" with { type: "json" };
import a58 from "./app-definitions/gmail.json" with { type: "json" };
import a59 from "./app-definitions/google-drive.json" with { type: "json" };
import a60 from "./app-definitions/google-docs.json" with { type: "json" };
import a61 from "./app-definitions/google-sheets.json" with { type: "json" };
import a62 from "./app-definitions/google-slides.json" with { type: "json" };
import a63 from "./app-definitions/google-calendar.json" with { type: "json" };
import a64 from "./app-definitions/google-chat.json" with { type: "json" };
import a65 from "./app-definitions/google-people.json" with { type: "json" };
import a66 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a67 from "./app-definitions/openai.json" with { type: "json" };
import a68 from "./app-definitions/openrouter.json" with { type: "json" };
import a69 from "./app-definitions/xai.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,a65] 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,a66,a67,a68,a69] as AppDefinition[];

View File

@ -679,7 +679,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(41);
expect(APP_STORE_DEFINITIONS).toHaveLength(46);
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 41 store-visible providers", () => {
it("ships complete local branding provenance for all 46 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(41);
expect(visible).toHaveLength(46);
expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty(
"size",
41,
46,
);
expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty(
"size",
41,
46,
);
expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual(
new Set(visible.map((entry) => entry.slug)),

View File

@ -4,6 +4,7 @@ import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-d
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai",
"agentmail",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"zapier",
@ -27,6 +28,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
@ -170,6 +172,7 @@ export function connectionMethodAcceptsCustomerOAuthClient(method: ConnectionMet
export function connectionMethodSupportsCatalogSetup(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
if (method.transport === "runtime_auth") return Boolean(method.ai);
if (method.auth === "none" || method.auth === "api_key") return true;
return connectionMethodSupportsAutomaticOAuth(method)
|| connectionMethodAcceptsCustomerOAuthClient(method);

View File

@ -15,6 +15,62 @@
"https://api.anthropic.com/*"
],
"methods": [
{
"key": "ai-subscription",
"label": "Claude subscription",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "oauth",
"ai": {
"provider": "anthropic",
"method": "subscription"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3"
},
{
"key": "ai-api_key",
"label": "Claude API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "anthropic",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "ANTHROPIC_API_KEY"
}
},
{
"key": "api-key",
"transport": "rest_api",

View File

@ -0,0 +1,50 @@
{
"schemaVersion": 1,
"slug": "imessage-photon",
"name": "iMessage Photon",
"description": "Message a Paperclip agent from Apple Messages using Photon Cloud. Pro supports DMs; dedicated lines also support groups.",
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/imessage-photon.png"
},
"urlPatterns": [
"https://photon.codes/*"
],
"methods": [
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "imessage-photon",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in iMessage Photon start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "projectSecret",
"label": "Project secret",
"type": "password",
"required": true,
"placeholder": "Photon project secret",
"secret": true
}
],
"guidanceMd": "Connect a Photon Cloud project. Pro shared lines support DMs after sender enrollment in Photon and identity linking in Paperclip. Dedicated lines also support individually enabled groups.",
"consoleLinks": {
"register": "https://photon.codes/",
"docs": "https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing"
},
"riskTier": "S3",
"requiredResourceFilters": [
"direct_message",
"group_chat"
]
}
]
}

View File

@ -3,12 +3,16 @@
"slug": "microsoft-teams",
"name": "Microsoft Teams",
"description": "Let people start and continue Paperclip work with an agent from Microsoft Teams.",
"categories": ["communication"],
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/microsoft-teams.svg"
},
"urlPatterns": ["https://teams.microsoft.com/*"],
"urlPatterns": [
"https://teams.microsoft.com/*"
],
"methods": [
{
"key": "chat-agent",
@ -17,7 +21,9 @@
"provider": "microsoft-teams",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": ["customer"],
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in Microsoft Teams start and continue work with one Paperclip agent.",
"credentialFields": [
{
@ -51,7 +57,11 @@
"docs": "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams"
},
"riskTier": "S3",
"requiredResourceFilters": ["team", "channel", "chat"]
"requiredResourceFilters": [
"team",
"channel",
"chat"
]
}
]
}

View File

@ -0,0 +1,74 @@
{
"schemaVersion": 1,
"slug": "openai",
"name": "OpenAI",
"description": "Connect OpenAI accounts for your agents.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/openai.svg",
"darkLogoUrl": "/brands/apps/openai-dark.svg"
},
"urlPatterns": [
"https://api.openai.com/*"
],
"methods": [
{
"key": "ai-subscription",
"label": "OpenAI subscription",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "oauth",
"ai": {
"provider": "openai",
"method": "subscription"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3"
},
{
"key": "ai-api_key",
"label": "OpenAI API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "openai",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "OPENAI_API_KEY"
}
}
]
}

View File

@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "openrouter",
"name": "OpenRouter",
"description": "Connect OpenRouter accounts for your agents.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/openrouter.svg",
"darkLogoUrl": "/brands/apps/openrouter-dark.svg"
},
"urlPatterns": [
"https://openrouter.ai/api/*"
],
"methods": [
{
"key": "ai-api_key",
"label": "OpenRouter API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "openrouter",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "OPENROUTER_API_KEY"
}
}
]
}

View File

@ -0,0 +1,74 @@
{
"schemaVersion": 1,
"slug": "xai",
"name": "Grok",
"description": "Connect Grok accounts for your agents.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/xai.svg",
"darkLogoUrl": "/brands/apps/xai-dark.svg"
},
"urlPatterns": [
"https://api.x.ai/*"
],
"methods": [
{
"key": "ai-subscription",
"label": "Grok subscription",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "oauth",
"ai": {
"provider": "xai",
"method": "subscription"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3"
},
{
"key": "ai-api_key",
"label": "Grok API key",
"purpose": "ai",
"transport": "runtime_auth",
"auth": "api_key",
"ai": {
"provider": "xai",
"method": "api_key"
},
"grantKinds": [
"user",
"organization"
],
"ownershipModes": [
"customer"
],
"whenToUse": "Authenticate an agent with this account.",
"guidanceMd": "Use your personal account or an explicitly shared company account.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "Enter API key",
"secret": true
}
],
"keyPlacement": {
"location": "env",
"name": "XAI_API_KEY"
}
}
]
}

View File

@ -2761,5 +2761,6 @@ export type { ExecutionProjection, ExecutionReconciliation, ExecutionBlocker } f
export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from "./types/execution-projection.js";
export * from "./ai-connections.js";
export * from "./types/email.js";
export * from "./validators/email.js";

View File

@ -1,3 +1,4 @@
import type { AiConnectionLoginIntent } from "../ai-connections.js";
import type {
AgentAdapterType,
PauseReason,
@ -22,7 +23,9 @@ export interface AgentPermissions extends Record<string, unknown> {
authorizationPolicy?: TrustAuthorizationPolicy;
}
export type AgentRuntimeConfig = Record<string, unknown>;
export type AgentRuntimeConfig = Record<string, unknown> & {
aiConnection?: import("../ai-connections.js").AiConnectionBinding;
};
export type AgentInstructionsBundleMode = "managed" | "external";
@ -197,6 +200,7 @@ export interface CodexAccountBindingClaim {
// The owner read of a login session. It adds the one-time prompt to the public
// response. Only the owner principal that started the session reads this shape.
export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionResponse {
aiConnection?: AiConnectionLoginIntent;
prompt: AdapterAuthSessionPrompt | null;
codexAccountBinding?: CodexAccountBindingClaim | null;
}
@ -204,6 +208,7 @@ export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionRespo
// The request that starts a login session for one adapter in one environment.
// The owner principal comes from the authenticated caller, not from this body.
export interface StartAdapterAuthSessionRequest {
aiConnection?: AiConnectionLoginIntent;
environmentId: string;
adapterType: AgentAdapterType;
ttlSeconds?: number;
@ -278,6 +283,7 @@ export interface ClaudeSetupTokenSessionResponse {
// the session reads this shape.
export interface ClaudeSetupTokenSessionOwnerResponse
extends ClaudeSetupTokenSessionResponse {
aiConnection?: AiConnectionLoginIntent;
panelMode: AdapterAuthPanelMode;
prompt: ClaudeSetupTokenSessionPrompt | null;
}

View File

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

View File

@ -6,6 +6,7 @@ export const CHAT_PROVIDERS = [
"microsoft-teams",
"telegram",
"agentmail",
"imessage-photon",
] as const;
export type ChatProvider = (typeof CHAT_PROVIDERS)[number];
@ -226,6 +227,7 @@ export interface ChatEndpoint {
botUsername?: string | null;
botLabel?: string | null;
botAvatarUrl?: string | null;
photonAllocation?: "dedicated" | "shared";
allowDirectMessages: boolean;
allowGroupChats: boolean;
allowUnlinkedPeople: boolean;
@ -255,6 +257,7 @@ export interface ChatEndpointResource {
enabled: boolean;
createdAt: string;
updatedAt: string;
participants?: string[];
}
export interface ChatExternalPrincipal {
@ -467,6 +470,7 @@ export interface UpdateChatEndpointInput {
export interface ConfigureChatEndpointInput {
action: "configure" | "verify" | "pause" | "resume" | "reconnect" | "remove";
credentials?: Record<string, string>;
photon?: PhotonChannelConfiguration;
}
export interface NormalizedChatEvent {
@ -503,3 +507,15 @@ export interface NormalizedChatEvent {
};
raw: Record<string, unknown>;
}
/** Safe Photon project inspection; credentials and line tokens are never serialized. */
export interface PhotonProjectInspection {
projectId: string;
projectName: string;
allocation: "dedicated" | "shared";
eligible: boolean;
lines: Array<{ lineId: string; phoneNumber: string; eligible: boolean; unavailableReason?: string }>;
}
export type PhotonChannelConfiguration =
| { allocation?: "dedicated"; projectId: string; lineId: string }
| { allocation: "shared"; projectId: string };

View File

@ -42,6 +42,12 @@ export interface ConnectionRequestResult {
export type ConnectionIntentSetupConnection = Pick<ToolConnection, "id" | "applicationId" | "name" | "status" | "enabled">;
export interface ConnectionIntentSetupOptions {
aiConnection?: import("../ai-connections.js").AiConnectionBinding;
/** Selected account, including an unavailable default. Reconnect must preserve its identity. */
aiRepair?: {
connection: import("../ai-connections.js").AiManagedConnectionSummary;
canReconnect: boolean;
};
version: 1;
interaction: ConnectionIntentInteraction;
service: ConnectionSearchResultItem;

View File

@ -1057,6 +1057,8 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata {
version: 1;
/** Inbound channel attribution; never an authorization input. */
sourceChannel?: "imessage-photon";
sourceRunId?: string | null;
sourceIdentityContextId?: string | null;
authorizationReason?: string | null;
@ -1329,6 +1331,8 @@ export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry";
*/
export interface ConnectionIntentPayload {
version: 1;
/** Runtime authentication requests cannot be satisfied by tool credentials. */
purpose?: "ai";
serviceSlug: string;
serviceName: string;
serviceLogoUrl?: string | null;

View File

@ -68,8 +68,8 @@ export type {
export type ToolActorType = "agent" | "user" | "system" | "plugin";
export type ToolConnectionTransport =
"mcp_remote" | "rest_api" | "local_stdio" | "chat_sdk";
export type ToolConnectionPurpose = "tool" | "channel";
"mcp_remote" | "rest_api" | "local_stdio" | "chat_sdk" | "runtime_auth";
export type ToolConnectionPurpose = "tool" | "channel" | "ai";
export type ToolConnectionAuthKind = "oauth" | "api_key" | "none";
export type ToolConnectionOwnership =
"platform_shared" | "platform_provisioned" | "customer" | "dcr";

View File

@ -1,3 +1,4 @@
import { aiConnectionLoginIntentSchema } from "../ai-connections.js";
import { z } from "zod";
import { AGENT_ADAPTER_TYPES } from "../constants.js";
import { ADAPTER_AUTH_SESSION_STATUSES } from "../types/agent.js";
@ -35,11 +36,13 @@ export type AdapterAuthSessionPrompt = z.infer<typeof adapterAuthSessionPromptSc
// The owner read schema. It adds the one-time prompt to the public response.
export const adapterAuthSessionOwnerResponseSchema = adapterAuthSessionResponseSchema.extend({
prompt: adapterAuthSessionPromptSchema.nullable(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
}).strict();
export type AdapterAuthSessionOwnerResponse =
z.infer<typeof adapterAuthSessionOwnerResponseSchema>;
export const startAdapterAuthSessionRequestSchema = z.object({
aiConnection: aiConnectionLoginIntentSchema.optional(),
environmentId: z.string().guid(),
adapterType: z.enum(AGENT_ADAPTER_TYPES),
ttlSeconds: z.number().int().min(60).max(24 * 60 * 60).optional(),

View File

@ -1,3 +1,4 @@
import { aiConnectionBindingSchema } from "../ai-connections.js";
import { z } from "zod";
import {
AGENT_ICON_NAMES,
@ -60,6 +61,7 @@ export const createAgentInstructionsBundleSchema = z.object({
});
export const agentRuntimeConfigSchema = z.object({
aiConnection: aiConnectionBindingSchema.optional(),
debug: z.object({
providerTrace: z.literal("raw").optional(),
}).strict().optional(),
@ -242,6 +244,7 @@ export const resetAgentSessionSchema = z.object({
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
aiConnection: aiConnectionBindingSchema.optional(),
/** 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. */

File diff suppressed because one or more lines are too long

View File

@ -87,6 +87,17 @@ export const updateChatEndpointSchema = z
message: "At least one chat endpoint field is required",
});
export const photonProjectIdSchema = z.string().trim().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
export const photonLineIdSchema = z.string().trim().min(1).max(63).regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/);
export const photonChannelConfigurationSchema = z.union([
z.object({ allocation: z.literal("dedicated").default("dedicated"), projectId: photonProjectIdSchema, lineId: photonLineIdSchema }).strict(),
z.object({ allocation: z.literal("shared"), projectId: photonProjectIdSchema }).strict(),
]);
export const inspectPhotonProjectSchema = z.object({
projectId: photonProjectIdSchema,
projectSecret: z.string().min(1).max(4096),
}).strict();
export const configureChatEndpointSchema = z
.object({
action: z.enum([
@ -98,11 +109,12 @@ export const configureChatEndpointSchema = z
"remove",
]),
credentials: chatEndpointCredentialsSchema.optional(),
photon: photonChannelConfigurationSchema.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.credentials &&
(value.credentials || value.photon) &&
value.action !== "configure" &&
value.action !== "reconnect"
) {

View File

@ -1,3 +1,4 @@
import { aiConnectionLoginIntentSchema } from "../ai-connections.js";
import { z } from "zod";
import { AGENT_ADAPTER_TYPES } from "../constants.js";
import { ADAPTER_AUTH_PANEL_MODES, SETUP_TOKEN_TRANSPORT_ADVISORY_CODE } from "../types/agent.js";
@ -43,6 +44,7 @@ export type ClaudeSetupTokenOverwrite =
// runtime does not support a caller-supplied session length, so the schema
// exposes no `ttlSeconds` field; a legacy `ttlSeconds` fails the strict parse.
export const startClaudeSetupTokenSessionRequestSchema = z.object({
aiConnection: aiConnectionLoginIntentSchema.optional(),
environmentId: z.string().guid(),
adapterType: z.enum(AGENT_ADAPTER_TYPES),
overwrite: claudeSetupTokenOverwriteSchema.optional(),
@ -87,6 +89,7 @@ export const claudeSetupTokenSessionOwnerResponseSchema =
claudeSetupTokenSessionResponseSchema.extend({
panelMode: adapterAuthPanelModeSchema,
prompt: claudeSetupTokenSessionPromptSchema.nullable(),
aiConnection: aiConnectionLoginIntentSchema.optional(),
}).strict();
export type ClaudeSetupTokenSessionOwnerResponse =
z.infer<typeof claudeSetupTokenSessionOwnerResponseSchema>;

View File

@ -1000,6 +1000,7 @@ export const issueCommentMetadataSectionSchema = z
export const issueCommentMetadataSchema = z
.object({
version: z.literal(1),
sourceChannel: z.literal("imessage-photon").optional(),
sourceRunId: z.string().guid().nullable().optional(),
authorizationReason: z
.string()
@ -1073,6 +1074,7 @@ const connectionIntentBrandAssetSchema = z
export const connectionIntentPayloadSchema = z
.object({
purpose: z.literal("ai").optional(),
version: z.literal(1),
serviceSlug: z.string().trim().min(1).max(120),
serviceName: z.string().trim().min(1).max(160),

View File

@ -10,6 +10,8 @@ overrides:
rollup: '>=4.59.0'
react: ^19.2.8
react-dom: ^19.2.8
'@codemirror/state': ^6.7.2
'@codemirror/view': ^6.43.11
patchedDependencies:
'@agentclientprotocol/claude-agent-acp@0.73.0':
@ -618,11 +620,11 @@ importers:
specifier: ^6.12.4
version: 6.12.4
'@codemirror/state':
specifier: ^6.7.1
version: 6.7.1
specifier: ^6.7.2
version: 6.7.2
'@codemirror/view':
specifier: ^6.43.9
version: 6.43.9
specifier: ^6.43.11
version: 6.43.11
'@lezer/highlight':
specifier: ^1.2.1
version: 1.2.3
@ -883,6 +885,9 @@ importers:
'@discordjs/ws':
specifier: 1.2.3
version: 1.2.3(patch_hash=vbylpnnxt5av2zdnuqmf6hjyjy)
'@grpc/grpc-js':
specifier: 1.14.4
version: 1.14.4
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.1
@ -934,6 +939,9 @@ importers:
'@paperclipai/skills-catalog':
specifier: workspace:*
version: link:../packages/skills-catalog
'@photon-ai/advanced-imessage':
specifier: 2.1.0
version: 2.1.0(@grpc/grpc-js@1.14.4)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17)
'@vercel/connect':
specifier: 0.6.1
version: 0.6.1(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12)))
@ -973,12 +981,21 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
heif2jpeg:
specifier: 0.1.6
version: 0.1.6
jsdom:
specifier: ^30.0.1
version: 30.0.1(@noble/hashes@2.4.0)
multer:
specifier: ^2.2.0
version: 2.2.0
nice-grpc:
specifier: 2.1.17
version: 2.1.17
nice-grpc-common:
specifier: 2.0.4
version: 2.0.4
open:
specifier: ^11.0.1
version: 11.0.1
@ -1664,6 +1681,9 @@ packages:
'@bufbuild/protobuf@1.10.0':
resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==}
'@bufbuild/protobuf@2.15.0':
resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==}
'@chat-adapter/discord@4.39.0':
resolution: {integrity: sha512-9IS4HF0Jw+Ucsm3f7XaXGCGb22Swb/r6FGaHoSDYxOEraQ88pFH5S7xc8//C+pCnqhX7p8QwLB/dYaNxDnl9pw==}
engines: {node: '>=20'}
@ -1795,18 +1815,12 @@ packages:
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.7.1':
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
'@codemirror/state@6.7.2':
resolution: {integrity: sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg==}
'@codemirror/view@6.43.11':
resolution: {integrity: sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==}
'@codemirror/view@6.43.9':
resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==}
'@connectrpc/connect-node@1.7.0':
resolution: {integrity: sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==}
engines: {node: '>=16.0.0'}
@ -2506,6 +2520,51 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@grpc/grpc-js@1.14.4':
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.8.1':
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
engines: {node: '>=6'}
hasBin: true
'@heif2jpeg/heif2jpeg-darwin-arm64@0.1.6':
resolution: {integrity: sha512-HSXg4gGIb/RUwdUyRfXMopxvs8JpTLexW5+XLV3MqNZ8bMjUINv8aAigBECZ7It8dFWfcpui236S+z/LLMMFHQ==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [darwin]
'@heif2jpeg/heif2jpeg-darwin-x64@0.1.6':
resolution: {integrity: sha512-2Rno3RuhB5jK61RoMThoD9XNG+p997xOs9qN+qAk4vlr8IVFvAamunWvmtumHiJOWQ97mL7WuvpTa4PGDadqcQ==}
engines: {node: '>= 18'}
cpu: [x64]
os: [darwin]
'@heif2jpeg/heif2jpeg-linux-arm64-gnu@0.1.6':
resolution: {integrity: sha512-myeXEKyYG39tPz7Tir526LozlRKk8a70zdpiBoEIZs1vxft3NAyj7OiR2SHbEQVOuRmcdOtJ6wxswvbafzOtAw==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
'@heif2jpeg/heif2jpeg-linux-x64-gnu@0.1.6':
resolution: {integrity: sha512-fvxLcCxbmXNHEvGpnKjVZmO3Wx1ew/e2+LvOOKW1vHbH5/5E7xdgYpbJVGcVa4s4nneKbybhNXDfaMdcZYqRgg==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
'@heif2jpeg/heif2jpeg-win32-arm64-msvc@0.1.6':
resolution: {integrity: sha512-WWh/rOngHL+q27Y8TXIwsBH111gBf5Z7kBc14LZg+LoD/EnK1KzXx5NcguIyhuKzAEQJk+BwTIqfP7HeFWCrmw==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [win32]
'@heif2jpeg/heif2jpeg-win32-x64-msvc@0.1.6':
resolution: {integrity: sha512-Op3rpPzCwUxXHbzJD9hHNFBlV2nLr2/j+oNg5v0v4utKhLJ5GZg7NbfMJa07T1MkYMRs6lS60QhY/IWwWbSlFQ==}
engines: {node: '>= 18'}
cpu: [x64]
os: [win32]
'@hono/node-server@2.1.0':
resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==}
engines: {node: '>=20'}
@ -2693,6 +2752,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@lexical/a11y@0.48.0':
resolution: {integrity: sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA==}
peerDependencies:
@ -2939,9 +3001,6 @@ packages:
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
@ -3381,6 +3440,21 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@photon-ai/advanced-imessage@2.1.0':
resolution: {integrity: sha512-KT/aCBWcq667alyKe8DclqDUa/0fUuW2ZYDCxfIKuQBm08U4JcNG56fCdNUd69MFkKkzZgW35j59EYsx5DnLcQ==}
engines: {node: '>=18.17'}
peerDependencies:
'@grpc/grpc-js': ^1.12.6
nice-grpc: ^2.1.11
nice-grpc-common: ^2.0.2
peerDependenciesMeta:
'@grpc/grpc-js':
optional: true
nice-grpc:
optional: true
nice-grpc-common:
optional: true
'@pierre/diffs@1.3.6':
resolution: {integrity: sha512-a3woaW2QHy78JDxPJK0OJzwZUN4xoQLLIS/pceO8X6+L8gA5D682mP7/w3YxxEVRPXOaoe/p/RJ5Oj/3nrEzew==}
peerDependencies:
@ -3426,6 +3500,33 @@ packages:
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.5':
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
'@protobufjs/eventemitter@1.1.1':
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
'@protobufjs/fetch@1.1.1':
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@radix-ui/colors@3.0.0':
resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==}
@ -5363,6 +5464,9 @@ packages:
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
abort-controller-x@0.5.0:
resolution: {integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -5751,6 +5855,10 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -5759,8 +5867,8 @@ packages:
resolution: {integrity: sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@codemirror/state': ^6.7.2
'@codemirror/view': ^6.43.11
'@lezer/highlight': ^1.0.0
cmdk@1.1.1:
@ -6644,6 +6752,10 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
heif2jpeg@0.1.6:
resolution: {integrity: sha512-pXgHIbFS6HDcGhcYHkTltdKG+WSEb7Xtk8NSzQf7tVz3Nw8JzXojqyOqyqRfMLYLbHzxTuLN3a4FZKFlTHzcRA==}
engines: {node: '>= 18'}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@ -7062,6 +7174,9 @@ packages:
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
@ -7092,6 +7207,9 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -7438,6 +7556,12 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
nice-grpc-common@2.0.4:
resolution: {integrity: sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw==}
nice-grpc@2.1.17:
resolution: {integrity: sha512-pu9xYPlWSeqoYQOCqb2ftQqZi/P2DaI70PSuwnxvoyIRiilaOVtktyPe6LmuXegWB4Ic1sPuDGCnCCASbwzK0w==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -7760,6 +7884,10 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
protobufjs@7.6.6:
resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -7975,6 +8103,10 @@ packages:
remend@1.3.1:
resolution: {integrity: sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@ -8384,6 +8516,9 @@ packages:
resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
engines: {node: '>=6.10'}
ts-error@1.0.6:
resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==}
ts-mixer@6.0.4:
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
@ -8785,12 +8920,24 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
yjs@13.6.32:
resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@ -9360,6 +9507,8 @@ snapshots:
'@bufbuild/protobuf@1.10.0': {}
'@bufbuild/protobuf@2.15.0': {}
'@chat-adapter/discord@4.39.0(patch_hash=nywlhls7dd4npegrgax7jqdbxa)(zod@4.4.3)':
dependencies:
'@chat-adapter/shared': 4.39.0(zod@4.4.3)
@ -9453,22 +9602,22 @@ snapshots:
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.2':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.0':
@ -9530,8 +9679,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.6
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
@ -9681,8 +9830,8 @@ snapshots:
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@ -9694,14 +9843,14 @@ snapshots:
'@codemirror/lint@6.9.4':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
crelt: 1.0.7
'@codemirror/lint@6.9.6':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
crelt: 1.0.7
'@codemirror/merge@6.12.2':
@ -9714,14 +9863,10 @@ snapshots:
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
crelt: 1.0.7
'@codemirror/state@6.7.1':
dependencies:
'@marijn/find-cluster-break': 1.0.3
'@codemirror/state@6.7.2':
dependencies:
'@marijn/find-cluster-break': 1.0.4
@ -9733,13 +9878,6 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@codemirror/view@6.43.9':
dependencies:
'@codemirror/state': 6.7.1
crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@connectrpc/connect-node@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))':
dependencies:
'@bufbuild/protobuf': 1.10.0
@ -10211,6 +10349,36 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@grpc/grpc-js@1.14.4':
dependencies:
'@grpc/proto-loader': 0.8.1
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.8.1':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.6.6
yargs: 17.7.3
'@heif2jpeg/heif2jpeg-darwin-arm64@0.1.6':
optional: true
'@heif2jpeg/heif2jpeg-darwin-x64@0.1.6':
optional: true
'@heif2jpeg/heif2jpeg-linux-arm64-gnu@0.1.6':
optional: true
'@heif2jpeg/heif2jpeg-linux-x64-gnu@0.1.6':
optional: true
'@heif2jpeg/heif2jpeg-win32-arm64-msvc@0.1.6':
optional: true
'@heif2jpeg/heif2jpeg-win32-x64-msvc@0.1.6':
optional: true
'@hono/node-server@2.1.0(hono@4.13.2)':
dependencies:
hono: 4.13.2
@ -10365,6 +10533,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@js-sdsl/ordered-map@4.4.2': {}
'@lexical/a11y@0.48.0(typescript@7.0.2)':
dependencies:
'@lexical/extension': 0.48.0(typescript@7.0.2)
@ -10690,8 +10860,6 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@marijn/find-cluster-break@1.0.3': {}
'@marijn/find-cluster-break@1.0.4': {}
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
@ -11145,6 +11313,14 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@photon-ai/advanced-imessage@2.1.0(@grpc/grpc-js@1.14.4)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17)':
dependencies:
'@bufbuild/protobuf': 2.15.0
optionalDependencies:
'@grpc/grpc-js': 1.14.4
nice-grpc: 2.1.17
nice-grpc-common: 2.0.4
'@pierre/diffs@1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@pierre/theme': 2.0.0
@ -11180,6 +11356,26 @@ snapshots:
'@preact/signals-core@1.14.4': {}
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.1': {}
'@protobufjs/fetch@1.1.1':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/float@1.0.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.2': {}
'@radix-ui/colors@3.0.0': {}
'@radix-ui/number@1.1.3': {}
@ -13075,6 +13271,8 @@ snapshots:
'@xterm/xterm@6.0.0': {}
abort-controller-x@0.5.0: {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -13406,6 +13604,12 @@ snapshots:
classnames@2.5.1: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {}
cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.2)(@codemirror/view@6.43.11)(@lezer/highlight@1.2.3):
@ -13434,8 +13638,8 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.4
'@codemirror/search': 6.6.0
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.9
'@codemirror/state': 6.7.2
'@codemirror/view': 6.43.11
color-convert@2.0.1:
dependencies:
@ -14370,6 +14574,15 @@ snapshots:
dependencies:
'@types/hast': 3.0.5
heif2jpeg@0.1.6:
optionalDependencies:
'@heif2jpeg/heif2jpeg-darwin-arm64': 0.1.6
'@heif2jpeg/heif2jpeg-darwin-x64': 0.1.6
'@heif2jpeg/heif2jpeg-linux-arm64-gnu': 0.1.6
'@heif2jpeg/heif2jpeg-linux-x64-gnu': 0.1.6
'@heif2jpeg/heif2jpeg-win32-arm64-msvc': 0.1.6
'@heif2jpeg/heif2jpeg-win32-x64-msvc': 0.1.6
help-me@5.0.0: {}
hono@4.13.2: {}
@ -14724,6 +14937,8 @@ snapshots:
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.includes@4.3.0: {}
@ -14744,6 +14959,8 @@ snapshots:
lodash@4.18.1: {}
long@5.3.2: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@ -15374,6 +15591,16 @@ snapshots:
negotiator@1.0.0: {}
nice-grpc-common@2.0.4:
dependencies:
ts-error: 1.0.6
nice-grpc@2.1.17:
dependencies:
'@grpc/grpc-js': 1.14.4
abort-controller-x: 0.5.0
nice-grpc-common: 2.0.4
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@ -15732,6 +15959,20 @@ snapshots:
property-information@7.2.0: {}
protobufjs@7.6.6:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.2
'@types/node': 24.13.3
long: 5.3.2
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -16024,6 +16265,8 @@ snapshots:
remend@1.3.1: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
requires-port@1.0.0: {}
@ -16545,6 +16788,8 @@ snapshots:
ts-dedent@2.3.0: {}
ts-error@1.0.6: {}
ts-mixer@6.0.4: {}
tsconfig-paths@4.2.0:
@ -16893,10 +17138,24 @@ snapshots:
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.3:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yjs@13.6.32:
dependencies:
lib0: 0.2.117

View File

@ -36,3 +36,10 @@ overrides:
rollup: ">=4.59.0"
react: "^19.2.8"
react-dom: "^19.2.8"
# CodeMirror validates extensions with instanceof, so exactly one copy of
# these two packages may resolve. Different CodeMirror packages pin
# different transitive minors, which resolves two copies and crashes the
# editor with "Unrecognized extension value in extension set". A shared
# range forces every consumer onto one resolution.
"@codemirror/state": "^6.7.2"
"@codemirror/view": "^6.43.11"

View File

@ -1,10 +1,18 @@
#!/usr/bin/env bash
# Build the real Docker target twice in a disposable copy of tracked source.
# Build the real Docker target on two fresh builders using an exported cache.
# Export only metadata, avoiding a multi-gigabyte test image in the daemon.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
probe_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-runner-cache.XXXXXX")"
trap 'rm -rf "$probe_dir"' EXIT
baseline_builder="${probe_dir##*/}-baseline"
rebuild_builder="${probe_dir##*/}-rebuild"
cleanup() {
for builder in "$baseline_builder" "$rebuild_builder"; do
docker buildx rm "$builder" >/dev/null 2>&1 || true
done
rm -rf "$probe_dir"
}
trap cleanup EXIT
mkdir "$probe_dir/context"
cd "$repo_root"
git ls-files -z | tar -cf - --null -T - | tar -xf - -C "$probe_dir/context"
@ -21,9 +29,16 @@ FROM scratch AS recipe-proof-export
COPY --from=runner-plan /tmp/runner-recipe.json /recipe.json
DOCKER
build_proof() {
docker buildx build --file "$probe_dir/cache-probe.Dockerfile" --target cache-proof-export --output "type=local,dest=$probe_dir/$1" --progress plain . 2>&1 | tee "$probe_dir/$1.log"
local result="$1" builder="$2"
shift 2
docker buildx build --builder "$builder" --file "$probe_dir/cache-probe.Dockerfile" --target cache-proof-export --output "type=local,dest=$probe_dir/$result" --progress plain "$@" . 2>&1 | tee "$probe_dir/$result.log"
}
build_proof baseline
docker buildx create --name "$baseline_builder" --driver docker-container
build_proof baseline "$baseline_builder" --cache-to "type=local,dest=$probe_dir/cache,mode=max"
# Removing the first builder proves the second build cannot use daemon-local
# state, and releases its disk space before importing the exported cache.
docker buildx rm "$baseline_builder"
docker buildx create --name "$rebuild_builder" --driver docker-container
python3 - <<'CHECK'
from pathlib import Path
p=Path('packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs')
@ -31,7 +46,7 @@ s=p.read_text(); needle='paperclip-runner/runnerd-build-metadata/v1'
assert s.count(needle)==1
p.write_text(s.replace(needle,needle+'-cache-probe'))
CHECK
build_proof source-change
build_proof source-change "$rebuild_builder" --cache-from "type=local,src=$probe_dir/cache"
python3 - <<'CHECK'
import os,json,re
from pathlib import Path
@ -45,12 +60,12 @@ log=(root/'source-change.log').read_text()
step=re.search(r'#(\d+) \[runner-deps[^\n]+ RUN cargo chef cook',log)[1]
assert f'#{step} CACHED' in log
assert 'Compiling paperclip-runner-core' in log
print('PASS: unchanged dependency recipe and cached cook layer; real binary changed.')
print('PASS: fresh builder imported compiled dependencies; real binary changed.')
p=Path('packages/paperclip-runner/runner/Cargo.toml')
s=p.read_text(); assert 'serde_json = "1.0"' in s
p.write_text(s.replace('serde_json = "1.0"','serde_json = ">=1.0.0, <2.0.0"'))
CHECK
docker buildx build --file "$probe_dir/cache-probe.Dockerfile" --target recipe-proof-export --output "type=local,dest=$probe_dir/manifest-change" --progress plain .
docker buildx build --builder "$rebuild_builder" --file "$probe_dir/cache-probe.Dockerfile" --target recipe-proof-export --output "type=local,dest=$probe_dir/manifest-change" --progress plain .
python3 - <<'CHECK'
from pathlib import Path
import os

View File

@ -65,6 +65,7 @@ const chatProviderName = (provider) =>
"microsoft-teams": "Microsoft Teams",
slack: "Slack",
telegram: "Telegram",
"imessage-photon": "iMessage Photon",
})[provider];
const channelMethod = (
provider,
@ -364,6 +365,14 @@ const apps = [
},
),
],
[
"imessage-photon", "iMessage Photon",
"Message a Paperclip agent from Apple Messages using Photon Cloud. Pro supports DMs; dedicated lines also support groups.",
"communication", "photon.codes", ["https://photon.codes/*"],
channelMethod("imessage-photon", [field("projectSecret", "Project secret", "Photon project secret")], ["direct_message", "group_chat"],
"Connect a Photon Cloud project. Pro shared lines support DMs after sender enrollment in Photon and identity linking in Paperclip. Dedicated lines also support individually enabled groups.",
{ register: "https://photon.codes/", docs: "https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing" }),
],
[
"telegram",
"Telegram",
@ -1476,6 +1485,13 @@ const inferState = (slug, state) => {
linkCount: state.links.length,
};
};
// Runtime credentials share the provider catalog, but never expose tool actions.
for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, "ANTHROPIC_API_KEY"], ["openai", "OpenAI", true, "OPENAI_API_KEY"], ["openrouter", "OpenRouter", false, "OPENROUTER_API_KEY"], ["xai", "Grok", true, "XAI_API_KEY"]]) {
let app=apps.find(a=>a.slug===slug);
if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);}
const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})}));
app.methods.unshift(...methods);
}
const validateApp = (app) => {
if (
app.schemaVersion !== 1 ||

View File

@ -4,6 +4,7 @@ import { planArtifacts } from "./preview-artifacts.mjs";
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { execFileSync, spawnSync } from "node:child_process";
import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs";
@ -217,8 +218,13 @@ test("cloud builds bake the managed runtime identity and verify it before public
test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const step = workflow.split(" - name: Select cloud cache ancestry")[1].split(" - name: Setup pnpm")[0];
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const selector = workflow.indexOf(" - name: Select cloud cache ancestry");
assert.ok(selector > workflow.indexOf(" - name: Login to GitHub Container Registry"));
assert.ok(selector > workflow.indexOf(" - name: Set up Docker Buildx"));
assert.ok(selector < workflow.indexOf(" - name: Build and push (cloud)"));
assert.match(workflow, /run: node scripts\/select-cloud-cache.mjs/);
assert.match(workflow, /cache-from: \$\{\{ steps.cloud-cache.outputs.source \}\}/);
const script = fileURLToPath(new URL("./select-cloud-cache.mjs", import.meta.url));
const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-"));
const output = path.join(dir, "output");
const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" };
@ -235,14 +241,25 @@ test("cloud cache imports are bounded, follow master ancestry, and retain the le
git("checkout", "master");
git("merge", "--no-ff", "topic", "-m", "merge topic");
commits.unshift(git("rev-parse", "HEAD"));
const result = spawnSync("bash", ["-c", script], { cwd: dir, encoding: "utf8", env: { ...env, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip", GITHUB_OUTPUT: output } });
const available = `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commits[2]}`;
const inspections = path.join(dir, "inspections");
writeFileSync(path.join(dir, "docker"), `#!/usr/bin/env node
const fs = require("node:fs");
fs.appendFileSync(process.env.CACHE_INSPECTIONS, process.argv.at(-1) + "\\n");
if (process.argv.at(-1) !== process.env.AVAILABLE_CACHE) {
process.stderr.write("manifest unknown");
process.exit(1);
}
`, { mode: 0o755 });
const result = spawnSync(process.execPath, [script], {
cwd: dir, encoding: "utf8", env: {
...env, PATH: `${dir}${path.delimiter}${env.PATH}`, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip",
GITHUB_OUTPUT: output, AVAILABLE_CACHE: available, CACHE_INSPECTIONS: inspections,
},
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readFileSync(output, "utf8").trim().split("\n"), [
"sources<<CACHE_SOURCES",
...commits.slice(0, 10).map((commit) => `type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`),
"type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud",
"CACHE_SOURCES",
]);
assert.equal(readFileSync(output, "utf8"), `source=type=registry,ref=${available}\n`);
assert.deepEqual(readFileSync(inspections, "utf8").trim().split("\n"), commits.slice(0, 3).map((commit) => `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`));
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View File

@ -0,0 +1,66 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
export function cloudCacheCandidates(image, commits) {
if (!/^ghcr\.io\/[a-z0-9._-]+\/[a-z0-9._-]+$/.test(image ?? "")) {
throw new Error("Expected a GHCR owner/repository cache image.");
}
if (!Array.isArray(commits) || commits.length === 0 || commits.some((sha) => !/^[a-f0-9]{40}$/.test(sha))) {
throw new Error("Cloud cache ancestry requires full commit SHAs.");
}
return [
...[...new Set(commits)].slice(0, 10).map((sha) => `${image}:buildcache-cloud-${sha}`),
`${image}:buildcache-cloud`,
];
}
export async function selectCloudCache(image, commits, {
exists = registryCacheExists,
log = console.log,
} = {}) {
for (const ref of cloudCacheCandidates(image, commits)) {
try {
if (!await exists(ref)) continue;
log(`Using cloud cache: ${ref}`);
return `type=registry,ref=${ref}`;
} catch {
// Cache availability must not turn an otherwise valid build into a
// failure. A later ancestor may still be available during a rollout.
log(`Could not inspect cloud cache ${ref}; trying the next ancestor.`);
}
}
log("No cloud cache is available; this build will populate one.");
return "";
}
function registryCacheExists(ref) {
try {
// Use the preceding Docker login, including for private registry caches.
// Inspect metadata only: no layer download and no image execution.
execFileSync("docker", ["buildx", "imagetools", "inspect", "--raw", ref], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
return true;
} catch (error) {
if (/manifest unknown|not found|NAME_UNKNOWN/i.test(String(error.stderr ?? ""))) return false;
throw error;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.");
const commits = execFileSync("git", ["rev-list", "--first-parent", "--max-count=10", "HEAD"], {
encoding: "utf8",
}).trim().split("\n");
const source = await selectCloudCache(process.env.CACHE_IMAGE, commits, { exists: registryCacheExists });
appendFileSync(process.env.GITHUB_OUTPUT, `source=${source}\n`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cloudCacheCandidates, selectCloudCache } from "./select-cloud-cache.mjs";
const image = "ghcr.io/paperclipai/paperclip";
const commits = ["a".repeat(40), "b".repeat(40), "c".repeat(40)];
const candidates = cloudCacheCandidates(image, commits);
test("a same-SHA rerun imports only its existing cache", async () => {
const inspected = [];
const source = await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return true; }, log() {},
});
assert.equal(source, `type=registry,ref=${candidates[0]}`);
assert.deepEqual(inspected, candidates.slice(0, 1));
});
test("a new merge imports only its nearest available ancestor", async () => {
const inspected = [];
const source = await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return ref === candidates[1]; }, log() {},
});
assert.equal(source, `type=registry,ref=${candidates[1]}`);
assert.deepEqual(inspected, candidates.slice(0, 2));
assert.equal(source.includes("\n"), false);
});
test("a still-building parent falls back to an older completed cache", async () => {
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => ref === candidates[2], log() {},
}), `type=registry,ref=${candidates[2]}`);
});
test("the legacy cache is used only if no SHA cache exists", async () => {
const inspected = [];
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return ref === candidates.at(-1); }, log() {},
}), `type=registry,ref=${candidates.at(-1)}`);
assert.deepEqual(inspected, candidates);
});
test("missing caches permit a cold build", async () => {
assert.equal(await selectCloudCache(image, commits, { exists: async () => false, log() {} }), "");
});
test("a failed lookup can fall back without failing image publication", async () => {
const messages = [];
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => {
if (ref === candidates[0]) throw new Error("registry temporarily unavailable");
return true;
},
log: (message) => messages.push(message),
}), `type=registry,ref=${candidates[1]}`);
assert.match(messages[0], /Could not inspect cloud cache/);
});
test("ancestry is bounded, deduplicated, and rejects output injection", () => {
const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(40, "0"));
assert.equal(cloudCacheCandidates(image, many).length, 11);
assert.deepEqual(cloudCacheCandidates(image, [commits[0], commits[0]]), [candidates[0], candidates.at(-1)]);
assert.throws(() => cloudCacheCandidates(`${image}\nsource=untrusted`, commits));
assert.throws(() => cloudCacheCandidates(image, ["master"]));
assert.throws(() => cloudCacheCandidates(image, []));
});

View File

@ -51,6 +51,7 @@
"@chat-adapter/teams": "4.39.0",
"@chat-adapter/telegram": "4.39.0",
"@discordjs/ws": "1.2.3",
"@grpc/grpc-js": "1.14.4",
"@opentelemetry/api": "^1.9.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@ -68,6 +69,7 @@
"@paperclipai/plugin-sdk": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@paperclipai/skills-catalog": "workspace:*",
"@photon-ai/advanced-imessage": "2.1.0",
"@vercel/connect": "0.6.1",
"acpx": "0.13.1",
"ajv": "^8.20.0",
@ -81,8 +83,11 @@
"drizzle-orm": "^0.45.2",
"embedded-postgres": "^18.1.0-beta.16",
"express": "^5.1.0",
"heif2jpeg": "0.1.6",
"jsdom": "^30.0.1",
"multer": "^2.2.0",
"nice-grpc": "2.1.17",
"nice-grpc-common": "2.0.4",
"open": "^11.0.1",
"pino": "^10.0.0",
"pino-http": "^11.0.0",

View File

@ -425,7 +425,7 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
}, 10_000);
});
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
@ -832,6 +832,7 @@ describe("agent live run routes", () => {
// Optional wake fields retain their existing shape; execution identity
// always comes from the authenticated caller.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
manualUserWake: true,
source: "on_demand",
triggerDetail: "manual",
reason: "issue_assigned",
@ -863,6 +864,7 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
manualUserWake: true,
source: "on_demand",
triggerDetail: "manual",
requestedByActorType: "user",
@ -876,6 +878,18 @@ describe("agent live run routes", () => {
});
});
it.each(["wakeup", "heartbeat/invoke"])("lets an operator start an existing agent via %s without creating agents", async (endpoint) => {
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "agent:wake", explanation: "Missing permission: agents:create",
}));
const res = await requestApp(await createApp(undefined, {
type: "board", userId: "operator", source: "session", companyIds: ["company-1"],
}), url => request(url).post(`/api/agents/${routeAgentId}/${endpoint}`).send({}));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent:wake" }));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({ manualUserWake: true }));
});
describe("exact failed chat run retry", () => {
const retryBody = {
failedRunId: failedChatRunId,
@ -901,6 +915,10 @@ describe("agent live run routes", () => {
companyId: "company-1",
});
mockHeartbeatService.getRun.mockResolvedValue(selectedRun);
mockIssueService.getById.mockResolvedValue({
id: failedChatIssueId, companyId: "company-1", assigneeAgentId: routeAgentId,
assigneeUserId: null, projectId: null, parentId: null, status: "blocked",
});
mockChatRunRetries.prepareFailedChatRunRetry.mockResolvedValue({
actionId: retryActionId,
issueId: failedChatIssueId,
@ -913,6 +931,50 @@ describe("agent live run routes", () => {
});
});
it("retries a task for an operator without agent-creation permission", async () => {
const fixture = createFailedChatRetryDb(false);
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: {
issueId: failedChatIssueId,
} });
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "issue:comment" || action === "agent:wake", explanation: "Missing permission: agents:create",
}));
const res = await requestApp(await createApp(fixture.db, {
type: "board", userId: "operator", source: "session", companyIds: ["company-1"],
}), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send(retryBody));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:comment", resource: expect.objectContaining({
type: "issue", companyId: "company-1", issueId: failedChatIssueId,
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({
requestedByActorType: "user", requestedByActorId: "operator", failedRunId: failedChatRunId,
payload: { issueId: failedChatIssueId },
}));
});
it.each(["viewer", "missing", "other-company", "reassigned", "other-chat-owner"])(
"rejects a %s task retry without dispatching or requiring agent creation", async (fault) => {
const fixture = createFailedChatRetryDb(false);
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: { issueId: failedChatIssueId } });
if (fault === "viewer") mockAccessService.decide.mockResolvedValue({
allowed: false, explanation: "Viewer membership does not grant issue:comment.",
});
else mockIssueService.getById.mockResolvedValue(fault === "missing" ? null : {
id: failedChatIssueId, companyId: fault === "other-company" ? "elsewhere" : "company-1",
assigneeAgentId: "other-agent", assigneeUserId: null, projectId: null, parentId: null, status: "blocked",
...(fault === "other-chat-owner" ? { conversationAgentId: routeAgentId, conversationUserId: "someone-else" } : {}),
});
const res = await requestApp(await createApp(fixture.db), url =>
request(url).post(`/api/agents/${routeAgentId}/wakeup`).send(retryBody));
expect(res.status).toBe(fault === "viewer" || fault === "other-chat-owner" ? 403 : fault === "reassigned" ? 409 : 404);
expect(mockAccessService.decide.mock.calls.every(([input]) => input.action !== "agents:create")).toBe(true);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockChatRunRetries.prepareFailedChatRunRetry).not.toHaveBeenCalled();
},
);
it.each([
["failed", "deferred", null],
["timed_out", "running", "55555555-5555-4555-8555-555555555555"],
@ -1073,7 +1135,7 @@ describe("agent live run routes", () => {
);
it.each(["agent", "company", "permission"])(
"denies %s authority before retry selection",
"denies %s authority before retry admission",
async (denial) => {
const fixture = createFailedChatRetryDb();
const actor =
@ -1615,7 +1677,7 @@ describe("agent live run routes", () => {
id: "trace-1",
status: "incomplete",
deletedAt: null,
expiresAt: new Date(Date.now() + 60_000),
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
},
"trace_incomplete",
],

View File

@ -0,0 +1,527 @@
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
let service: ReturnType<typeof aiConnectionService>;
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
const input = { companyId, agentId, adapterType: "claude_local", binding };
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-"));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
db = createDb(database.connectionString);
service = aiConnectionService(db);
await db.insert(companies).values([{ id: companyId, name: "AI connection tests", issuePrefix: "AIT" }, { id: otherCompanyId, name: "Other", issuePrefix: "AIO" }]);
await db.insert(agents).values({ id: agentId, companyId, name: "Nova", adapterType: "claude_local" });
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
}, 90000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it("checks the selected environment for project auth overrides without exposing their contents", async () => {
const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess");
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace/project" } as Parameters<typeof assertManagedAiProjectAuth>[2];
try {
execute.mockResolvedValue({ exitCode: 42, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
await expect(assertManagedAiProjectAuth({}, "openai", target)).rejects.toThrow("project authentication settings");
expect(execute.mock.calls[0][3]).toContain("/workspace/project");
expect(execute.mock.calls[0][3]).toContain(".codex/config.toml");
execute.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
await expect(assertManagedAiProjectAuth({}, "openai", target)).resolves.toBeUndefined();
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "xai", target)).rejects.toThrow("overrides");
} finally { execute.mockRestore(); }
});
it("keeps personal defaults separate and does not replace the first default", async () => {
const first = await create("alice", "Alice first");
await create("alice", "Alice second");
const bob = await create("bob", "Bob first");
const [a,b] = await Promise.all([service.select({ ...input, userId: "alice" }), service.select({ ...input, userId: "bob" })]);
expect(a.grant.id).toBe(first.grantId); expect(b.grant.id).toBe(bob.grantId);
expect(await service.credential(a)).toBe("fixture-Alice first");
expect(await service.credential(b)).toBe("fixture-Bob first");
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-");
expect(await service.list(otherCompanyId, "alice")).toEqual([]);
});
it("retains a revoked default without automatic fallback", async () => {
const selected = await service.select({ ...input, userId: "alice" });
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.grant.id));
await create("alice", "Alice third");
expect(await toolAccessService(db).getConnection(selected.connection.id, companyId)).toMatchObject({ healthStatus: "missing_secret", requiresReauthorization: true });
expect((await toolAccessService(db).listConnections(companyId)).find(connection => connection.id === selected.connection.id)?.healthStatus).toBe("missing_secret");
await expect(service.select({ ...input, userId: "alice" })).rejects.toThrow("Reconnect");
const second = (await service.list(companyId, "alice")).find(a => a.name === "Alice second")!;
await service.setDefault(companyId, "alice", second.grantId);
expect((await service.select({ ...input, userId: "alice" })).grant.id).toBe(second.grantId);
await expect(service.setDefault(companyId, "bob", second.grantId)).rejects.toThrow("owner");
});
it("uses human access for every selection; an agent delegation cannot override Just me", async () => {
const personal = await service.select({ ...input, userId: "alice" });
const delegated = { ...binding, mode: "delegated" as const, connectionId: personal.connection.id, grantId: personal.grant.id };
await expect(service.select({ ...input, userId: "bob", binding: delegated })).rejects.toThrow("not shared");
// Existing delegation records no longer confer an independent AI permission.
await db.insert(connectionGrantDelegations).values({ companyId, grantId: personal.grant.id, agentId, createdByUserId: "alice" });
await expect(service.select({ ...input, userId: "bob", binding: delegated })).rejects.toThrow("not shared");
expect((await service.select({ ...input, userId: "alice", binding: delegated })).grant.id).toBe(personal.grant.id);
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === personal.connection.id)).toBe(false);
await expect(toolAccessService(db).createConnectionGrantDelegation(personal.connection.id, personal.grant.id, agentId, "alice")).rejects.toThrow("human access settings");
});
it("applies the existing human audience editor to AI listing and execution without a second authorization", async () => {
const shared = await create("alice", "Engineering", "shared");
const sharedBinding = { ...binding, mode: "shared" as const, ...shared };
const tools = toolAccessService(db);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { userId: "alice" });
await expect(service.select({ ...input, userId: "bob", binding: sharedBinding })).rejects.toThrow("not shared");
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(false);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { userId: "alice" });
expect((await service.select({ ...input, userId: "bob", binding: sharedBinding })).grant.id).toBe(shared.grantId);
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(true);
await expect(service.select({ ...input, userId: "alice", binding: sharedBinding })).rejects.toThrow("not shared");
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { userId: "alice" });
for (const userId of ["alice", "bob"]) {
expect((await service.select({ ...input, userId, binding: sharedBinding })).grant.id).toBe(shared.grantId);
}
await expect(service.select({ ...input, userId: null, binding: sharedBinding })).rejects.toThrow("not shared");
// Human permission still cannot bypass the separate agent-access setting.
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, shared.connectionId));
await expect(service.select({ ...input, userId: "bob", binding: sharedBinding })).rejects.toThrow("not permitted for this agent");
expect(sharedBinding).toEqual({ ...binding, mode: "shared", ...shared });
});
it("isolates concurrent homes and overrides ambient credentials without changing the model", async () => {
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-never-use");
const config = { model: "unchanged-model", env: { ANTHROPIC_API_KEY: "project-never-use" } };
const [a,b] = await Promise.all(["alice", "bob"].map(responsibleUserId => prepareManagedAiRuntime(db, { ...input, responsibleUserId, config })));
const ae = a.config.env as Record<string,string>, be = b.config.env as Record<string,string>;
expect(ae.ANTHROPIC_API_KEY).toBe("fixture-Alice second"); expect(be.ANTHROPIC_API_KEY).toBe("fixture-Bob first");
expect(ae.HOME).not.toBe(be.HOME); expect(a.identity).not.toBe(b.identity);
expect(a.config.model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
await Promise.all([a.cleanup(), b.cleanup()]); await expect(access(ae.HOME)).rejects.toThrow();
});
it("blocks missing identity, incompatible providers, and cross-company explicit selections", async () => {
await expect(service.select({ ...input, userId: null })).rejects.toThrow("responsible user");
await expect(service.select({ ...input, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("compatible");
const account = await service.select({ ...input, userId: "alice" });
await expect(service.select({ ...input, companyId: otherCompanyId, userId: "alice", binding: { ...binding, mode: "shared", connectionId: account.connection.id, grantId: account.grant.id } })).rejects.toThrow();
});
it("resolves shared encrypted credentials through the existing secret binding system", async () => {
const created = await create("alice", "Shared credential proof", "shared");
const selected = await service.select({ ...input, userId: "bob", binding: { ...binding, mode: "shared", ...created } });
expect(await service.credential(selected)).toBe("fixture-Shared credential proof");
});
it("saves successful login completion once and rejects abandoned attempts", async () => {
const [environment] = await db.insert(environments).values({ name: "AI login test", driver: "sandbox" }).returning();
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Claude subscription", agentIds: [], allAgents: true } as const;
const sessionId = randomUUID();
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() + 60000) });
const first = await service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-subscription", sessionId);
expect(await service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-subscription", sessionId)).toEqual(first);
const cancelled = randomUUID();
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: cancelled, status: "cancelled", expiresAt: new Date(Date.now() + 60000) });
await expect(service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-never-save", cancelled)).rejects.toThrow("no longer active");
});
it("preserves connection identity and defaults through reconnect; revocation wins over older attempts", async () => {
const current = await service.select({ ...input, userId: "bob" });
const reconnect = { ...binding, ownership: "personal" as const, name: current.connection.name, apiKey: "fixture", agentIds: [], allAgents: true, connectionId: current.connection.id };
const result = await service.save(companyId, "bob", reconnect, "fixture-reconnected");
expect(result.grantId).toBe(current.grant.id);
expect(await service.credential(await service.select({ ...input, userId: "bob" }))).toBe("fixture-reconnected");
const beforeRevocation = new Date(Date.now() - 1000);
await db.update(connectionGrants).set({ status: "revoked", updatedAt: new Date() }).where(eq(connectionGrants.id, current.grant.id));
await expect(service.save(companyId, "bob", reconnect, "fixture-stale", undefined, beforeRevocation)).rejects.toThrow("changed");
await expect(service.select({ ...input, userId: "bob" })).rejects.toThrow("Reconnect");
});
it("rejects invalid purpose/transport combinations in the database", async () => {
const selected = await service.select({ ...input, userId: "alice" });
await expect(db.update(toolConnections).set({ transport: "mcp_remote" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
await expect(db.update(toolConnections).set({ connectionPurpose: "tool" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
});
it("indexes only known user credentials, retains references, and is repeatable without adopting agents", async () => {
const selected = await service.select({ ...input, userId: "alice" });
const [emailConnection] = await db.insert(toolConnections).values({
companyId,
applicationId: selected.connection.applicationId,
name: "Existing AgentMail inbox",
uid: `agentmail-migration-${randomUUID()}`,
connectionPurpose: "channel",
transport: "rest_api",
authKind: "api_key",
config: { provider: "agentmail" },
}).returning();
const vault = secretService(db);
const definition = await vault.createUserSecretDefinition(companyId, { key: "legacy_claude", name: "Existing owned Claude key", provider: "local_encrypted" }, { userId: "alice" });
const secret = await vault.createCurrentUserSecretValue(companyId, "alice", { definitionId: definition.id, value: "fixture-legacy" }, { userId: "alice" });
await vault.syncUserSecretDeclarationsForTarget(companyId, { targetType: "agent", targetId: agentId }, [{ definitionKey: definition.key, configPath: "env.ANTHROPIC_API_KEY", envKey: "ANTHROPIC_API_KEY", required: true }]);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
const adoption = migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")));
await db.execute(sql.raw(adoption));
const before = await service.list(companyId, "alice");
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
expect(await service.list(companyId, "alice")).toEqual(before);
const [preservedEmail] = await db.select().from(toolConnections).where(eq(toolConnections.id, emailConnection.id));
expect(preservedEmail).toEqual(emailConnection);
const indexed = before.find(account => account.name === secret.name)!;
expect(indexed.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, indexed.grantId));
expect(grant.credentialSecretRefs[0].secretId).toBe(secret.id);
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
});
it("serializes subscription refresh and releases the lease after execution", async () => {
const subscription = { ...input, binding: { ...binding, method: "subscription" as const }, responsibleUserId: "alice", config: { model: "same-model" } };
const first = await prepareManagedAiRuntime(db, subscription);
await expect(prepareManagedAiRuntime(db, subscription)).rejects.toThrow("in use");
await first.cleanup();
const next = await prepareManagedAiRuntime(db, subscription);
expect(next.identity).toBe(first.identity);
await next.cleanup();
});
it("persists refreshed credentials only to their original grant and fences reconnects", async () => {
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const intent = { provider: "openai" as const, method: "subscription" as const, name: "Refresh test", ownership: "personal" as const, agentIds: [], allAgents: true, loginSessionId: "fixture" };
const saved = await service.save(companyId, "alice", intent, auth("first", 10));
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: "alice", binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
const first = await prepareManagedAiRuntime(db, runInput);
await writeFile(path.join(String(first.config.env.CODEX_HOME), "auth.json"), auth("refreshed", 11));
await first.cleanup();
const selected = await service.select({ ...runInput, userId: "alice" });
expect(await service.credential(selected)).toBe(auth("refreshed", 11));
const second = await prepareManagedAiRuntime(db, runInput);
expect(second.identity).not.toBe(first.identity);
await service.save(companyId, "alice", { ...intent, connectionId: saved.connectionId }, auth("reconnect", 12));
await writeFile(path.join(String(second.config.env.CODEX_HOME), "auth.json"), auth("stale-process", 13));
await second.cleanup();
expect(await service.credential(await service.select({ ...runInput, userId: "alice" }))).toBe(auth("reconnect", 12));
});
it("enforces the shared transport discriminator and existing harness compatibility", () => {
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "ai", transport: "mcp_remote" }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "tool", transport: "runtime_auth" }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "agentmail" } }).success).toBe(true);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "slack" } }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "runtime_auth", config: { provider: "agentmail" } }).success).toBe(false);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "codex")).toBe(false);
expect(isAiConnectionCompatible({ provider: "openrouter", method: "api_key" }, "opencode_local", "anthropic/model")).toBe(false);
});
it("does not let a forged delegation bypass human access or accept an expired subscription attempt", async () => {
const selected = await service.select({ ...input, userId: "alice" });
const otherAgent = randomUUID();
await db.insert(agents).values({ id: otherAgent, companyId, name: "Other" });
await db.insert(connectionGrantDelegations).values({ companyId, grantId: selected.grant.id, agentId: otherAgent, createdByUserId: "bob" });
await expect(service.select({ ...input, agentId: otherAgent, userId: "bob", binding: { ...binding, mode: "delegated", connectionId: selected.connection.id, grantId: selected.grant.id } })).rejects.toThrow("not shared");
const [environment] = await db.select().from(environments).limit(1);
const sessionId = randomUUID();
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Expired", agentIds: [], allAgents: true } as const;
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() - 1000) });
await expect(service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-never-save", sessionId)).rejects.toThrow("no longer active");
expect((await service.list(companyId, "alice")).some(account => account.name === "Expired")).toBe(false);
});
it("authorizes account creation, reconnect and defaults at the HTTP boundary before provider calls", async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
const userId = String(req.headers["x-test-user"] ?? "alice");
const role = req.headers["x-test-role"] === "viewer" ? "viewer" : "member";
req.actor = { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const personal = await service.select({ ...input, userId: "alice" });
const base = `/api/companies/${companyId}/ai-connections`;
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connections`)).status).toBe(403);
expect((await request(app).put(`${base}/default`).set("x-test-user", "bob").send({ grantId: personal.grant.id })).status).toBe(403);
expect((await request(app).put(`${base}/default`).set("x-test-role", "viewer").send({ grantId: personal.grant.id })).status).toBe(403);
const payload = { provider: "anthropic", method: "api_key", name: "Fixture", ownership: "personal", apiKey: "fixture", allAgents: false, agentIds: [] };
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("must not reach provider"));
try {
expect((await request(app).post(base).set("x-test-role", "viewer").send(payload)).status).toBe(403);
expect((await request(app).post(base).send({ ...payload, ownership: "shared" })).status).toBe(403);
expect((await request(app).post(base).set("x-test-user", "bob").send({ ...payload, connectionId: personal.connection.id })).status).toBe(403);
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
it("imports only for the local operator and preserves identity and permissions on reconnect", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: req.headers["x-local"] === "yes" ? "local_implicit" : "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const url = `/api/companies/${companyId}/ai-connections/local`;
const payload = { provider: "anthropic", method: "subscription", name: "Local account test", ownership: "personal", agentIds: [agentId], allAgents: false };
try {
expect((await request(app).post(url).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
expect(checked.status).toBe(200);
expect(checked.body).toEqual({ status: "ready" });
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
expect(connected.status).toBe(201);
expect(JSON.stringify(connected.body)).not.toContain("fixture-local-token");
const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(before.map(i => [i.targetType, i.targetId])).toEqual([["agent", agentId]]);
const reconnected = await request(app).post(url).set("x-local", "yes").send({ ...payload, connectionId: connected.body.connectionId, allAgents: true });
expect(reconnected.status).toBe(201);
expect(reconnected.body).toEqual(connected.body);
const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(after).toEqual(before);
reader.mockRejectedValueOnce(Object.assign(new Error("Sign in locally and retry"), { status: 422 }));
const failed = await request(app).post(url).set("x-local", "yes").send({ ...payload, name: "Unsuccessful local login" });
expect(failed.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false);
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
const attempts = `${url}/attempts`;
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
expect(prepared.status).toBe(201);
expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/);
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
expect((await request(app).post(url).set("x-local", "yes").send({ ...codex, localSessionId: prepared.body.sessionId })).status).toBe(422);
} finally { reader.mockRestore(); }
});
it.each(["anthropic", "openai"] as const)("blocks server-host %s login on a public deployment without a trusted host", async provider => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: "alice", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" }));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const base = `/api/companies/${companyId}/ai-connections/local`;
const intent = { provider, method: "subscription", ownership: "personal", name: "Hosted account", allAgents: false, agentIds: [] };
try {
for (const endpoint of [base, `${base}/attempts`, `${base}/check`]) {
const result = await request(app).post(endpoint).send(intent);
expect(result.status).toBe(422);
expect(result.body.error).toContain("unavailable on this hosted instance");
}
expect(reader).not.toHaveBeenCalled();
} finally { reader.mockRestore(); }
});
it.each(["anthropic", "openai"] as const)("lets authenticated users connect only their own isolated %s login", async provider => {
const owner = `self-hosted-${provider}`;
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("isolated-fixture-token");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? owner), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: req.headers["x-viewer"] ? "viewer" : "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const base = `/api/companies/${companyId}/ai-connections/local`;
const intent = { provider, method: "subscription", ownership: "personal", name: `Self-hosted ${provider}`, allAgents: false, agentIds: [] };
try {
expect((await request(app).post(`${base}/attempts`).set("x-viewer", "yes").send(intent)).status).toBe(403);
const started = await request(app).post(`${base}/attempts`).send(intent);
expect(started.status).toBe(201);
expect(started.headers["cache-control"]).toBe("no-store");
expect(started.body.command).toContain(provider === "anthropic" ? "CLAUDE_CONFIG_DIR=" : "login --device-auth");
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
const input = { ...intent, localSessionId: started.body.sessionId };
for (const endpoint of [base, `${base}/check`]) {
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
}
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${base}/check`).send(input);
expect(checked.body).toEqual({ status: "ready" });
expect(reader).toHaveBeenLastCalledWith(provider, path.join(home, "instances/ai-connection-fixture/ai-local-logins", started.body.sessionId));
const saved = await request(app).post(base).send(input);
expect(saved.status).toBe(201);
expect((await request(app).post(base).send(input)).body).toEqual(saved.body);
expect(JSON.stringify(saved.body)).not.toContain("isolated-fixture-token");
expect((await service.list(companyId, owner)).filter(c => c.name === intent.name)).toHaveLength(1);
} finally { reader.mockRestore(); }
});
it("rejects invalid credentials without exposing the provider response", async () => {
const request = vi.fn().mockResolvedValue(new Response("secret-provider-body", { status: 401 }));
await expect(validateAiApiKey("anthropic", "fixture", request)).rejects.toThrow("rejected");
expect(request.mock.calls[0][1].redirect).toBe("error");
});
it("uses the authenticated responsible user for agent-originated configuration and tests", async () => {
const req = { actor: { type: "agent", agentId, onBehalfOfUserId: "alice" } } as express.Request;
const selected = await service.select({ ...input, userId: responsibleUserForAiRequest(req) });
expect(selected.grant.subjectUserId).toBe("alice");
req.actor.onBehalfOfUserId = undefined;
expect(responsibleUserForAiRequest(req)).toBeNull();
await expect(service.select({ ...input, userId: responsibleUserForAiRequest(req) })).rejects.toThrow();
});
it("protects active-run attribution with the connection human audience", async () => {
const account = await create("alice", "Private run attribution");
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { aiConnection: { connectionId: account.connectionId, grantId: account.grantId } } });
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/active-runs`;
const own = await request(app).get(url);
expect(own.status).toBe(200);
expect(own.headers["cache-control"]).toBe("no-store");
expect(own.body).toEqual([expect.objectContaining({ id: runId, agentId })]);
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
await db.update(connectionGrants).set({ kind: "organization", subjectUserId: null }).where(eq(connectionGrants.id, account.grantId));
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "alice" });
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
expect((await request(app).get(url).set("x-test-user", "bob")).body).toEqual(own.body);
expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403);
});
it("permits new-agent shared installation only for a connection configurator, without bypassing audience", async () => {
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Restricted shared", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-restricted");
const selected = { provider: "anthropic", method: "api_key", mode: "shared", ...account } as const;
const futureAgentId = randomUUID();
const req = (userId: string, role = "member") => ({ actor: { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] } }) as express.Request;
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice"), companyId, selected)).toBe(true);
expect(await canInstallSharedAiConnectionForNewAgent(db, req("bob"), companyId, selected)).toBe(false);
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice", "viewer"), companyId, selected)).toBe(false);
expect(await canInstallSharedAiConnectionForNewAgent(db, { actor: { type: "agent", onBehalfOfUserId: "alice" } } as express.Request, companyId, selected)).toBe(false);
const selectionInput = { ...input, agentId: futureAgentId, userId: "alice", binding: selected };
await expect(service.select(selectionInput)).rejects.toThrow("not permitted for this agent");
const run = await prepareManagedAiRuntime(db, { companyId, agentId: futureAgentId, responsibleUserId: "alice", adapterType: "claude_local", binding: selected, config: { cwd: home, model: "same-model" }, allowUninstalledShared: true });
expect(run.config.model).toBe("same-model");
await run.cleanup();
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
await expect(service.select({ ...selectionInput, allowUninstalledShared: true })).rejects.toThrow("not shared with the responsible user");
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, account.grantId));
await db.insert(agents).values({ id: futureAgentId, companyId, name: "New shared agent", adapterType: "claude_local" });
await db.insert(toolConnectionInstalls).values({ companyId, connectionId: account.connectionId, targetType: "agent", targetId: futureAgentId, createdByUserId: "alice" });
expect((await service.select(selectionInput)).grant.id).toBe(account.grantId);
});
it("creates and hires agents with an authorized restricted shared connection", async () => {
const { agentRoutes } = await import("../routes/agents.js");
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Shared creation routes", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-create-routes");
const selected = { ...binding, mode: "shared", ...account } as const;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
next();
});
app.use("/api", agentRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
for (const endpoint of ["agents", "agent-hires"]) {
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name: `Shared ${endpoint}`, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
expect(response.status, JSON.stringify(response.body)).toBe(201);
const agent = endpoint === "agents" ? response.body : response.body.agent;
expect(agent.adapterConfig.model).toBe("claude-sonnet-4-6");
expect(agent.runtimeConfig.aiConnection).toEqual(selected);
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.connectionId, account.connectionId), eq(toolConnectionInstalls.targetId, agent.id)));
expect(installs).toHaveLength(1);
expect((await service.select({ ...input, agentId: agent.id, userId: "alice", binding: selected })).grant.id).toBe(account.grantId);
}
// A database failure between the two inserts must roll back the agent too.
await db.execute(sql`CREATE FUNCTION reject_test_ai_install() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture install failure'; END $$`);
await db.execute(sql`CREATE TRIGGER reject_test_ai_install BEFORE INSERT ON tool_connection_installs FOR EACH ROW EXECUTE FUNCTION reject_test_ai_install()`);
try {
for (const endpoint of ["agents", "agent-hires"]) {
const name = `Rollback ${endpoint}`;
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
expect(response.status).toBe(500);
expect(await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.name, name)))).toEqual([]);
}
} finally {
await db.execute(sql`DROP TRIGGER reject_test_ai_install ON tool_connection_installs`);
await db.execute(sql`DROP FUNCTION reject_test_ai_install()`);
}
}, 30000);
});
describe("AI connection recovery delivery", () => {
it.each(["restored", "newer failure", "different blocker", "revoked again", "closed task"])(
"continues only the repaired source failure: %s", async (scenario) => {
const userId = `recovery-${randomUUID()}`;
const recoveringAgentId = randomUUID();
const issueId = randomUUID();
const failedRunId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
await db.insert(agents).values({ id: recoveringAgentId, companyId, name: "Recovery agent", status: "active", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
await db.insert(issues).values({ id: issueId, companyId, title: "Restore selected account", status: "in_progress", assigneeAgentId: recoveringAgentId });
await db.insert(heartbeatRuns).values({ id: failedRunId, companyId, agentId: recoveringAgentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId } });
const intents = connectionIntentService(db);
const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" });
const account = await create(userId, `Recovered ${scenario}`);
expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]);
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" } } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } });
await intents.complete(pending.interactionId!, account.connectionId, userId);
if (scenario === "newer failure") await db.insert(heartbeatRuns).values({ companyId, agentId: recoveringAgentId, status: "failed", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
if (scenario === "different blocker") await db.update(issueRecoveryActions).set({ cause: "workspace_validation_failed" }).where(eq(issueRecoveryActions.sourceIssueId, issueId));
if (scenario === "closed task") await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
if (scenario === "revoked again") {
await toolAccessService(db).revokeConnectionGrant(account.connectionId, account.grantId, { actorType: "user", actorId: userId });
const repairOptions = await intents.setupOptions(pending.interactionId!);
expect(repairOptions.existingConnections).toEqual([]);
expect(repairOptions.aiRepair).toMatchObject({ canReconnect: true, connection: { id: account.connectionId, grantId: account.grantId, isDefault: true, status: "revoked" } });
}
const wakeup = vi.fn(async (_agentId, opts) => {
await db.insert(agentWakeupRequests).values({ companyId, agentId: recoveringAgentId, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
return null;
});
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
await delivery.deliver(pending.interactionId!);
await delivery.deliver(pending.interactionId!);
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
if (scenario === "restored") {
expect(issue.status).toBe("in_progress");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(recoveringAgentId, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
const [receipt] = await db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, pending.interactionId!));
expect(receipt.deliveredAt).not.toBeNull();
} else {
expect(wakeup).not.toHaveBeenCalled();
expect(issue.status).toBe(scenario === "closed task" ? "done" : "blocked");
}
}, 30000,
);
});

View File

@ -0,0 +1,175 @@
import { beforeAll, afterAll, it, expect, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { secretService } from "../services/secrets.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID(), agentId = randomUUID(), owner = "audit-owner";
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "ai-legacy-audit-"));
vi.stubEnv("PAPERCLIP_HOME", home);
database = await startEmbeddedPostgresTestDatabase("ai-legacy-audit-db-");
db = createDb(database.connectionString);
await db.insert(companies).values({ id: companyId, name: "Audit", issuePrefix: "AUD" });
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "owner" });
await db.insert(environments).values({ name: "Local audit", driver: "local" }).onConflictDoNothing();
await db.insert(agents).values({ id: agentId, companyId, name: "Unadopted legacy agent", adapterType: "claude_local" });
}, 90000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); if (home) await rm(home, { recursive: true, force: true }); });
it("reconnect detaches an indexed credential without changing unadopted legacy agents", async () => {
const vault = secretService(db), service = aiConnectionService(db);
const definition = await vault.createUserSecretDefinition(companyId, { key: "audit_legacy_claude", name: "Legacy key", provider: "local_encrypted" }, { userId: owner });
const secret = await vault.createCurrentUserSecretValue(companyId, owner, { definitionId: definition.id, value: "fixture-original" }, { userId: owner });
const legacyConfig = { env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: definition.key, required: true } } };
await db.update(agents).set({ adapterConfig: legacyConfig }).where(eq(agents.id, agentId));
await vault.syncUserSecretDeclarationsForTarget(companyId, { targetType: "agent", targetId: agentId }, [{ definitionKey: definition.key, configPath: "env.ANTHROPIC_API_KEY", envKey: "ANTHROPIC_API_KEY", required: true }]);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")))));
const connection = (await service.list(companyId, owner)).find(c => c.name === secret.name)!;
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
expect((await resolve())?.value).toBe("fixture-original");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
expect((await resolve())?.value).toBe("fixture-original");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, connection.grantId));
expect(grant.credentialSecretRefs[0].secretId).not.toBe(secret.id);
const selected = await service.select({ companyId, agentId, userId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
expect(await service.credential(selected)).toBe("fixture-new-account");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-second" }, "fixture-second");
expect((await resolve())?.value).toBe("fixture-original");
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
expect(agent.adapterConfig).toEqual(legacyConfig);
});
const intent = { provider: "openai", method: "subscription", name: "Isolated Codex", ownership: "personal", agentIds: [], allAgents: true } as const;
const loginIntent = () => ({ ...intent, agentIds: [] });
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
const hostHome = path.join(home, "host-codex");
await mkdir(hostHome);
await writeFile(path.join(hostHome, "auth.json"), auth("legacy"));
vi.stubEnv("CODEX_HOME", hostHome);
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}")));
const login = localAiLoginService(db);
const attempt = await login.start(companyId, owner, loginIntent());
const directory = directoryFor(attempt.sessionId);
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
expect(attempt.command).toContain(`(export CODEX_HOME='${directory}' && mkdir -p "$CODEX_HOME" && codex`);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "sign_in_required" });
// Resuming a valid attempt also repairs a missing directory without changing its ID.
await rm(directory, { recursive: true });
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
// A valid host login cannot satisfy an unfinished connection-specific login.
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("sign-in command shown");
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await expect(login.check(companyId, "another-owner", loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
await expect(login.check(randomUUID(), owner, loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
await expect(login.check(companyId, owner, { ...loginIntent(), ownership: "shared" }, attempt.sessionId)).rejects.toThrow("not found");
// New service instance simulates process restart: all intent is durable.
const results = await Promise.all([
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
]);
expect(results[0]).toEqual(results[1]);
await expect(readFile(path.join(directory, "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
const input = { companyId, agentId, adapterType: "codex_local", responsibleUserId: owner, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { cwd: home, model: "unchanged-model" } };
const run = await prepareManagedAiRuntime(db, input);
const credentialFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
expect(JSON.parse(await readFile(credentialFile, "utf8")).tokens.refresh_token).toBe("refresh-independent-login");
await writeFile(credentialFile, auth("rotated-independent", 11));
await run.cleanup();
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
const next = await prepareManagedAiRuntime(db, input);
expect(JSON.parse(await readFile(path.join(String(next.config.env.CODEX_HOME), "auth.json"), "utf8")).tokens.refresh_token).toBe("refresh-rotated-independent");
expect(next.config.model).toBe(input.config.model);
await next.cleanup();
// Cancellation after successful completion must not revoke the saved account.
await login.cancel(companyId, owner, attempt.sessionId);
expect((await aiConnectionService(db).list(companyId, owner)).find(c => c.id === results[0].connectionId)?.status).toBe("connected");
expect((await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
.filter(event => event.action === "ai_connection.local_login_cancelled")).toHaveLength(0);
const reconnectIntent = { ...loginIntent(), connectionId: results[0].connectionId };
const reconnect = await login.start(companyId, owner, reconnectIntent);
expect(reconnect.sessionId).not.toBe(attempt.sessionId);
await writeFile(path.join(directoryFor(reconnect.sessionId), "auth.json"), auth("new-login"));
expect(await login.complete(companyId, owner, reconnect.sessionId, reconnectIntent)).toEqual(results[0]);
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
});
it("enforces local attempt ownership, company, target, cancellation, and expiry", async () => {
const login = localAiLoginService(db);
const attempt = await login.start(companyId, owner, loginIntent());
const cancellations = async () => (await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
.filter(event => event.action === "ai_connection.local_login_cancelled");
await expect(login.complete(companyId, "another-owner", attempt.sessionId, loginIntent())).rejects.toThrow("not found");
await expect(login.cancel(companyId, "another-owner", attempt.sessionId)).rejects.toThrow("not found");
await expect(login.cancel(randomUUID(), owner, attempt.sessionId)).rejects.toThrow("not found");
await expect(login.complete(companyId, owner, attempt.sessionId, { ...loginIntent(), ownership: "shared" })).rejects.toThrow("not found");
expect(await cancellations()).toHaveLength(0);
await login.cancel(companyId, owner, attempt.sessionId);
await login.cancel(companyId, owner, attempt.sessionId);
expect(await cancellations()).toEqual([expect.objectContaining({
companyId, actorType: "user", actorId: owner, entityType: "adapter_auth_session",
entityId: attempt.sessionId, details: { provider: "openai" },
})]);
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("cancelled");
const retry = await login.start(companyId, owner, loginIntent());
await writeFile(path.join(directoryFor(retry.sessionId), "auth.json"), auth("abandoned"));
await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, retry.sessionId));
await expect(login.complete(companyId, owner, retry.sessionId, loginIntent())).rejects.toThrow("expired");
expect(await login.check(companyId, owner, loginIntent(), retry.sessionId)).toEqual({ status: "expired" });
await localAiLoginService(db).reapExpired();
await expect(readFile(path.join(directoryFor(retry.sessionId), "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
const [row] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, retry.sessionId));
expect(row.status).toBe("timed_out");
});
it("explicit retry replaces another owned local attempt while ordinary navigation preserves it", async () => {
const login = localAiLoginService(db);
const first = await login.start(companyId, owner, loginIntent());
const restricted = { ...loginIntent(), allAgents: false, agentIds: [agentId] };
await expect(login.start(companyId, owner, restricted)).rejects.toThrow("Another sign-in");
const retry = await login.start(companyId, owner, restricted, true);
expect(retry.sessionId).not.toBe(first.sessionId);
const [old] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, first.sessionId));
expect(old.status).toBe("cancelled");
await expect(readFile(path.join(directoryFor(first.sessionId), "config.toml"))).rejects.toHaveProperty("code", "ENOENT");
expect(await login.start(companyId, owner, restricted)).toEqual(retry);
await login.cancel(companyId, owner, retry.sessionId);
});
it("blocks preview-era copied subscriptions until isolated reconnect, leaving legacy config intact", async () => {
const service = aiConnectionService(db);
const account = (await service.list(companyId, owner)).find(c => c.provider === "openai")!;
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.id));
await db.update(toolConnections).set({ config: { ...connection.config, aiIsolatedSubscription: false } }).where(eq(toolConnections.id, account.id));
await expect(prepareManagedAiRuntime(db, {
companyId, agentId, adapterType: "codex_local", responsibleUserId: owner,
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { cwd: home },
})).rejects.toThrow("separate sign-in");
expect((await service.list(companyId, owner)).find(c => c.id === account.id)?.status).toBe("needs_attention");
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
expect(grant.status).toBe("active");
});

View File

@ -1462,8 +1462,8 @@ describeEmbeddedPostgres("authorization service", () => {
})).resolves.toMatchObject({ allowed: false, reason: "deny_missing_membership" });
});
it("keeps denying self-gated null-mapped actions for board members", async () => {
const company = await createCompany(db, "BoardWakeDenied");
it("allows legacy member roles to wake agents while rejecting incomplete task mutation scope", async () => {
const company = await createCompany(db, "BoardWake");
const userId = `user-${randomUUID()}`;
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
await db.insert(companyMemberships).values({
@ -1481,8 +1481,8 @@ describeEmbeddedPostgres("authorization service", () => {
action: "agent:wake",
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
})).resolves.toMatchObject({
allowed: false,
reason: "deny_unsupported_action",
allowed: true,
reason: "allow_simple_company_member",
});
const issue = await createIssue(db, company.id, { title: "Wake denied issue" });
await expect(authorization.decide({
@ -1749,6 +1749,25 @@ describeEmbeddedPostgres("authorization service", () => {
});
});
it.each(["session", "cloud_tenant"] as const)("allows %s operators to start agents, without granting hiring rights", async (source) => {
const company = await createCompany(db, "wake");
const agent = await createAgent(db, company.id);
const userId = await createUser(db);
await db.insert(companyMemberships).values({ companyId: company.id,
principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
const auth = authorizationService(db);
const actor = { type: "board" as const, source, userId, companyIds: [company.id] };
const resource = { type: "agent" as const, companyId: company.id, agentId: agent.id };
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: true });
expect(await auth.decide({ actor, action: "agents:create", resource: { type: "company", companyId: company.id } })).toMatchObject({ allowed: false });
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, userId));
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: false });
await db.update(companyMemberships).set({ membershipRole: "operator", status: "suspended" }).where(eq(companyMemberships.principalId, userId));
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: false });
const otherCompany = await createCompany(db, "other-wake");
expect(await auth.decide({ actor, action: "agent:wake", resource: { ...resource, companyId: otherCompany.id } })).toMatchObject({ allowed: false });
});
it("limits viewer members to read-only visibility actions", async () => {
const company = await createCompany(db, "BoardViewerVisibility");
const userId = `user-${randomUUID()}`;

View File

@ -163,6 +163,19 @@ describeEmbeddedPostgres("companySearchService", () => {
return id;
}
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
const companyId = await createCompany();
const agentId = await createAgent(companyId, { name: "Mibile" });
const projectId = await createProject(companyId, { name: "Mibile" });
const taskId = await createIssue(companyId, { title: "Mobile navigation" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile" }));
const ids = result.results.map((row) => row.id);
expect(ids).toContain(taskId);
expect(ids.indexOf(agentId)).toBeLessThan(ids.indexOf(taskId));
expect(ids.indexOf(projectId)).toBeLessThan(ids.indexOf(taskId));
expect((await svc.search(companyId, companySearchQuerySchema.parse({ q: '""' }))).results).toEqual([]);
});
it("ranks exact issue identifiers before weaker title matches", async () => {
const companyId = await createCompany();
const exactId = await createIssue(companyId, {
@ -180,7 +193,7 @@ describeEmbeddedPostgres("companySearchService", () => {
expect(result.results[0]?.matchedFields).toContain("identifier");
});
it("ranks phrase and all-token issue matches before partial scattered-token matches", async () => {
it("ranks phrase before reordered title words and rejects partial matches", async () => {
const companyId = await createCompany();
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
const partialTokenId = await createIssue(companyId, {
@ -201,7 +214,8 @@ describeEmbeddedPostgres("companySearchService", () => {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" }));
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId, partialTokenId]);
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId]);
expect(result.results.map((row) => row.id)).not.toContain(partialTokenId);
});
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
@ -682,6 +696,83 @@ describeEmbeddedPostgres("companySearchService", () => {
}
});
it("does not interpret short UI terms as the middle of unrelated words", async () => {
const companyId = await createCompany();
const target = await createIssue(companyId, { title: "Improve mobile UI" });
await createIssue(companyId, { title: "Build billing reports" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "UI" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps typo fallback inside the requested filters", async () => {
const companyId = await createCompany();
await createIssue(companyId, { title: "Mibile API", status: "done" });
const target = await createIssue(companyId, { title: "Mobile API", status: "todo" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile api", status: "todo" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps exact title hits on the task and chooses the most complete context evidence", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Aurora callback" });
await db.insert(issueComments).values({ companyId, issueId: task, body: "Aurora callback is mentioned here too." });
const exact = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Aurora callback" }));
expect(exact.results[0]?.href).not.toContain("#comment-");
const holder = await createIssue(companyId, { title: "Connection investigation" });
await db.insert(issueComments).values({ companyId, issueId: holder, body: "Aurora was discussed.", updatedAt: new Date("2026-06-01") });
const strongest = randomUUID();
await db.insert(issueComments).values({ id: strongest, companyId, issueId: holder,
body: "Aurora loses the callback state.", updatedAt: new Date("2026-01-01") });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "aurora state callback" }));
expect(result.results[0]?.href).toContain(`#comment-${strongest}`);
expect(result.results[0]?.snippet).toContain("state");
});
it.each(["comment", "document"] as const)("preserves %s evidence when title and identifier both match", async (source) => {
const companyId = await createCompany();
const task = await createIssue(companyId, {
identifier: "CTX-123", title: "CTX callback investigation", description: "CTX callback details",
});
const sourceId = randomUUID();
if (source === "comment") {
await db.insert(issueComments).values({ id: sourceId, companyId, issueId: task, body: "The missing signal is quasar." });
} else {
await db.insert(documents).values({ id: sourceId, companyId, title: "Investigation plan", latestBody: "The missing signal is quasar.", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: sourceId, key: "plan" });
}
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "CTX quasar" }));
const match = result.results.find((row) => row.id === task)!;
expect(match.matchedFields).toEqual(expect.arrayContaining(["identifier", "title", source]));
expect(match.score).toBeGreaterThanOrEqual(2000);
expect(match.score).toBeLessThan(3000);
expect(match.snippets).toHaveLength(2);
expect(match.snippets[0]).toMatchObject({ field: source, text: expect.stringContaining("quasar") });
expect(match.snippet).toContain("quasar");
expect(match.href).toContain(source === "comment" ? `#comment-${sourceId}` : "#document-plan");
});
it("reflects edits, deleted comments and document updates immediately", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Old uniquequartz title" });
const find = () => svc.search(companyId, companySearchQuerySchema.parse({ q: '"uniquequartz"' }));
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issues).set({ title: "New title" }).where(sql`${issues.id} = ${task}`);
expect((await find()).results).toEqual([]);
const comment = randomUUID();
await db.insert(issueComments).values({ id: comment, companyId, issueId: task, body: "uniquequartz" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issueComments).set({ deletedAt: new Date() }).where(sql`${issueComments.id} = ${comment}`);
expect((await find()).results).toEqual([]);
const doc = randomUUID();
await db.insert(documents).values({ id: doc, companyId, title: "Findings", latestBody: "uniquequartz", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: doc, key: "plan" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(documents).set({ latestBody: "Updated findings" }).where(sql`${documents.id} = ${doc}`);
expect((await find()).results).toEqual([]);
});
it("uses pg_trgm for conservative fuzzy title matches", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {

View File

@ -3,6 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
aiConnectionDefaults,
agentWakeupRequests,
issueComments,
companies,
@ -792,4 +793,33 @@ describeEmbeddedPostgres("connectionIntentService", () => {
await expect(service.search(claims, "notion"))
.rejects.toThrow("no longer active");
});
it("keeps runtime authentication requests distinct from the same provider's tool requests", async () => {
const companyId = claims.company_id;
const agentId = randomUUID();
const issueId = randomUUID();
const aiRunId = randomUUID();
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
await db.insert(agents).values({ id: agentId, companyId, name: "AI Agent", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
await db.insert(issues).values({ id: issueId, companyId, title: "AI authentication", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: aiRunId, companyId, agentId, status: "running", responsibleUserId: claims.responsible_user_id, contextSnapshot: { issueId } });
const [app] = await db.insert(toolApplications).values({ companyId, applicationKey: "ai-intent-fixture", name: "AI intent fixture", type: "mcp_http", metadata: { sourceTemplateKey: "anthropic" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: app!.id, name: "Personal Claude API", uid: `ai-${randomUUID()}`, connectionPurpose: "ai", transport: "runtime_auth", authKind: "api_key", credentialPolicy: "per_user", healthStatus: "ok", status: "active", enabled: true, config: { sourceTemplateKey: "anthropic", ai: { provider: "anthropic", method: "api_key" } } }).returning();
const [grant] = await db.insert(connectionGrants).values({ companyId, connectionId: connection!.id, kind: "user", subjectUserId: claims.responsible_user_id, createdByUserId: claims.responsible_user_id }).returning();
await db.insert(aiConnectionDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", method: "api_key", grantId: grant!.id });
const aiClaims = { ...claims, sub: agentId, run_id: aiRunId };
const service = connectionIntentService(db);
const toolRequest = await service.request(aiClaims, "anthropic");
const aiRequest = await service.request(aiClaims, "anthropic", { purpose: "ai" });
expect(aiRequest.state).toBe("needs_user_action");
expect(aiRequest.interactionId).not.toBe(toolRequest.interactionId);
expect((await service.setupOptions(aiRequest.interactionId!)).aiConnection).toEqual(binding);
expect((await service.setupOptions(toolRequest.interactionId!)).existingConnections).toEqual([]);
await expect(service.complete(toolRequest.interactionId!, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy");
await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" });
expect((await service.request(aiClaims, "anthropic", { purpose: "ai" })).state).toBe("ready");
expect((await service.request(aiClaims, "anthropic")).state).toBe("needs_user_action");
expect((await service.search(aiClaims, "openrouter")).results.some(result => result.service === "openrouter")).toBe(false);
});
});

View File

@ -0,0 +1,77 @@
// Authored relevance judgments, independent of the ranker's scoring constants.
export const taskSearchCorpus = [
{ key: "id", identifier: "PAP-42", title: "Repair callback state", status: "done" },
{ key: "id-mention", title: "PAP-42 follow-up discussion" },
{ key: "id-neighbor", identifier: "PAP-420", title: "Repair callback state later" },
{ key: "oauth", title: "Fix GitHub OAuth callback", status: "done" },
{ key: "oauth-noise", title: "GitHub release checklist", comments: ["OAuth is mentioned in an unrelated weekly update."] },
{ key: "oauth-partial", title: "GitHub repository badges" },
{ key: "oauth-body", title: "Repair the connection flow", description: "GitHub OAuth callback loses state on redirect." },
{ key: "oauth-comment", title: "Investigate sign-in", comments: ["The GitHub OAuth callback loses state on redirect."] },
{ key: "oauth-doc", title: "Connection investigation", document: { title: "GitHub OAuth callback findings", body: "State is lost on redirect." } },
{ key: "search", title: "Improve search performance" },
{ key: "search-noise", title: "Improve exports", description: "A search for performance numbers was included in the meeting." },
{ key: "search-partial", title: "Search typography" },
{ key: "mobile", title: "Polish mobile navigation" },
{ key: "mobile-api", title: "Build mobile API" },
{ key: "mobile-ui", title: "Build mobile UI" },
{ key: "onboarding", title: "Onboarding wizard polish" },
{ key: "quoted", title: "Repair connection timeout handling" },
{ key: "quoted-scattered", title: "Connection retry after timeout" },
{ key: "cross", title: "Checkout ownership", comments: ["A concurrency race needs a regression test."] },
{ key: "cross-partial", title: "Checkout style guide" },
{ key: "document", title: "Adapter investigation", document: { title: "Hermes parser plan", body: "Discover plugins from their package manifest." } },
{ key: "percentage", title: "Release 100% checklist" },
{ key: "percentage-decoy", title: "Release 1000 checklist" },
{ key: "path", title: "Fix foo_bar lookup" },
{ key: "path-decoy", title: "Fix fooXbar lookup" },
{ key: "unicode", title: "Réparer navigation mobile" },
{ key: "identifier-code", title: "Document heartbeat_run_events retention" },
{ key: "word", title: "Fix API authentication" },
{ key: "word-decoy", title: "Capistrano migration", description: "API details appear here.", comments: ["API is an incidental mention."] },
{ key: "freshness", title: "Reconcile billing ledger", status: "done" },
{ key: "freshness-noise", title: "Weekly financial update", comments: ["Reconcile billing ledger was one of many completed projects."] },
] as const;
export type TaskSearchCase = {
name: string;
q: string;
relevant: Record<string, number>; // 3 = intended task; 2 = useful; 1 = incidental; absent = irrelevant
first?: string;
absent?: string[];
scope?: "all" | "issues" | "comments" | "documents";
};
export const taskSearchCases: TaskSearchCase[] = [
{ name: "exact identifier", q: "PAP-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "identifier case", q: "pap-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "compact identifier", q: "pap42", relevant: { id: 3, "id-neighbor": 1 }, first: "id" },
{ name: "spaced identifier", q: "PAP 42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "title phrase", q: "GitHub OAuth", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2, "oauth-noise": 1 }, first: "oauth", absent: ["oauth-partial"] },
{ name: "reordered title words", q: "OAuth GitHub callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-partial", "oauth-noise"] },
{ name: "title beats body chatter", q: "performance search", relevant: { search: 3, "search-noise": 1 }, first: "search", absent: ["search-partial"] },
{ name: "title beats incidental comment", q: "billing ledger", relevant: { freshness: 3, "freshness-noise": 1 }, first: "freshness" },
{ name: "filler words", q: "the GitHub OAuth callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-noise"] },
{ name: "quoted phrase", q: '"connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "quoted phrase plus term", q: 'repair "connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "transposition", q: "serach", relevant: { search: 3, "search-partial": 3 } },
{ name: "substitution", q: "mibile navigation", relevant: { mobile: 3, unicode: 3 }, absent: ["mobile-api", "mobile-ui"] },
{ name: "two missing letters", q: "onbordng wizard", relevant: { onboarding: 3 }, first: "onboarding" },
{ name: "short token constrains typo", q: "mibile api", relevant: { "mobile-api": 3 }, first: "mobile-api", absent: ["mobile", "mobile-ui"] },
{ name: "cross-field thread", q: "checkout concurrency", relevant: { cross: 3 }, first: "cross", absent: ["cross-partial"] },
{ name: "document title", q: "Hermes parser", relevant: { document: 3 }, first: "document" },
{ name: "document body", q: "plugins manifest", relevant: { document: 3 }, first: "document" },
{ name: "literal percent", q: "100%", relevant: { percentage: 3 }, first: "percentage", absent: ["percentage-decoy"] },
{ name: "literal underscore", q: "foo_bar", relevant: { path: 3 }, first: "path", absent: ["path-decoy"] },
{ name: "code identifier", q: "heartbeat_run_events", relevant: { "identifier-code": 3 }, first: "identifier-code" },
{ name: "unicode", q: "réparer mobile", relevant: { unicode: 3 }, first: "unicode" },
{ name: "whole word title", q: "api", relevant: { word: 3, "mobile-api": 3, "word-decoy": 1 } },
{ name: "no result", q: "quasarxylophone", relevant: {} },
];
export function searchQualityMetrics(keys: string[], relevant: Record<string, number>) {
const gain = (grade: number, index: number) => (2 ** grade - 1) / Math.log2(index + 2);
const dcg = keys.slice(0, 5).reduce((sum, key, index) => sum + gain(relevant[key] ?? 0, index), 0);
const ideal = Object.values(relevant).sort((a, b) => b - a).slice(0, 5).reduce((sum, grade, index) => sum + gain(grade, index), 0);
const rank = keys.findIndex((key) => relevant[key] === 3);
return { ndcg5: ideal === 0 ? Number(keys.length === 0) : dcg / ideal, reciprocalRank: rank < 0 ? 0 : 1 / (rank + 1) };
}

View File

@ -106,6 +106,7 @@ describe("GET /health dev-server supervisor access", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
localAiLoginSupported: true,
commit: null,
bootstrapStatus: "ready",
bootstrapInviteActive: false,

View File

@ -343,6 +343,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
@ -400,6 +401,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
@ -438,6 +440,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,

View File

@ -149,6 +149,142 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
return { companyId, ownerUserId, agentId };
}
it("dispatches an interrupted queue under the clicking operator through the real startup path", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Interrupted queue", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Continue the task" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", status: "deferred_issue_execution", requestedByActorType: "system",
payload: { issueId, commentId, queuedCommentInterrupt: { actorId: operatorId, requestedAt: new Date().toISOString() },
_paperclipWakeContext: { wakeCommentIds: [commentId], responsibleUserId: ownerUserId,
retryOfRunId: randomUUID(), originIdentityContextId: randomUUID() } },
});
await heartbeat.resumeQueuedCommentInterrupt(companyId, queueId);
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(receipt.status).toBe("coalesced");
const completed = await waitForRun(db, receipt.runId!);
expect(completed).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(completed?.activeIdentityContextId).toBeTruthy();
expect(completed?.contextSnapshot?.originIdentityContextId).toBeUndefined();
expect(completed?.contextSnapshot?.retryOfRunId).toBeUndefined();
expect(mockAdapterExecute).toHaveBeenCalled();
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs.every(run => run.responsibleUserId === operatorId && run.status === "succeeded")).toBe(true);
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0].authorUserId).toBe(ownerUserId);
});
it("keeps a board manual wake under its caller even when it adopts someone else's queue", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Manual wake", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Pending work" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution", requestedByActorType: "user", requestedByActorId: ownerUserId,
payload: { issueId, commentId, _paperclipWakeContext: { wakeCommentIds: [commentId] } },
});
const run = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: operatorId,
contextSnapshot: { responsibleUserId: operatorId } });
expect(run?.responsibleUserId).toBe(operatorId);
const completed = await waitForRun(db, run!.id);
expect(completed).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(completed?.contextSnapshot?.wakeCommentIds).toEqual([commentId]);
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect((await db.select().from(heartbeatRuns)).every(row => row.responsibleUserId === operatorId)).toBe(true);
});
it("keeps the clicking user when a manual wake merges into an older deferred receipt", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Deferred manual wake", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
let finish!: () => void;
const blocked = new Promise<void>(resolve => { finish = resolve; });
const execute = mockAdapterExecute.getMockImplementation()!;
mockAdapterExecute.mockImplementationOnce(async () => { await blocked; return execute(); });
const first = await heartbeat.wakeup(agentId, { payload: { issueId },
requestedByActorType: "user", requestedByActorId: ownerUserId });
try {
await vi.waitFor(() => expect(mockAdapterExecute).toHaveBeenCalled(), { timeout: 5_000 });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Pending work" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "user", requestedByActorId: ownerUserId,
payload: { issueId, commentId, _paperclipWakeContext: { wakeCommentIds: [commentId] } },
});
expect(await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: operatorId,
contextSnapshot: { responsibleUserId: operatorId } })).toBeNull();
const [pending] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(pending).toMatchObject({ requestedByActorType: "user", requestedByActorId: operatorId,
payload: { manualUserWake: true } });
} finally {
finish();
}
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const successors = (await db.select().from(heartbeatRuns)).filter(run => run.id !== first!.id);
expect(successors.length).toBeGreaterThan(0);
expect(successors.every(run => run.responsibleUserId === operatorId && run.status === "succeeded")).toBe(true);
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0].authorUserId).toBe(ownerUserId);
});
it("starts an unscoped manual wake with its own user instead of joining another user's run", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
let finish!: () => void;
const blocked = new Promise<void>(resolve => { finish = resolve; });
const execute = mockAdapterExecute.getMockImplementation()!;
mockAdapterExecute.mockImplementationOnce(async () => { await blocked; return execute(); });
const first = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
requestedByActorType: "user", requestedByActorId: ownerUserId });
let second: Awaited<ReturnType<typeof heartbeat.wakeup>>;
try {
await vi.waitFor(() => expect(mockAdapterExecute).toHaveBeenCalled(), { timeout: 5_000 });
second = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
requestedByActorType: "user", requestedByActorId: operatorId });
expect(second?.id).not.toBe(first!.id);
expect(second?.responsibleUserId).toBe(operatorId);
} finally {
finish();
}
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect(await waitForRun(db, second!.id)).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(await waitForRun(db, first!.id)).toMatchObject({ status: "succeeded", responsibleUserId: ownerUserId });
});
it("denies a manual wake of another user's private conversation", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Private conversation", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId, conversationAgentId: agentId, conversationUserId: ownerUserId, conversationState: "active" });
await expect(heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: "another-user" })).rejects.toThrow("conversation owner");
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
});
it("does not accept a caller-supplied manual-wake authority marker", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual",
requestedByActorType: "agent", requestedByActorId: agentId, payload: { manualUserWake: true },
contextSnapshot: { responsibleUserId: ownerUserId } });
expect((await waitForRun(db, run!.id))?.status).toBe("succeeded");
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, run!.wakeupRequestId!));
expect(wake.payload?.manualUserWake).toBeUndefined();
});
it("uses the issue responsible user for automated dependency wakes without a message context", async () => {
const { companyId, agentId } = await seedCompany();
const issueResponsibleUserId = `issue-owner-${randomUUID()}`;

View File

@ -1638,6 +1638,11 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
originCommentIds: [wakeCommentId],
interactionResolvedAt: new Date().toISOString(), mutation: "interaction", source: `${interactionKind}.resolved`, forceFreshSession: true } });
await heartbeat.resumeQueuedRuns();
expect(await waitForCondition(async () => (await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status === "succeeded")).toBe(true);
// Terminal status precedes completion bookkeeping. Drain those writes before
// afterEach truncates the fixture, otherwise PostgreSQL can deadlock.
await heartbeat.waitForRunExecutionDrain(runId);
expect(countExecuteCallsForRun(runId)).toBe(1);
await waitForCondition(async () => claimedIssue !== null);
expect(claimedIssue).toEqual({ status: "in_progress", executionRunId: runId });
});

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