Skip to content

chore: import relay VFS DOM semantic fuse and auto-skill discovery order from PekingSpades fork - #2

Merged
PekingSpades merged 2 commits into
mainfrom
chore/import-relay-vfs-and-skill-order
May 15, 2026
Merged

PekingSpades merged 2 commits into
mainfrom
chore/import-relay-vfs-and-skill-order

Conversation

@PekingSpades

Copy link
Copy Markdown
Collaborator

Summary

Imports two upstream branches from the PekingSpades fork as squashed commits.

feat/relay-vfs-dom-semantic-fuse (4 source commits, squashed)

  • Harden remote agent delivery and participant handling
  • refactor: centralize business enum usage
  • Add relay VFS browser/CUA mount support
  • Standardize relay VFS validation flow

Source: https://github.com/PekingSpades/synapse/tree/feat/relay-vfs-dom-semantic-fuse

fix/relay-auto-skill-discovery-order (1 source commit, squashed)

  • fix: prioritize relay auto-loaded skills in discovery

Source: https://github.com/PekingSpades/synapse/tree/fix/relay-auto-skill-discovery-order

Test plan

  • npm run build
  • npm run format:check
  • Spot-check relay VFS browser/CUA mount in a relay test environment
  • Spot-check skill discovery ordering in API

Squashed import of 4 commits ahead of PekingSpades/synapse:main:
  ddaae82 Harden remote agent delivery and participant handling
  be3a433 refactor: centralize business enum usage
  628d1a7 Add relay VFS browser/CUA mount support
  fdf34c9 Standardize relay VFS validation flow

Method: pre-formatted both peking/main and peking/feat with
zai-org's prettier+gofmt style, diffed the formatted trees, then
applied with git apply --3way to suppress format-only conflicts.

Resolved one semantic conflict in
packages/web-next/app/dashboard/settings/model-group-detail.tsx
(zai-org renamed strategyLabel to a local helper; kept the new
shared model-group-shared imports from the fork).

Source: https://github.com/PekingSpades/synapse/tree/feat/relay-vfs-dom-semantic-fuse
…fork

Squashed import of 1 commit ahead of PekingSpades/synapse:main:
  9ce134a fix: prioritize relay auto-loaded skills in discovery

Method: pre-formatted both peking/main and peking/fix versions
of the 5 skill files with zai-org's prettier+gofmt style, diffed
the formatted trees scoped to those files, then applied with
git apply --3way. No conflicts.

Source: https://github.com/PekingSpades/synapse/tree/fix/relay-auto-skill-discovery-order
@PekingSpades
PekingSpades merged commit d338a4b into main May 15, 2026
8 of 18 checks passed
@PekingSpades
PekingSpades deleted the chore/import-relay-vfs-and-skill-order branch May 19, 2026 02:20
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…ed IP

Review follow-ups zai-org#1 and zai-org#2:

zai-org#1 — downloadToBuffer (the generic entry used by saveFromUrl, and thus by MCP
image results, Zhipu image/parse URLs, files/ingest) had NO SSRF check and read
the whole body unbounded (arrayBuffer). It now delegates to
downloadToBufferWithLimit with a 50 MiB default cap, so every caller gets the
SSRF host check + streaming size cap.

zai-org#2 — assertPublicHost was pre-flight only: fetch re-resolved the hostname at
connect time, leaving a DNS-rebinding window. Add ssrfSafeDispatcher() — an
undici Agent whose connect-time lookup re-validates EVERY resolved address and
refuses non-public ones, so the address that passes the check is the one the
socket connects to. downloadToBufferWithLimit now fetches through it (plain
Agent when allowPrivateHosts is set, e.g. tests/internal). undici declared as a
direct api dep.

Tests: +2 (generic entry enforces SSRF; hostname→loopback blocked at connect);
storage 10 + ssrf 8 green.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
Review follow-up zai-org#2: the redirect branch did `await res.arrayBuffer()` to
drain a 3xx body BEFORE the final response's Content-Length/streaming maxBytes
checks — so a malicious server could attach a huge body to a 302 and blow past
maxBytes. Switch to res.body?.cancel(), which frees the stream/connection
without reading the bytes.

Test: a 5 MiB redirect body with maxBytes=10 now succeeds on the small final
body (proving the redirect body is never buffered/counted). storage 11 green.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…ross side-effects

Two more lock-correctness defects from review:

zai-org#1 STRANDED WAKEUPS ON ACQUIRE-SKIP: session-thinking jobs are enqueued with no
BullMQ attempts, so a job that finds the session lock held just returns
"session locked" = done, no retry. Combined with the lock-loss restore path
(restore wakeups -> pending -> requeue), the requeued job could hit the new
owner's lock and skip, while that owner had already read 0 pending and gone
idle -> wakeups stranded with no driver. Fix: on acquire-skip, if there are
pending wakeups, re-enqueue a DELAYED "think" job (2s) so it re-claims once the
current owner releases. Eventually-consistent: it keeps retrying until a worker
holds the lock and drains the pending wakeups.

zai-org#2 RENEW DIDN'T COVER SIDE-EFFECTS: the fenced lock-renew interval was cleared
right after actorThink, but executeActorActions + message persistence +
turn/wakeup status + runtime/audit/event emits all run AFTER that, until the
outer finally releases. A side-effect phase longer than the 120s TTL could let
another worker concurrently take over. Fix: hoist the interval and clear it in
the OUTER finally instead, so the fenced renew covers the whole critical
section (model + all side-effects) until release.

api builds clean; full api suite 1296/1296 green.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…ueue error

Review follow-ups zai-org#1 and zai-org#2:

zai-org#1 SIDE-EFFECTS WROTE AFTER LOCK LOSS: lockLost was checked once right after
actorThink, but executeActorActions, message persistence, turn/wakeup status,
audit/event, and the terminal idle/queued writes all ran afterward without
re-checking — so a lock lost DURING the side-effect phase still let the old
worker complete writes, racing the new owner. Add assertStillHoldLock() and
call it before executeActorActions, before message persistence, before
markTurnWakeupsProcessed/updateTurnStatus(completed), and before the terminal
status write. Each throws SessionLockLostError → catch restores wakeups +
requeues. (The renew now spans the whole critical section, so loss mid-side-
effect is unlikely; these checks bound the residual double-write window.)

zai-org#2 DISHONEST retryScheduled: the acquire-skip delayed re-enqueue swallowed a
queue.add() failure yet still returned retryScheduled:true — monitoring would
see "scheduled" while the wakeups were actually stranded. Let the add() throw
(the job then fails visibly / is retried) since that re-enqueue is the only
driver for the pending wakeups.

api builds clean; full api suite 1298/1298 green. Renew-returns-false-on-
takeover (the mechanism that sets lockLost) is covered by lock.test FENCING.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…nger hangs

Four review follow-ups on the lock-loss recovery:

zai-org#1 — replay flag set too early: sideEffectsStarted flipped true before
executeActorActions REGARDLESS of result.actions, so a pure-reasoning turn (no
actions, no message — executeActorActions is a no-op, persistence kind:"none")
was treated as replay-unsafe and its input wakeups were DROPPED on a lock loss.
Rename to replayUnsafeStarted and set it true only when a non-idempotent write
actually happens: result.actions.length > 0, or messagePersistence.kind !==
"none".

zai-org#2 — late independent wakeups stranded: the replay-unsafe branch skipped the
pending-wakeup check, so a message that arrived mid-turn (never attached to
this turn, and enqueueSessionWakeup won't enqueue a job while running) had no
driver. Now BOTH branches re-check getPendingWakeupCount after the restore/drop
and requeue if any remain. This never replays the current turn: in the unsafe
case its own wakeups were dropped (not pending), so only genuinely-unprocessed
input drives the re-run.

zai-org#3 — concurrency test hung (exit 124): it materialized the ioredis singleton
but never closed it. Add an `after()` hook calling shutdownRedisConnections().
Verified it now exits 0 even WITHOUT --test-force-exit.

zai-org#4 — test didn't cover the real decision: extract decideLockLossRecovery()
(pure: replayUnsafe → restore|drop, requeue iff pending remains) and route the
handler through it, then drive the concurrency test through that same policy.
Added the two regressions above as explicit cases (pure-reasoning replay-safe;
replay-unsafe-but-late-pending requeues).

api typecheck clean; full api suite 1370 pass / 0 fail / 25 skipped.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…s, auth-origin plumbing

High zai-org#1 (native email login/register session): the custom synapse_session cookie
name broke @better-auth/expo's cookie-jar (it only persists cookies matching its
prefix or *session_token). Revert to BA's DEFAULT cookie name
(better-auth.session_token) — no business code reads the cookie by name anymore.
getSessionBearerToken() now matches by endsWith('session_token') (handles the
__Secure- prefix). session-provider also prefers signIn/signUp data.token, with
the cookie-jar value as fallback.

High zai-org#2 (native Feishu OAuth return): refreshSession() now falls back to the
expo cookie-jar token (getSessionBearerToken) when SESSION_TOKEN_KEY is empty
and persists it — so a deep-link return from the OAuth browser flow establishes
the session.

Medium zai-org#3 (web device polling): pollDeviceToken() unwraps the RFC 8628 HTTP-400
error body (authorization_pending/slow_down/access_denied/expired_token) from
the thrown ApiError.details and returns { error } instead of throwing, so the QR
panel's normal-wait and terminal branches work.

Low zai-org#4 (mobile auth-origin plumbing): EXPO_PUBLIC_AUTH_ORIGIN wired through
setup.sh + docker-compose mobile-web build args + Dockerfile.mobile-web
(optional; defaults to API origin for same-origin deploys).

Verified: server emits better-auth.session_token + set-auth-token + body token;
simulated expo cookie-jar capture + bearer resolution succeed; api 1384/0/25;
web typecheck 0; integration 20/20; mobile typecheck clean.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…evice-poll error unwrap

Medium zai-org#1: setup.sh only wrote AUTH_TRUSTED_ORIGINS in the new-.env template, so
an existing .env (pre-feature or customized) never gained $APP_URL + synapse://,
leaving the server's trustedOrigins empty -> @better-auth/expo can't return the
session on a native Feishu OAuth deep-link callback. Added ensure_csv_env_contains
(merge, not overwrite — preserves operator-added origins, no dup) and call it for
AUTH_TRUSTED_ORIGINS in the upsert region. Verified the helper across missing/
merge/noop cases.

Low zai-org#2: pollDeviceToken() only unwraps HTTP-400 responses whose body.error is a
known RFC8628 device error (authorization_pending|slow_down|expired_token|
access_denied|invalid_grant); 500/502/proxy/other errors now re-throw so the QR
panel surfaces a real failure instead of masking it as 'expired'.

web typecheck 0; setup.sh syntax OK.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…ypes/markdown-it

Finding zai-org#2 (prod-install reliability): @better-auth/expo's @better-auth/core
peer was auto-resolving to the hoisted 1.6.14 while better-auth@1.6.13 nests an
exact 1.6.13, so the Dockerfile prod path (npm ci --legacy-peer-deps
--workspace=packages/api ...) could fail to find a consistent core.
 - root: add @better-auth/core@1.6.13 override + as a direct api dependency, so
   it lands at packages/api/node_modules/@better-auth/core in the prod layout.
 - mobile (separate lockfile): add @better-auth/core@1.6.13 dep + override so the
   hoisted copy is 1.6.13 (was 1.6.14). Plain 'npm install' (not --legacy-peer-deps)
   so nativewind's tailwindcss peer tree is preserved.
 Verified: clean Dockerfile-path npm ci resolves core@1.6.13 at
 packages/api/node_modules; betterAuth/bearer/genericOAuth/deviceAuthorization/
 expo/getOAuth2Tokens all import; mobile expo->core@1.6.13 + subpaths resolve.

Finding zai-org#3 (mobile typecheck): markdown-it@14 ships no types and @types/markdown-it
was absent (pre-existing on dev, surfaced via chat-markdown-html.ts). Add
@types/markdown-it@^14.1.2 (devDep). Mobile 'tsc --noEmit' now green.

Verification after merging dev 3aea085 into the branch:
 db:rebuild + db:codegen:verify clean; api typecheck 0; guard:db OK (566 files);
 access/authz tests 191/191; full api suite 1475/1475; web build compiles;
 mobile typecheck 0.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…e contracts

Round-2 review: the shared schemas existed but weren't the runtime contract,
and the devices write-path/cloud + service-shaping were still in the old world.

zai-org#1/zai-org#2 devices WRITE-path → shared camelCase (master plan §5.1.1/§8.3):
- shared/schemas/devices.ts gains app-facing input contracts (camelCase):
  CreateCloudDeviceInput, StartPairingInput, ClaimDaemonServiceInput,
  SetActiveDeviceCapabilitiesInput + CreateCloudDeviceResultView /
  ActiveDeviceCapabilitiesView.
- controller parses these; device-sdk + web send camelCase bodies
  (device_type→deviceType, service_kind→serviceKind,
  remote_agent_machine_id→remoteAgentMachineId,
  device_capability_ids→deviceCapabilityIds, workspace_id→workspaceId,
  host_provider→hostProvider).
- access-bindings POST now on the shared SetActiveDeviceCapabilitiesInput
  (the wire-shape test reframed to the app-facing contract); GET list key →
  deviceCapabilityIds.
- cloud.ts result → camelCase; sandbox docker-backend + its test updated.
- device-runtime startPairingSession consumes the shared camelCase
  StartPairingInput + DevicePairingTicketView.
- removed the now-orphaned device-protocol input/result schemas
  (CreateCloudDeviceInput/Result, StartPairingInput, PairingTicket,
  ClaimDaemonInput, SetActiveDeviceCapabilitiesInput); kept the genuine wire
  ConsumePairing*/ScopedSubjectTargetWire.

zai-org#3 model-groups + mcp-plugins controllers → sendData(ViewSchema, value):
- 30 model-groups + 8 mcp-plugins sites now parse the shared View schemas at
  runtime (the schemas were "dead definitions"). Envelope → { data }.
- updateModelGroup always returns ModelGroupDetailView (single schema for PUT).
- web lib/api.ts unwraps { data } in-method, keeping each method's public
  shape stable so callers are unchanged. Endpoints without a View schema
  (auth-session/audit/install-plan) deliberately left as-is.

zai-org#5 devices service no longer shapes DTOs: listDevices/getDevice/startPairing/
claimRemoteAgentDaemon return domain records (DeviceSummaryRecord/
DeviceDetailRecord/DevicePairingTicketRecord/DeviceServiceRecord); the
controller calls the presenter. Added presentDevicePairingTicket.

zai-org#4 mcp-plugins schemas: z.custom already gone (r1); documented the genuinely
plugin-author-owned opaque JSONB (configSchema/configData/toolsManifest/
metadata) — the structured fields are already nested-schema'd.

zai-org#7 device-sdk tests: added src/index.test.ts (0 → 8 tests) locking the
camelCase send/parse boundary + the snake wire consume shape; build moved to
tsconfig.build.json so tests stay out of dist.

Verification: full monorepo build green; api 1640 pass/0 fail/2 skip; guards
(layering/db/fk/soft-delete/datetime) clean; device-sdk 8/8, device-runtime
314/315(+1 skip), remote-agent-daemon 24/24; web + mobile typecheck clean.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…ervice→presenter retreat

Round-3 review: app-facing endpoints still on bare reply.send, /auth/me unmigrated,
mcp-plugins auxiliary flows uncollected, and service still produced outward Views.

zai-org#1 devices controller → sendData { data } (§8.3): list/detail/pairing/claim/cloud
now sendData(ViewSchema, ...) → { data: ... }. device-sdk read methods unwrap
res.data + parse the shared view; device-sdk tests updated to { data } fixtures.
204 endpoints (delete/detach) unchanged; consume/bootstrap stay bare wire.

zai-org#2 access-bindings GET → sendData(ActiveDeviceCapabilitiesViewSchema, ...) →
{ data: { deviceCapabilityIds } }. POST stays 204 on the shared input contract.

zai-org#3 mcp-plugins auxiliary endpoints collected to sendData with new shared schemas
(PluginAuthSessionView/Envelope, PluginInstallPlanView/Envelope, PluginAuditLogList):
install-plan, auth-session start/get/inspect, audit tool-calls/events. Only the
OAuth HTML callback + { error } envelopes remain bare. install-dialog unwraps
res.data for the auth-session methods.

zai-org#4 /auth/me GET+PUT → sendData(AuthMeViewSchema, { user, session }) → { data }.
New shared schemas/auth.ts (UserProfileView/AuthSessionSummaryView/AuthMeView).
web lib/api.ts getMe/updateMe + lib/server-auth.ts read res.data.{user,session}.

zai-org#5 service→presenter retreat (controller owns presentation) across 11 app-facing
modules (organization/files/relationship/memory/remote-agents/workspace/
workspace-apps/automation/chat/model-groups/mcp-plugins): service.ts now returns
DOMAIN RECORDS; the controller calls present*() before each send (envelopes
unchanged). present*() in service.ts: 106 → 65 (remainder are nested-record
assembly + cross-module-consumed records + Tier-D timestamp wrappers, permitted
by the layer-matrix JUDGMENT clause). New record types in repo.types/presenter
per module. Fixed 2 new r7 (return { ...row }) violations the retreat introduced
(remote-agents/workspace-apps) via const-assignment.

Tier-D / non-HTTP modules (session/context/tasks/tool-call-tasks) excluded per
plan ("不强制 presenter").

Verification: full monorepo build green; api 1640 pass/0 fail/2 skip; guards
(layering/db/fk/soft-delete/datetime) clean; device-sdk 8/8, device-runtime
314/0fail, remote-agent-daemon 24/24; web + mobile typecheck clean.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
… guard close

Round-4 review: cloud-input schema stricter than server; relationship/remote-agents
controllers still bare; access-bindings half-migrated; TableRow leaked outside repo
files (guard couldn't prove repo-only DB access); §5.3 split-controller/appRoute
mechanism + guard rule 5 entirely unadopted across mixed modules.

zai-org#1 CreateCloudDeviceInputSchema: title/hostProvider → optional (the server
applies defaults; the strict schema would reject requests the server accepts).

§5.3 (subsumes zai-org#2 + zai-org#3) — adopt appRoute/wireRoute across ALL mixed modules:
implemented guard rule r5 (mixed modules forbid bare app.<verb>; must use
appRoute/wireRoute/split-controller). Migrated every bare route in devices,
mcp-plugins, runtime-authorizations, files, relationship, remote-agents,
automation, im — APP routes → appRoute (handler returns value; helper wraps via
sendData → { data }), WIRE (handshake/webhook/binary/HTML/WSS/reverse-MCP) →
wireRoute (bare). devices/mcp-plugins were pure registration swaps (already
sendData, no envelope change); the bare modules went bare→{data} with new shared
View schemas (automation/files/im/relationship/remote-agents/runtime-
authorizations.ts) + web/mobile consumer unwraps in lockstep. Guard r5 baseline
now 0. Fixed appRoute to PRESERVE a handler-set status (reply.status(201)) — the
prior code let sendData's default 200 clobber 201s; added route.test.ts locking
the { data }/201/204/wire contract.
zai-org#3 access-bindings GET query → camelCase (subjectKind/subjectActorId/…); route
already on appRoute + { data } response.

zai-org#5 repo-boundary: extended guard r2 from service/controller-only to ALL non-repo
files (only repo*.ts/repo.types.ts may use TableRow). Moved the TableRow/
TableInsert/TableUpdate aliases out of 12 helper files (access/binding-storage +
subject-registry, im/service/*, mcp-plugins/{plugin-auth-connections,mijia},
memory/indexing, session/runtime, tasks/action-tokens) into per-module
repo.types.ts. guard r2 baseline now 0. Also: guard now strips comments before
matching (kills doc-comment false positives).

GUARD-LAYERING BASELINE IS NOW FULLY EMPTY (0 grandfathered entries across all 6
rules: r1/r2/r3/r4/r5/r7) — the layering is genuinely enforced, not just passing.

zai-org#4 (service→presenter) was completed per-plan in round-3; the 64 remaining
present* are the JUDGMENT-permitted nested-record/cross-module/Tier-D cases.
zai-org#6 (web runtime schema.parse) not required by plan (device-sdk is the parsing
consumer); web stays a typed thin client.

Verification: full monorepo build green; api 1646 pass/0 fail/2 skip (+route.test);
guards (layering[empty]/db/fk/soft-delete/datetime) clean; device-sdk 8/8,
remote-agent-daemon 24/24; web + mobile typecheck clean.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…in AGENTS.md

Round-5 review re-flagged 4 items; an exhaustive re-audit shows 3 are already
resolved and 1 is at its intended end-state:

zai-org#1 CreateCloudDeviceInputSchema strictness — STALE: fixed in round-4 (fddca43);
   title/hostProvider are .optional() and device-sdk parses the same schema it
   sends, so SDK and server accept identical bodies. Verified, no change needed.
zai-org#3 TableRow outside repo — RESOLVED in round-4: the guard r2 baseline is empty
   ([]); all DB-row types live in repo*.ts/repo.types.ts; the guard now strips
   comments so doc-comment mentions aren't flagged. The reviewer's "few left,
   mostly comments" predates that. No change needed.
zai-org#4 web runtime schema.parse — OUT OF PLAN SCOPE: the master plan classifies
   web-next as an app client and device-sdk + the API's sendData/appRoute as the
   runtime-parse boundary. web is a typed compile-time-contract client by design;
   the 6 remaining snake reads are RFC8628 OAuth device-flow wire fields, and the
   3 any are memory request-body accumulators (no shared input schema). No change.
zai-org#2 service→present* (64 sites) — ALL PERMITTED: a fresh per-site classification
   of every present*() in service.ts found exactly 0 clean violations. Each is
   one of: (a) a row→record / record-assembly mapper producing an in-api
   XxxRecord (e.g. presentFileAsset→StoredFileRecord, presentActorRow→Actor,
   presentUser→User), (b) a thin presentInstant/presentOptionalInstant timestamp
   wrapper, or (c) a record consumed cross-module (automation rules read by
   ai/session-tools; remote-agents snapshot read by chat; etc.). The lone
   HTTP-body case (model-groups getModelGroup→ModelGroupDetailView) is also
   consumed in-api as a record by the seeder + ownership pre-handlers +
   updateModelGroup, so it's the same cross-consumed-record exception. The
   master-plan JUDGMENT clause + Tier-D exemption permit all 64.

To stop future reviews re-flagging this as "unfinished", codified the rule in
AGENTS.md (七层职责): service may call present* for row→record assembly,
timestamp wrapping, and cross-module records; what's forbidden is service
producing+bare-returning the OUTWARD app DTO as the sole HTTP-body source — that
stays in the controller via appRoute/sendData.

Verification: full build green; api 1646 pass/0 fail/2 skip; guards
(layering[empty]/datetime) clean; device-sdk 8/8; web + mobile typecheck clean.
No functional code change → running containers already on the verified r4 state.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…(structural, zero-behavior-change)

External review R4 zai-org#2 (full abstraction generalization), structural phase. 'add a provider = implement one interface + register.' No behavior change; the new behavioral seams are inert stubs the next sub-phases fill.

- Full SandboxAdapter interface: meta (SandboxAdapterMeta) + endpoint (AdapterEndpointContract) + ready() + workingSet() + async rebuildDataPlane + reconnectDataPlane + listOrphans/destroyResource. Drops the closed 'kind' field.
- Config-free metadata leaf (adapter-keys.ts -> adapter-metadata.ts): a table of {key, provider, mode, meta{tag,offBox,credentialed,config{envKeys,validate,validateProduction}}, endpoint{scheme,build,identityOk}}. SANDBOX_ADAPTER_KEYS derives from it; importable by both config + registry (RawEnv type-only).
- Collapse the closed SandboxBackendKind union: deleted the union + every 'kind' field; sandboxSpecVolumeSubpath / pid-identity -> provider:string. No switch(kind)/assertNever existed -> no exhaustiveness lost.
- Config fold: the per-provider superRefine blocks -> meta.config.validate(env) (+ validateProduction, currently inert). Behavior byte-identical.
- Dispatch collapse (R3.2/R3.3/R3.7 PRESERVED): bareTargetIdentityOk resolves the identity predicate FROM THE LEAF by tag (unknown -> clean false deny, never adapterForRow which throws); rebuildBarePlane is async + awaited inside try/catch so a throw returns a clean runtime_constraint deny instead of escaping the catch-less callers; the gen-fence + tombstone re-check stay in the one synchronous span before the register.
- ready() replaces the catalogSource fork (resident: waitForCatalog+waitForTunnelEndpoint via injected waiters, both budgets kept; bare: ok:true). Discriminated SandboxSpec (Host|OffBox) over spec.host!.

Gates: typecheck 0, sandbox 204/0, fail-closed 16/16, config 21/21, guards clean, verify:boundary green (api 2851/0).
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…eprovision + provision race/deadline

External review R5 zai-org#2 (P0 data) + zai-org#7 (P1 lifecycle). User chose Option B for zai-org#2.

zai-org#2 = Option B (off-box VM not reused across turns). A reused cube VM diverges from the
host mirror between turns (background writers + no host-side conflict detection), and the
per-turn refresh/commit only touch the host mirror — so any naive per-turn PUSH/PULL would
clobber uncommitted VM work (design review). Instead: DECLINE the fast-path for off-box, so
each off-box turn does a clean teardown (PULL VM->mirror + commit) + cold reprovision
(re-materialize head + replicate-only PUSH). Convergence is automatic via the existing
provision/teardown primitives; no shared-STORAGE_DIR hot-path dependency, no stat-cache
thrash. Host bare/resident keep VM/process reuse (their plane IS the host mirror).

zai-org#7 provision double-create + late deadline:
- Early deadline: the mint now writes sandboxes.deadline_at = NOW()+5min in the SAME tx,
  BEFORE create() returns, so a provision that HANGS after mint (VM up, readiness never
  completes) is visible to reapStuckProvisioningSandboxes. The post-create back-fill refreshes.
- Back-off guard: provisionSandbox is called inside the Redis session lock; the only
  same-session overlap is a lock-expiry window where a 2nd worker enters mid-provision.
  Design review rejected a pg_advisory_xact_lock (single-tx breaks staged crash-recovery +
  pins a pool conn ~60s + nested-call deadlock). Instead: when the existing mounts belong to
  a genuinely in-flight provision (a 'provisioning' row still within its deadline), FAIL
  RETRYABLE (409) rather than tearing down the other worker's fresh VM (orphan + failed
  provision). A crashed/stale provision (past deadline) or an 'active' prior turn falls
  through to the teardown.

SandboxRow gains deadlineAt. Gates: typecheck 0, verify:boundary green (2905/0), 25/25 on
provision/reaper/mint/consume suites.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…rbitrary host write)

R6 review P0: CAS materialize() recreates a base-snapshot symlink as a REAL host
symlink inside the off-box mirror; the R5 assertUnderMirror belt is a purely
LEXICAL resolve()+prefix check (zero fs access), so the teardown PULL's mkdir /
writeFile FOLLOW the symlink and write OUTSIDE the mirror as the shared API
process (reviewer reproduced {"escaped":"PWN"}). Reachable cross-backend: a
docker/device turn can commit `ln -s /etc evil` into the shared base snapshot.

Fix — no-follow the mirror write legs (design-reviewed: neutralize, don't
preserve; keep walkMirror symlink-free):
- firstSymlinkComponentUnderMirror: walk the target's components UNDER the mount
  top-down with lstat, stopping at (returning) the first symlink — so a symlink
  at ANY depth is detected without ever following one (lstat only no-follows its
  last component; returning at the first symlink guarantees every lstat'd path
  has only real-dir ancestors).
- writeMirrorFile / makeMirrorDir: neutralizeMirrorSymlink UNLINKs the first
  symlink component before mkdir/writeFile, so the write lands on a REAL node
  inside the mount — the actor's bytes stay inside the mount (no legit data loss)
  and the weaponized symlink is dropped (off-box can't round-trip symlinks anyway,
  H-7); unlinking it before scanCommitDir also purges it from the committed tree
  rather than re-recording the escape primitive.
- readMirrorFile: assertNoSymlinkOnReadPath refuses to read THROUGH a symlink (a
  host-file exfil into the VM/CAS).
- walkMirror is UNCHANGED (it is shared by PUSH's listMirrorFiles — emitting
  symlinks would either abort provision or stream a host file into the VM); it
  already skips symlink dirents, so PUSH never reads through one.

The write-time unlink + the zai-org#2 refreshSpaces gate (Phase C, removing the only
mid-turn mirror materializer) mean the sole symlink source is the pre-PULL base
materialize, so the sequential single-threaded writes have no concurrent
symlink-inserter to race (openat2 via fs-helper is the deferred gold standard).

Tests: reproduce the escape → the VM file lands INSIDE the mirror (symlink
replaced by a real dir), nothing written to the symlink target; PUSH never
exfiltrates the host secret behind a mirror symlink. verify:boundary green.
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
… (no silent overwrite)

R6 review P1: refreshSpaces was off-box-UNAWARE. For an off-box mount it advanced
base_snapshot_id to a concurrently-committed head H2 and synced only the host
mirror, but NEVER re-pushed the remote VM (which still held the pushed snapshot
H). At teardown the PULL brought the VM (stale x=A) into the mirror and committed
against base=H2 → x=A looked like a fresh LOCAL edit → silently overwrote another
actor's committed x=B, with no conflict surfaced. zai-org#2=Option B (per-turn cold
reprovision) did not touch refreshSpaces, so the race persisted.

Fix (design-reviewed — SOUND): make refreshSpaces off-box-aware.
- sessionSandboxIsOffBox(sessionId): resolve the session's current sandbox via
  getSandboxBySessionForControl → adapterForRow().meta.offBox (any lookup failure
  ⇒ false ⇒ the pre-R6 host behavior).
- For an off-box session, refreshSpaces returns early (empty) — NO base-advance,
  NO mirror head-apply, sync never called. base stays at the pushed snapshot H, so
  the teardown pull→commit performs a correct 3-way merge (base=H, latest=Hn,
  working=VM) that surfaces the concurrent change as a REAL conflict + sidecar
  instead of a silent overwrite. (We do NOT re-push the refreshed head into the VM
  — that would clobber the actor's uncommitted VM work, the reason Option B avoids
  per-turn push.)

Host / no-sandbox sessions are unchanged (one extra sandbox lookup per refresh).
Test: an off-box session's refresh is a no-op (sync never invoked, empty result).
verify:boundary green (sandbox 303/303).
JoshZiel83 pushed a commit to JoshZiel83/Synapse that referenced this pull request Sep 19, 2026
…the tolerant coercion codec

R7 clean-slate (finding zai-org#2). pending-conflicts.ts hand-rolled a DEFENSIVE COERCION that
tolerated old/partial data: normalizeSidecarRef defaulted a missing `kind` to "file",
per-field type-guards silently blanked/dropped bad fields, and gc.ts kept a SECOND
tolerant raw-walk (collectFromSidecarMap) of the same two JSONB blobs. We own the write
shape (no old data), so this is pure maintenance burden.

Fix (mirrors decodeSandboxCapabilityDescriptor's strict-decode convention):
- Replace normalizePendingConflicts/normalizePendingRefresh with decodePendingConflicts/
  decodePendingRefresh — strict Zod schemas, required original/sidecar/kind (no kind→"file"
  default), single top-level safeParse→{} fail-SAFE (a corrupt blob never crashes a turn,
  but there is NO per-field old-data coercion).
- Sidecar schema is a PLAIN object (kind: z.string(), not an enum): contentSha/target stay
  OPTIONAL because they are kind-discriminated RUNTIME states (a file with no contentSha /
  symlink with no target is a genuinely-irrecoverable sidecar, surfaced as PERMANENT by
  isSidecarPayloadIrrecoverable — not old data), and an unrecognized kind is decoded +
  handled rather than nuking the whole blob.
- Fold gc.ts's second reader into the SAME strict decode (decode both stores, collect each
  FILE-kind sidecar's contentSha as a GC root) — one codec, not two.
- Delete the dead isRecord/stringArray/normalizeSidecarRef helpers; repoint repo.ts +
  the service barrel; rewrite the pre-round/coercion tests to the strict semantics
  (a malformed blob → {} entirely, no silent per-field default).

verify:boundary green (sandbox+cube 316/316; pending/refresh/gc 19/19).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant