Merge pinned master into work folders base and reconcile migration 0277
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f5bbc84ee2
|
|
@ -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"/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -161,6 +161,24 @@ Paperclip’s 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 0–3 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.
|
||||
|
|
@ -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 user’s 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
|
||||
|
|
|
|||
28
doc/SPEC.md
28
doc/SPEC.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 request’s 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 operator’s 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
|
||||
attempt’s credential files, never the server operator’s 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 user’s 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 operator’s 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:27–13: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:35–13: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:43–13: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:46–13: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:49–13: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:51–13: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:53–13: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:13–18: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:56–13: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
|
||||
```
|
||||
|
|
@ -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 2–10 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. Photon’s
|
||||
[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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 2–10 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "" };
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
})),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -120,3 +120,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
|
||||
|
||||
export { copyBackCodexAuth } from "./codex-auth-copyback.js";
|
||||
|
|
|
|||
|
|
@ -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 agent’s 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }); } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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')`),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
packages:
|
||||
- '.'
|
||||
|
||||
# The SDK ships generated protobuf code; its optional install script is not needed.
|
||||
allowBuilds:
|
||||
protobufjs: false
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
351
pnpm-lock.yaml
351
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, []));
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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()}`;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue