Skip to content

feat(sync): send and sync projects between environments - #13

Open
BarretoDiego wants to merge 2 commits into
mainfrom
feat/project-sync-across-environments
Open

feat(sync): send and sync projects between environments#13
BarretoDiego wants to merge 2 commits into
mainfrom
feat/project-sync-across-environments

Conversation

@BarretoDiego

Copy link
Copy Markdown
Owner

Problem

There is no way to move a project between environments. If you start work on your laptop and want it on a remote box (homelab, workstation, cloud server), you re-clone by hand, copy uncommitted work around with scp, and repeat the whole dance every time the two drift apart. T3 Code already knows how to reach every one of those machines — it just had no way to move a workspace between them.

Solution

Send a project to another environment, or sync an existing one, from the command palette or Project Settings → Sync.

  • Send creates the project on the destination (folder pre-filled from that environment's "Add project starts in" setting) and copies everything.
  • Sync mirrors an existing project: it diffs manifests, copies only what changed, and deletes what disappeared from the source. The plan is shown before anything moves, and deletions need an explicit confirmation.
  • .git is included by default, so the destination is a full working copy. Progress is reported by bytes and files, and the run can be cancelled.

How it works

The client orchestrates the transfer — it reads from the source environment and writes to the destination. There is no server-to-server channel in T3 Code, and this does not add one: the client is already the multi-environment surface, so sync works over every connection mode that already exists (local, LAN, Tailscale, T3 Connect, SSH) with no new transport, identity, or credentials between machines.

Metadata travels over four new WS RPCs; file content travels over HTTP with short-lived signed URLs (the same HMAC scheme the attachment uploads use), batched and streamed on both ends so a large project never lands in memory. Writes are atomic, paths are confined to the workspace root, and the projectSync capability flag keeps clients from offering the feature against a server that does not support it. Nothing here is event-sourced — sync is a filesystem operation, not a domain event.

Review pass

The first implementation was reviewed adversarially, and the second commit fixes what that turned up. Worth calling out:

  • Framing headers were trusted on decode, so a negative size decremented the signed byte budget — a token authorized for 1 KB could write unbounded data. Headers are now validated structurally.
  • The signed fileCount was never enforced; a body of zero-byte records could exhaust inodes under a tiny token. It is now a hard cap.
  • Export resolved paths lexically and did not reject symlinked ancestors (import did), so a symlink inside the workspace could serve files from outside the root with read scope only.
  • Paths were trimmed and had backslashes rewritten, so filenames that are perfectly legal on POSIX were silently skipped or collapsed onto a different real file.
  • The delete list was recomputed after the user confirmed it, so the deletions executed could differ from the ones shown. The confirmed plan is now the plan that runs — which also halves the work, since the workspace is walked once instead of twice.
  • A file that grew between manifest and export blew the batch budget and aborted the whole sync with a misleading error; it is now skipped and reconciled on the next run.
  • The empty-directory prune deleted directories the source publishes as empty, so the two sides never converged.

Also fixed: file/directory kind conflicts permanently failing import and deletion, deletions above the per-request cap failing to encode, valid in-flight export tokens being evicted under load, a retried send creating a duplicate project, and the capability being checked only on the destination.

Testing

221 tests across 16 files, typecheck clean on contracts, shared, client-runtime, server, and web. Coverage includes a real HTTP round trip (export → import, plus the 400/404/413 paths) and an end-to-end mirror test that runs the client's planner against real manifests and asserts the second pass is a no-op.

Not verified in a browser — happy to do an integrated pass in a real client if you want one before merge.

Known limitations

Documented in docs/user/project-sync.md and docs/internals/project-sync.md: mobile is not supported in v1 (the capability flag gates it), a sync does not survive a tab refresh (re-running reconciles), there is no checkpoint taken on the destination before applying, and syncing while an agent is actively writing to either workspace is not recommended.

Model and harness: Claude Fable 5 subagents (Sonnet and Opus) via Claude Code.

Adds client-orchestrated project sync across environments: the client
reads a manifest (path/size/sha256) from the source environment and
streams changed files to the destination over signed short-lived HTTP
URLs, so it works over every existing connection mode (local, LAN,
Tailscale, T3 Connect, SSH) without any server-to-server trails.

- contracts: projectSync RPCs, capability flag, tagged errors
- shared: binary framing codec for batched file transfer
- server: manifest walker, signed export/import routes, path-safety
  guards, atomic writes, mirror deletions with empty-dir pruning
- client-runtime: pure diff/batching plan + runProjectSync controller
  with progress and cancellation
- web: SyncProjectDialog (send/sync modes, delete confirmation,
  include-.git toggle), command palette entries, Project Settings
  section; destination folder defaults from the environment's add
  project base directory
- docs: user guide, internals architecture page, glossary terms

Implemented by Claude Fable 5 subagents (Sonnet/Opus) via Claude Code.
Review pass over the project sync feature. Security and correctness:

- validate framing headers on decode; a negative size decremented the
  signed byte budget and let a small token write unlimited bytes
- enforce the signed fileCount as a record cap (413), closing an
  inode-exhaustion path via zero-byte records
- guard symlinked ancestors on export, which could read outside the
  workspace root with only read scope
- stop transforming paths (no trim, no backslash rewrite) so names that
  are legal on POSIX round-trip instead of being silently skipped
- skip files whose size changed since the manifest instead of blowing
  the batch budget and aborting the whole sync
- release export registrations after use and expire by TTL, so a busy
  server no longer evicts valid in-flight tokens
- tolerate a non-directory ancestor, replacing it as the mirror intends
- re-assert source empty directories after the deletion pass
- chunk deletions to the contract's per-request cap
- check the capability on the origin environment, not just the
  destination, and reuse the created project when a send is retried

Behavior and structure:

- the plan the user confirms is the plan that runs: planProjectSync is
  computed once for review and handed to runProjectSync, so deletions
  cannot drift between confirmation and execution
- stream batch bodies through when the runtime supports it, buffering
  only as a fallback
- carry an HTTP status on transfer errors instead of scraping messages
- move the dialog's pure logic to client-runtime so mobile can reuse it
- share the signed-token verification and default-folder helpers with
  their existing counterparts

Docs corrected against the implementation.

Implemented by Claude Fable 5 subagents (Sonnet/Opus) via Claude Code.
@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant