fix: preserve managed sessions in sandbox work folders

Keep managed account session fingerprints stable across temporary homes and
copy refreshed native Codex credentials through the actual CLI home. Handle
child stdin EPIPE without losing the authoritative exit result. Verify the
existing preview database upgrade and refresh the dedicated runner lock.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 18:38:13 -05:00
parent 3e0dae9952
commit 3bcbd7dab9
14 changed files with 3531 additions and 76 deletions

View File

@ -316,7 +316,7 @@ COPY packages ./packages
COPY server/package.json ./server/package.json
COPY ui/package.json ./ui/package.json
COPY cli/package.json ./cli/package.json
ARG PAPERCLIP_RUNNER_LOCK_SHA256=3d4577e64214452e3c1f65e32c9223cf34d17d5cf84aa4658f0fefa158fe6de5
ARG PAPERCLIP_RUNNER_LOCK_SHA256=6e107822490361b4e46084c98b0c8ac11434bd3388186799d4cf3fe795481e07
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
&& sha256sum -c /tmp/provider-lock.sha256 \
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'

View File

@ -0,0 +1,47 @@
# Work-folder preview migration reconciliation
The work-folder branch previously assigned `0275_sandbox_work_folders` after the
mainline `0274_agent_chat` migration. Mainline subsequently assigned 0275 to
iMessage support and 0276 to AI connections. Both mainline migrations must remain
in the sequence and execute on existing preview databases.
The merged sequence preserves mainline SQL, journal entries and snapshots through
0276. `pnpm --filter @paperclipai/db generate --name sandbox_work_folders` generates
0277 and its snapshot from the complete merged schema. Its six work-folder table
definitions equal the prior generated work-folder snapshot. The reviewed
idempotent work-folder SQL is retained byte-for-byte at the generated 0277 path,
including guards for preview tables, constraints, indexes and missing sync columns.
Its SHA-256 remains
`0c62020d37107e3b2284823c2a297a8dd4b505cbdd3993a0e757c93ac8f3a257`.
No existing journal timestamp or database migration row is rewritten.
The application migrator identifies applied SQL by hash. Consequently a database
that already ran the old 0275 work-folder SQL recognizes the renamed 0277 hash,
while independently applying the missing mainline 0275 and 0276 migrations. An
older preview with the four separate work-folder migrations applies the
idempotent 0277 consolidation as well. A timestamp-only migrator cannot establish
this behavior and is not an acceptable substitute.
`packages/db/src/work-folder-preview-migration.test.ts` reconstructs both the exact
f3c67d50 history and the exact 64814d5 history from pinned SQL hashes and journals.
It seeds legacy/native tasks and provider sessions, files, trash, operation
receipts, pending uploads and unpushed checkpoint references before upgrading.
It checks data and original journal preservation, both new mainline schemas,
six-table schema equivalence, uniqueness/tenant constraints and idempotent replay.
The 64814d5 history is also the migration history used by the later d4ae130 app.
`migration-snapshot-drift.test.ts` independently compares the generated snapshot
with the merged schema.
## Cloud deployment requirement
Cloud's ordered-prefix compatibility guard must not be bypassed. Inserting the
two missing mainline migrations before the renamed work-folder migration changes
the ordered manifest even though the work-folder SQL hash is retained. Before
deployment, add a reviewed, exact-version transition to Cloud's consolidation
catalog for the actual deployed source schema marker and final target package,
including their full manifest fingerprints and required SQL hashes. The current
staging markers are the prior 08dd27e and 64814d5 package versions; an additional
f3/f4-to-target entry is necessary only for a direct upgrade from those versions.
The final target cannot be bound until its candidate revision and artifacts exist.
Snapshot, migration success, authenticated readiness and post-migration data
checks remain mandatory. This document does not authorize deployment or merging.

View File

@ -29,7 +29,7 @@ COPY cli/package.json ./cli/package.json
# The complete resolved lock (including transitive integrity hashes) is reviewed.
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
# Refresh this digest together with source/provider dependency changes.
ARG PAPERCLIP_RUNNER_LOCK_SHA256=3d4577e64214452e3c1f65e32c9223cf34d17d5cf84aa4658f0fefa158fe6de5
ARG PAPERCLIP_RUNNER_LOCK_SHA256=6e107822490361b4e46084c98b0c8ac11434bd3388186799d4cf3fe795481e07
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
&& sha256sum -c /tmp/provider-lock.sha256 \
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'

View File

@ -10,6 +10,8 @@ overrides:
rollup: '>=4.59.0'
react: ^19.2.8
react-dom: ^19.2.8
'@codemirror/state': ^6.7.2
'@codemirror/view': ^6.43.11
patchedDependencies:
'@agentclientprotocol/claude-agent-acp@0.73.0':
@ -624,11 +626,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
@ -889,6 +891,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
@ -940,6 +945,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)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))))
@ -979,12 +987,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
@ -1725,6 +1742,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'}
@ -1856,18 +1876,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'}
@ -2606,6 +2620,51 @@ packages:
'@modelcontextprotocol/sdk':
optional: true
'@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'}
@ -2793,6 +2852,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:
@ -3039,9 +3101,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==}
@ -3544,6 +3603,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:
@ -5572,6 +5646,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'}
@ -5964,6 +6041,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'}
@ -5972,8 +6053,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:
@ -6869,6 +6950,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==}
@ -7302,6 +7387,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==}
@ -7690,6 +7778,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'}
@ -8253,6 +8347,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'}
@ -8671,6 +8769,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==}
@ -9080,6 +9181,10 @@ 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==}
@ -9091,6 +9196,14 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
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'}
@ -9761,6 +9874,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)
@ -9854,22 +9969,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':
@ -9931,8 +10046,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
@ -10082,8 +10197,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
@ -10095,14 +10210,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':
@ -10115,14 +10230,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
@ -10134,13 +10245,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
@ -10709,6 +10813,36 @@ snapshots:
- supports-color
- utf-8-validate
'@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
@ -10863,6 +10997,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)
@ -11188,8 +11324,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': {}
'@mariozechner/clipboard-darwin-arm64@0.3.9':
@ -11687,6 +11821,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
@ -13667,6 +13809,8 @@ snapshots:
'@xterm/xterm@6.0.0': {}
abort-controller-x@0.5.0: {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -14000,6 +14144,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):
@ -14028,8 +14178,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:
@ -14970,6 +15120,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: {}
highlight.js@10.7.3: {}
@ -15339,6 +15498,8 @@ snapshots:
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.includes@4.3.0: {}
@ -15997,6 +16158,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:
@ -16679,6 +16850,8 @@ snapshots:
remend@1.3.1: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
requires-port@1.0.0: {}
@ -17204,6 +17377,8 @@ snapshots:
ts-dedent@2.3.0: {}
ts-error@1.0.6: {}
ts-mixer@6.0.4: {}
tsconfig-paths@4.2.0:
@ -17576,12 +17751,26 @@ snapshots:
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.3:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yjs@13.6.32:
dependencies:
lib0: 0.2.117

View File

@ -618,6 +618,59 @@ describe("runChildProcess", () => {
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
});
it.each([0, 7])(
"preserves exit %i and output when the child closes stdin before an asynchronous write",
async (exitCode) => {
let ready!: () => void;
const stdinClosed = new Promise<void>((resolve) => {
ready = resolve;
});
let spawnCount = 0;
const errors: unknown[] = [];
const result = await runChildProcess(
randomUUID(),
process.execPath,
[
"-e",
[
"require('node:fs').closeSync(0);",
"process.stdout.write('stdin-closed\\n');",
"setTimeout(() => {",
"require('node:fs').writeSync(1, 'final-output');",
"require('node:fs').writeSync(2, 'final-error');",
`process.exit(${exitCode});`,
"}, 100);",
].join(" "),
],
{
cwd: process.cwd(),
env: {},
timeoutSec: 5,
graceSec: 1,
stdin: "input".repeat(1024 * 1024),
onSpawn: async () => {
spawnCount++;
await stdinClosed;
},
onLog: async (stream, chunk) => {
if (stream === "stdout" && chunk.includes("stdin-closed")) ready();
},
onLogError: (error) => {
errors.push(error);
},
},
);
expect(result.exitCode).toBe(exitCode);
expect(result.signal).toBeNull();
expect(result.timedOut).toBe(false);
expect(result.stdout).toBe("stdin-closed\nfinal-output");
expect(result.stderr).toBe("final-error");
expect(spawnCount).toBe(1);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({ code: "EPIPE" });
},
);
it.skipIf(process.platform === "win32")(
"kills descendant processes on timeout via the process group",
async () => {

View File

@ -4751,11 +4751,26 @@ export async function runChildProcess(
const stdin = child.stdin;
if (opts.stdin != null && stdin) {
void spawnPersistPromise.finally(() => {
if (child.killed || stdin.destroyed) return;
stdin.write(opts.stdin as string);
stdin.end();
});
const reportStdinError = (err: unknown) => {
onLogError(err, runId, "failed to write child stdin");
};
// The child can close its input before this asynchronous write drains.
// Keep its exit and output authoritative, but handle the pipe's own
// error event so an early exit cannot crash the parent process.
stdin.on("error", reportStdinError);
void spawnPersistPromise
.then(() => {
if (
child.killed ||
child.exitCode !== null ||
child.signalCode !== null ||
stdin.destroyed
) {
return;
}
stdin.end(opts.stdin as string);
})
.catch(reportStdinError);
}
child.on("error", (err: Error) => {

File diff suppressed because it is too large Load Diff

View File

@ -99,7 +99,10 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
it("upgrades the exact f3c67d50 published history without losing files or provider sessions", async ({ onTestFinished }) => {
it.for([
{ source: "f3c67d50", sourceCommit: "f3c67d50dad32563c7eb5cef1ebae8e83584d4cf", count: 248, removedHashes: 4 },
{ source: "64814d5", sourceCommit: "64814d5a4b1cf9a61e6f0a661856d3c92401fa3b", count: 274, removedHashes: 0 },
])("upgrades the exact $source history without losing files or provider sessions", { timeout: EMBEDDED_POSTGRES_TEST_TIMEOUT_MS }, async ({ source, sourceCommit, count, removedHashes }, { onTestFinished }) => {
const database = await startEmbeddedPostgresTestDatabase("work-folder-historical-");
// Register each cleanup before acquiring the next resource or parsing the
// fixture. Vitest runs these in reverse order even when setup fails.
@ -108,11 +111,11 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
onTestFinished(() => admin.end());
const migrationsFolder = mkdtempSync(path.join(os.tmpdir(), "work-folder-f3-history-"));
onTestFinished(() => rmSync(migrationsFolder, { recursive: true, force: true }));
const historyRoot = new URL("./__fixtures__/work-folders-f3c67d50/", import.meta.url);
const historyRoot = new URL(`./__fixtures__/work-folders-${source}/`, import.meta.url);
const history = JSON.parse(readFileSync(new URL("history.json", historyRoot), "utf8")) as {
sourceCommit: string;
journal: { entries: { tag: string; when: number }[] };
files: { name: string; sha256: string }[];
files: { name: string; sha256: string; currentFile?: string }[];
};
const historicalUrl = new URL(database.connectionString);
historicalUrl.pathname = "/historical_preview";
@ -121,32 +124,32 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
// The cluster helper's regular database is intentionally not the upgrade
// subject: this separate database has never seen the current schema.
await admin`CREATE DATABASE historical_preview`;
expect(history.sourceCommit).toBe("f3c67d50dad32563c7eb5cef1ebae8e83584d4cf");
expect(history.files).toHaveLength(248);
expect(history.journal.entries).toHaveLength(248);
expect(history.sourceCommit).toBe(sourceCommit);
expect(history.files).toHaveLength(count);
expect(history.journal.entries).toHaveLength(count);
mkdirSync(path.join(migrationsFolder, "meta"));
writeFileSync(path.join(migrationsFolder, "meta/_journal.json"), JSON.stringify(history.journal));
for (const file of history.files) {
const historicalFile = new URL(file.name, historyRoot);
const source = existsSync(historicalFile) ? historicalFile : new URL(`./migrations/${file.name}`, import.meta.url);
const bytes = readFileSync(source);
// The 244 unchanged files are shared, but every byte is pinned to the
// old package. The four removed preview migrations are verbatim fixtures.
const sourceFile = existsSync(historicalFile) ? historicalFile : new URL(`./migrations/${file.currentFile ?? file.name}`, import.meta.url);
const bytes = readFileSync(sourceFile);
// Shared SQL, including the renumbered idempotent work-folder migration,
// must match the exact old bytes. Removed SQL remains in the fixture.
expect(createHash("sha256").update(bytes).digest("hex"), file.name).toBe(file.sha256);
writeFileSync(path.join(migrationsFolder, file.name), bytes);
}
await migrate(drizzle(sql), { migrationsFolder });
const journalBefore = [...await sql`SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`];
expect(journalBefore).toHaveLength(248);
expect(journalBefore).toHaveLength(count);
expect(journalBefore.map(row => ({ hash: row.hash, created_at: String(row.created_at) }))).toEqual(
history.journal.entries.map(entry => ({
hash: history.files.find(file => file.name === `${entry.tag}.sql`)!.sha256,
created_at: String(entry.when),
})),
);
expect(await sql`SELECT to_regclass('public.email_messages') AS name`).toEqual([{ name: null }]);
expect(await sql`SELECT to_regclass('public.email_messages') AS name`).toEqual([{ name: source === "f3c67d50" ? null : "email_messages" }]);
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'heartbeat_runs'
AND column_name = 'controller_boot_id'`).toHaveLength(0);
AND column_name = 'controller_boot_id'`).toHaveLength(source === "f3c67d50" ? 0 : 1);
const company = randomUUID(), otherCompany = randomUUID(), project = randomUUID(), environment = randomUUID();
const responsibleUser = "paperclip-id:historical-preview-user";
@ -214,7 +217,11 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
const pending = await inspectMigrations(historicalUrl.toString());
expect(pending.status).toBe("needsMigrations");
if (pending.status !== "needsMigrations") throw new Error("Historical preview unexpectedly has current migrations");
expect(pending.pendingMigrations).toContain("0277_sandbox_work_folders.sql");
expect(pending.pendingMigrations).toEqual(expect.arrayContaining([
"0275_easy_dragon_man.sql", "0276_hard_mandroid.sql",
]));
if (source === "f3c67d50") expect(pending.pendingMigrations).toContain("0277_sandbox_work_folders.sql");
else expect(pending.pendingMigrations).not.toContain("0277_sandbox_work_folders.sql");
await applyPendingMigrations(historicalUrl.toString());
expect((await inspectMigrations(historicalUrl.toString())).status).toBe("upToDate");
for (const table of preservedTables) {
@ -225,6 +232,12 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
expect(await sql`SELECT to_regclass('public.email_messages') AS name`).toEqual([{ name: "email_messages" }]);
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'heartbeat_runs'
AND column_name IN ('controller_boot_id', 'controller_lease_expires_at', 'execution_stage')`).toHaveLength(3);
// Both mainline migrations must execute even when the saved preview has a
// later work-folder timestamp. A schema marker alone is not evidence.
expect(await sql`SELECT to_regclass('public.ai_connection_defaults') AS name`).toEqual([{ name: "ai_connection_defaults" }]);
expect(await sql`SELECT to_regclass('public.chat_endpoints_photon_number_uq') AS name`).toEqual([{ name: "chat_endpoints_photon_number_uq" }]);
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'adapter_auth_sessions'
AND column_name IN ('ai_connection', 'connection_id', 'connection_grant_id', 'connection_method')`).toHaveLength(4);
// Compare the complete six-table catalog with the independently migrated
// current database, ignoring physical column order from historical ADDs.
async function workFolderSchema(connection: typeof sql) {
@ -246,7 +259,7 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
}
expect(await workFolderSchema(sql)).toEqual(await workFolderSchema(admin));
const journalAfter = [...await sql`SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`];
expect(journalAfter).toHaveLength(pending.availableMigrations.length + 4);
expect(journalAfter).toHaveLength(pending.availableMigrations.length + removedHashes);
expect(journalAfter.slice(0, journalBefore.length)).toEqual(journalBefore);
expect(new Set(journalAfter.map(row => row.hash)).size).toBe(journalAfter.length);
for (const hash of history.files.slice(-4).map(file => file.sha256)) expect(journalAfter.filter(row => row.hash === hash)).toHaveLength(1);
@ -264,6 +277,6 @@ const migration = readFileSync(new URL("./migrations/0277_sandbox_work_folders.s
} finally {
await sql.end();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
});
});

View File

@ -12,7 +12,8 @@ import { createDb, companies, agents, heartbeatRuns, companyMemberships, connect
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 { prepareManagedAiRuntime, assertManagedAiProjectAuth, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js";
import { buildEffectiveRunSessionConfigMetadata } from "../services/heartbeat.js";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
@ -130,6 +131,34 @@ describe("managed AI connections", () => {
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("keeps managed task sessions across temporary homes while detecting account and configuration changes", async () => {
const config = { model: "unchanged-model", env: { PLAIN_FLAG: "first" } };
const runtimes = await Promise.all(["alice", "alice", "bob"].map(responsibleUserId =>
prepareManagedAiRuntime(db, { ...input, responsibleUserId, config })));
try {
const fingerprint = async (runtime: typeof runtimes[number], effectiveConfig = runtime.config, managed = true) =>
(await buildEffectiveRunSessionConfigMetadata({
adapterType: input.adapterType,
effectiveAdapterConfig: managedAiSessionFingerprintConfig(effectiveConfig, managed ? runtime : undefined),
agentRuntimeConfig: { aiConnection: binding },
issueOverrides: null, workspaceConfig: null, environment: null,
environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [],
})).fingerprint;
const [first, next, otherUser] = runtimes;
expect(first.config.env.HOME).not.toBe(next.config.env.HOME);
expect(first.identity).toBe(next.identity);
expect(await fingerprint(first)).toBe(await fingerprint(next));
expect(await fingerprint(first)).not.toBe(await fingerprint(otherUser));
expect(await fingerprint(next, { ...next.config, env: { ...next.config.env, PLAIN_FLAG: "changed" } })).not.toBe(await fingerprint(first));
expect(await fingerprint(next, { ...next.config, model: "changed-model" })).not.toBe(await fingerprint(first));
// Homes supplied by an ordinary local/SSH configuration remain meaningful.
expect(await fingerprint(first, first.config, false)).not.toBe(await fingerprint(next, next.config, false));
expect(first.config.env.HOME).toBeTruthy();
expect(next.config.env.CODEX_HOME).toBeTruthy();
} finally {
await Promise.all(runtimes.map(runtime => runtime.cleanup()));
}
});
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 } });

View File

@ -60,6 +60,24 @@ export function stripAiAuthBindings(env: unknown): Record<string, unknown> {
delete result[key];
return result;
}
/** Compare managed credentials by account identity, not their per-run staging directory. */
export function managedAiSessionFingerprintConfig(
config: Record<string, unknown>,
runtime: { config: { env: Record<string, unknown> } } | undefined,
): Record<string, unknown> {
if (!runtime) return config;
const env = { ...(config.env as Record<string, unknown> | undefined) };
// Only the host's exact generated paths are omitted. Ordinary home settings,
// provider routing, credentials, and the managed account identity still count.
for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "CODEX_HOME", "GROK_HOME", "CLAUDE_CONFIG_DIR"]) {
if (typeof runtime.config.env[key] === "string" && env[key] === runtime.config.env[key]) {
delete env[key];
}
}
return { ...config, env };
}
export async function assertManagedAiProjectAuth(
config: Record<string, unknown>,
provider: AiConnectionBinding["provider"],

View File

@ -9,7 +9,7 @@ import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminati
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
import { connectionIntentService } from "./connection-intents.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, AI_AUTH_ENV_KEYS, managedAiSessionFingerprintConfig } from "./ai-connection-runtime.js";
import { aiConnectionBindingSchema } from "@paperclipai/shared";
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
@ -20896,7 +20896,7 @@ export function heartbeatService(
const sessionConfigMetadata =
await measureSandboxOperation("heartbeat.build_effective_run_session_config_metadata", { operationIndex: 61 }, async () => (buildEffectiveRunSessionConfigMetadata({
adapterType: agent.adapterType,
effectiveAdapterConfig: runtimeConfig,
effectiveAdapterConfig: managedAiSessionFingerprintConfig(runtimeConfig, managedAiRuntime),
agentRuntimeConfig: agent.runtimeConfig,
issueOverrides: issueAssigneeOverrides,
workspaceConfig: {

View File

@ -9831,6 +9831,63 @@ describe("runnerd provider runtime wiring", () => {
}));
});
it.each(["/home/daytona", undefined])(
"copies refreshed managed Codex auth from the actual remote CLI home (%s) and removes that copy",
async (workFolderHome) => {
const credentialHome = join(isolatedStateDirectory, "managed-credential");
await mkdir(credentialHome);
const auth = (refresh: string) => JSON.stringify({
tokens: { account_id: "same-account", id_token: "test-id",
access_token: `test-access-${refresh}`, refresh_token: `test-refresh-${refresh}` },
last_refresh: refresh,
});
const original = auth("2026-01-01T00:00:00Z");
const refreshed = auth("2026-01-02T00:00:00Z");
const hostAuth = join(credentialHome, "auth.json");
await writeFile(hostAuth, original, { mode: 0o600 });
let remoteAuthPath = "";
const operations: string[] = [];
const close = vi.fn(async () => { operations.push("close"); });
state.createBackend.mockReturnValueOnce({
kind: "test", openSession: async () => ({ close }),
} as never);
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
if (command.command === "base64") {
operations.push("read");
return { exitCode: command.args?.[0] === remoteAuthPath ? 0 : 1,
stdout: Buffer.from(refreshed).toString("base64"), stderr: "", timedOut: false };
}
if (command.command === "rm") operations.push("remove");
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
});
const backend = await createRunnerdBackend({
db: leaseDb(execution), execution, runnerInstanceId: "managed-auth-runner",
managedAiCredentialHome: credentialHome,
runnerExecutionTarget: {
kind: "remote", transport: "sandbox", remoteCwd: "/home/daytona/repos/main",
workFolderHome, environmentId: "environment", leaseId: "lease", providerKey: "daytona",
runner: { execute: remoteExecute, syncIn: vi.fn() },
} as never,
runnerPublicUrl: "wss://paperclip.example.test",
});
const providerEnvironment = state.createBackend.mock.calls.at(-1)![1].environment!;
remoteAuthPath = `${providerEnvironment.CODEX_HOME}/auth.json`;
if (workFolderHome) {
expect(providerEnvironment.HOME).toBe(workFolderHome);
expect(remoteAuthPath).toBe(`${workFolderHome}/.codex/auth.json`);
}
const session = await backend.openSession({} as never);
await session.close({} as never);
expect(await readFile(hostAuth, "utf8")).toBe(refreshed);
expect((await lstat(hostAuth)).mode & 0o777).toBe(0o600);
expect(operations).toEqual(["close", "read", "remove"]);
expect(remoteExecute).toHaveBeenCalledWith(expect.objectContaining({ command: "base64", args: [remoteAuthPath] }));
expect(remoteExecute).toHaveBeenCalledWith(expect.objectContaining({ command: "rm", args: ["-f", "--", remoteAuthPath] }));
await session.close({} as never);
expect(remoteExecute).toHaveBeenCalledTimes(2);
},
);
it("uses the image's shared Codex without uploading or installing artifacts", async () => {
const syncIn = vi.fn(async () => undefined);
const remoteExecute = vi.fn(

View File

@ -10133,6 +10133,13 @@ async function createRunnerdBackendWithinSessionClaim(
const remoteRunnerFilesystemRoot = remoteSessionRoot
? posix.join(remoteSessionRoot, "filesystem")
: null;
// Credential copy-back must use the same CLI home as launch and persistence.
const remoteCodexHome =
remoteTarget?.transport === "sandbox" && remoteTarget.workFolderHome
? posix.join(remoteTarget.workFolderHome, ".codex")
: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, "codex-home")
: null;
const persistenceProfile = resolveNativeHarnessPersistenceProfile(
input.execution,
);
@ -10143,8 +10150,8 @@ async function createRunnerdBackendWithinSessionClaim(
const remotePersistencePath = (
directory: NativeHarnessPersistenceDirectory,
): string | null =>
remoteTarget?.transport === "sandbox" && remoteTarget.workFolderHome && directory.name === "codex-home"
? posix.join(remoteTarget.workFolderHome, ".codex")
directory.name === "codex-home"
? remoteCodexHome
: directory.location === "runner"
? (remoteStateDirectory ?? null)
: remoteRunnerFilesystemRoot
@ -11475,7 +11482,7 @@ async function createRunnerdBackendWithinSessionClaim(
// permission profile. SSH retains its isolated provider home so its
// host-home deny rules cannot shadow the assigned workspace.
HOME: remoteTarget!.transport === "sandbox" && remoteTarget!.workFolderHome ? remoteTarget!.workFolderHome : posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
CODEX_HOME: remoteTarget!.transport === "sandbox" && remoteTarget!.workFolderHome ? posix.join(remoteTarget!.workFolderHome, ".codex") : posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
CODEX_HOME: remoteCodexHome!,
PAPERCLIP_WORKSPACE_CWD: remoteTarget!.remoteCwd,
...(remoteTarget!.transport === "sandbox"
? { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }
@ -12026,7 +12033,7 @@ async function createRunnerdBackendWithinSessionClaim(
await close(closeInput);
if (copied) return;
copied = true;
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null;
const remoteAuth = remoteCodexHome ? posix.join(remoteCodexHome, "auth.json") : null;
const localAuth = join(root, "codex-home", "auth.json");
try {
await copyBackCodexAuth({

View File

@ -1541,6 +1541,7 @@ test.describe.serial("native chat adapter UI", () => {
).toBeVisible();
await expectSetupRail(page);
await selectMaya(page);
// The selection schedules a request; clicking alone does not await it.
await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId);
expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId);
await expect(