diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55c98d74..2eedd6cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,6 +149,13 @@ jobs: - name: Install Redis for ledger integration tests run: sudo apt-get update && sudo apt-get install -y redis-server + - name: Verify fleet admission with isolated Redis + run: | + redis_socket="$RUNNER_TEMP/byom-admission.sock" + redis-server --port 0 --unixsocket "$redis_socket" --save '' --appendonly no --daemonize yes + trap 'redis-cli -s "$redis_socket" shutdown nosave' EXIT + BRIDGE_TEST_REDIS_URL="$redis_socket" bun test src/bridge/fleet.test.ts + - name: Build service run: bun run build @@ -201,6 +208,11 @@ jobs: node-version: 24.16.0 - run: npm ci - run: npm run build + - name: Selected project root containment tests + run: | + command -v rg || brew install ripgrep + node --test dist/root-access.test.js + node --test --test-name-pattern='selected command|selected replay copy|programmatic probes reject' dist/native-sandbox.test.js - name: Native environment containment tests run: node --test dist/environment.test.js - name: Native ACL and credential lifecycle tests diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 486876a0..5df2463d 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -57,6 +57,38 @@ unchanged. - Delta: the port also picks up upstream `c688b30` (#225), which the fork's byte-identical-to-v1.1.0 `release.yml` had been missing. +### Re-audit basis: upstream v1.2.0 integration (2026-09-18) + +Recorded when the fork merged upstream release v1.2.0. Every disposition above +was re-checked against the merged tree; all ten behaviors remain Active and +none needed re-derivation. + +- Fork ref and SHA: `uzh/main` at + `529d9c0ee18f0f4fb8e91ef726a2528db87335e8` (PR #25 release guard) +- Upstream ref and SHA: LibreChat-AI/code-interpreter tag `v1.2.0` at + `fd9a4fa65e0a5189957032c0046eb286311fda62` (also `upstream/main`) +- Merge base: `b35c503fd2fe7be412d95c0eef6db50a09aad280` (tag `v1.1.0`) +- Audited date: 2026-09-18 +- Method: semantic merge of the exact refs above in the scratch clone (clean, + zero conflicts), file-overlap analysis between the fork's 55-file delta over + `v1.1.0` and the merge's 28-file delta, a logging-sink grep over the newly + introduced upstream files, and byte-checks of the release-automation files +- Delta: three upstream commits. `#226` (`4c7b224`, bounded + repository-instruction discovery) and `#222` (`95bfcbd`, selected coding + project registration) add new `packages/code` and `service` files; `#227` + (`672e195`) reports workspace admission capacity from `service/src/bridge`. + `#225` and `#233` were already ported (see the two bases above). +- Overlap: the only file both the fork delta and the merge touch is + `.github/workflows/ci.yml`, where upstream appends two test steps and the + fork's `chmod 0555` and release-version-resolution lines are unchanged. The + merged `.github/scripts/resolve-release-version.sh` and + `tests/release-version-resolution.sh` stay byte-identical to `v1.2.0`, and + `release.yml` differs from `v1.2.0` only by the fork guard described below. +- No merged upstream file introduces a logging sink, so the values-free policy + (below) gains no new surface. +- Limitation: the fork SHA identifies the pre-integration `main`; the + integration PR records the resulting exact head and the GitHub merge SHA. + States: Active, Review on sync, Draft, History only, Retired. ## Patch index diff --git a/docs/project/2026-09-18-upstream-v1.2.0-integration-plan.md b/docs/project/2026-09-18-upstream-v1.2.0-integration-plan.md new file mode 100644 index 00000000..c7a51973 --- /dev/null +++ b/docs/project/2026-09-18-upstream-v1.2.0-integration-plan.md @@ -0,0 +1,192 @@ +# Upstream v1.2.0 integration plan + +Status: executed — terminal condition met (green-CI draft PR #26 on the task +branch at head `a25ea9a`, ledger re-inventoried, slice and final reviews +closed with no findings); merging into `main` stays separately approved. +Package: full path (security seam: selected coding-project root access; +architecture seams: workspace admission capacity and repository-instruction +discovery) +Branch: chore/reconcile-upstream-v1.2.0 +Target: uzh-bf/code-interpreter main +Upstream release: LibreChat-AI/code-interpreter v1.2.0 (tag `fd9a4fa`, also +`upstream/main`, released 2026-09-18) + +## Approval summary + +Fork `main` is at `529d9c0` (PR #25, release guard) and already carries two +of v1.2.0's five commits as ports: #233 (`fd9a4fa`, release version +resolution, cherry-picked as `b67791e`) and #225 (`c688b30`). The remaining +delta is three upstream commits: #226 (`4c7b224`) bounded repository +instructions for attached workspaces, #222 (`95bfcbd`) selected coding +project registration, #227 (`672e195`) workspace admission capacity +reporting. + +The trial merge (`origin/main` + `v1.2.0`) is clean: zero conflicts, 28 +files, +2427/−20, no lockfile or chart or Dockerfile change. The only file +both the fork deltas and the merge touch is `.github/workflows/ci.yml`, +where upstream appends two test steps and the fork's lines are untouched. +No merged upstream file adds a logging sink, so the values-free policy needs +no new sink. `release.yml`, `.github/scripts/resolve-release-version.sh`, +and `tests/release-version-resolution.sh` merge byte-untouched: the fork's +permanent release-job guard and the ported resolver survive. + +All ten fork behaviors remain needed and none requires re-derivation. The +policy stays as agreed on 2026-09-18: the fork tracks upstream releases and +adds its own commits on top, deployments pin commit SHAs, and the fork never +cuts its own tags or publishes its own releases. + +Done means: reconciled branch with the verification battery green at the +exact head, patch ledger re-inventoried to the new basis, reviews closed, and +a draft PR open with receipts. Deployment, image publication, GitOps +promotion, and merging into `main` are excluded here and separately gated. + +## Evidence and binding contracts + +### Live state (verified 2026-09-18) + +- `uzh/main` = `529d9c0` (PR #25 merged 11:16:52Z as a merge commit). Fork + tags/releases remain 0/0. Post-merge CI run `35338850317` and image build + `35338850298` run on `529d9c0`; the `Release` workflow's only main-push + run (`35266235871`) failed before the fix, and every run after the job + guard is skipped, never failing. +- Upstream `v1.2.0` = `fd9a4fa` = `upstream/main`; delta to fork `main` is + the three commits above with `c688b30` and `fd9a4fa` already ported. +- Fork patch surface vs `v1.1.0`: 55 files (`git diff v1.1.0 origin/main`); + its intersection with the merge's 28 files is `ci.yml` only, and the + merged diff there is upstream's two appended test steps. +- New upstream code introduces no `console`/logger sink (grep over the new + service and packages/code files), so the values-free log policy (patch 9) + has no new surface to cover. +- The fork's GHCR image workflow, Helm chart, Dockerfiles, egress ledger, + queue-wait fallback, JWT trust table, telemetry seam, and PVC de-hook are + not touched by this merge. + +### Fork patch disposition (vs v1.2.0) + +| # | Behavior | v1.2.0 state | Disposition | +| --- | --- | --- | --- | +| 1 | Exact-SHA UZH GHCR images | no equivalent workflow | Keep; untouched | +| 2 | Spec-guard readable roots (chmod 0555) | rootfs-setup unchanged | Keep; untouched | +| 3 | Split/harden untrusted sandbox namespace | still single-namespace | Keep; untouched | +| 4 | Cede ownership to external controllers | chart unchanged | Keep; untouched | +| 5 | Argo-safe PVC package-init | hook still present | Keep; untouched | +| 6 | BullMQ poll fallback | waitForJobWithCancellation unchanged | Keep; untouched | +| 7 | Egress ledger indefinite reconnect | unchanged | Keep; untouched | +| 8 | JWT issuer-scoped trust table | unchanged | Keep; untouched | +| 9 | Values-free logging | no new sinks | Keep; untouched | +| 10 | Optional nonfatal telemetry | unchanged | Keep; untouched | + +## Slices + +S1 — Merge upstream v1.2.0 (main). +Merge `v1.2.0` into `chore/reconcile-upstream-v1.2.0` with the plan file as +the branch's first commit; keep upstream files byte-identical (the clean merge +does this natively) and the fork's release guard intact. Acceptance: +`git diff origin/main HEAD` equals the trial-merge delta, resolver files +byte-identical to `v1.2.0` plus the guard, `git diff --check` clean. + +S2 — Ledger re-inventory (main). +Record the new inventory basis in `docs/fork/patches.md` (uzh/main `529d9c0`, +upstream `v1.2.0` `fd9a4fa`), the ci.yml overlap, and the unchanged +dispositions. Acceptance: ledger text matches the merged tree; no stale +claims. + +S3 — Verification battery (main). +Run on the merged head: service `bun test`, api `bun test`, packages/code +`npm run build && node --test dist/*.test.js` (any failure compared against +pristine `v1.2.0`), workflow YAML parse, and the release-versioning scripts. +Helm and Dockerfile checks are skipped with reason (no chart/Dockerfile +change). Acceptance: all green at the exact head or inherited-only failures +proven byte-identical to pristine `v1.2.0`. + +S4 — Reviews and draft PR (main + reviewers). +Simplifier scoped to fork-authored hunks (upstream-carried code stays +byte-identical for merge-sync), one slice review with the security and +architecture lenses on the merge, one integrated final review, then the +rs-mr-description-writer draft PR. Acceptance: review findings resolved or +recorded; draft PR open with verification receipts. + +## Test portfolio + +| Risk | Obligation | Seam | Failure it must catch | +| --- | --- | --- | --- | +| Merge silently drops a fork line | existing | fork-delta diff check | fork behavior lost in a file both sides touch | +| New root-access code weakens sandbox containment | upstream + CI step | root-access/native-sandbox tests | selected roots escape containment | +| Admission reporting misreports capacity | upstream + CI step | fleet.test.ts with isolated Redis | capacity errors surface as timeouts | +| Repository instructions read unbounded input | upstream | instructions tests | unbounded or unlisted file reads | +| Release guard lost in the merge | existing | release.yml byte-check | fork job guard or resolver diverges | + +## Working context + +- The primary checkout's `.git` is read-only; execution runs in the writable + clone `/tmp/ci-reconcile-1` (origin = uzh-bf fork over https, upstream = + LibreChat-AI). Push the task branch from there. +- Host toolchain: bun 1.3.14, node 24.17.0, docker 29.4.0; service and api + suites run on the host; packages/code uses its build plus node test runner. +- Rollback: ordinary source revert of the merge commit on the branch; + deployment rollback stays declarative through df-cloud/Helm (out of scope). + +## Authority + +Granted within this package: branch creation, merge work, commits, task-branch +push, draft PR, local test runs, read-only `gh` evidence. Standing +implementation delivery applies. + +Separately gated (asked at the end, not executed): merge into `main`, whose +push will run CI plus the standing `Build CodeAPI Images` job that publishes +the seven ``/`main` GHCR images exactly as on PR #24/#25; closing +dependabot PRs #4/#14/#16 (all `CONFLICTING`; `main` already carries +`@opentelemetry/core` 2.8.0 and `nanoid ^5.1.16`, and `api` has no +`body-parser` dependency, so all three are superseded); PR #21 disposition +(draft, `CONFLICTING`, 1030/202 lines across `api/openapi.yaml`, +`service/openapi.yml`, and five service files; its +`service/src/openapi-contract.test.ts` is absent from `main`, so it needs +re-derivation rather than a blind rebase); deleting merged branches or +worktrees (`rs/fix-release-version-resolution` is merged into `main`); +image publication; any GitOps/deployment promotion. + +## Progress + +- 2026-09-18: PR #25 merged (`529d9c0`, merge commit) with the release-job + guard; post-merge CI and image build running; fork tags/releases still 0/0. +- 2026-09-18: trial merge of `v1.2.0` clean; overlap analysis and logging-sink + check done; this plan drafted. +- 2026-09-18: S1 merge `abff6b7` (true merge commit, upstream parent + `fd9a4fa`); resolver files byte-identical to `v1.2.0`, `release.yml` + differs only by the fork guard. S2 ledger `c5aee61`. +- 2026-09-18: S3 verification at `c5aee61`: service `bun run test` 1097 pass / + 0 fail / 12 skip; api `bun run test` 481 pass / 0 fail; fleet admission with + isolated Redis 1 pass; packages/code 506 tests / 483 pass / 11 fail with the + failure set identical to pristine `v1.2.0` (environment-inherited macOS + ACL/Koffi class); workflow YAML parses; `release-version-resolution.sh` and + `release-versioning.sh` pass. Two initial runs were invalidated: a stale + container-installed `node_modules` in the clone produced 73 spurious + packages/code failures, and running three suites concurrently pushed the + 240-input api test past its 30 s timeout; the clean reinstall and quiet-host + reruns reproduced the numbers above. +- 2026-09-18: S4 routes: slice review on `combo/glm-5.3-flash` (max) returned + DONE with no findings; it reproduced blob-identity for 27/28 merge files and + checked the security lenses (root containment, instruction reads, admission). + Simplifier: the native `gpt-6-astra` route is blocked by the account usage + limit (until 2026-09-20) and the Claude CLI fallback failed with an expired + OAuth token; per the continuity ladder the non-gate simplification pass + returned to the main session with no net-simplification changes identified in + the docs hunks. Final review: Claude CLI failed terminally and AGY is not + logged in, so the documented one-shot GLM final-review fallback carries the + complete final-review contract. +- 2026-09-18: final review (GLM 5.3 Flash continuity fallback with the complete + final-review contract) returned DONE with no findings; it reproduced the + range and merge topology, the 27/28 blob-identity, plan compliance, and the + security spot-checks. Recorded limitations: same model family as the slice + reviewer, verification battery accepted per contract, branch CI pending the + push. Local reports: docs/project/_local/reviews/2026-09-18-upstream-v1.2.0-integration-*.md. +- 2026-09-18: S4 closed. Draft PR #26 opened against `main` (never-squash + merge expected, matching PR #24). CI run `35341483663` at head `a25ea9a` + completed **success**: all ten checks green — Deployment Config, Launcher, + API, Service (including the new "Verify fleet admission with isolated + Redis" step), three Node code-package jobs, macOS Storage ACL, Lambda + MicroVM Provisioning, and the arm64 runner-image build. PR merge state + `CLEAN`/`MERGEABLE`, draft. The fork still has 0 tags / 0 releases, and a + branch push runs no image workflow. +- 2026-09-18: terminal condition met. No further work is authorized in this + package; every remaining action is a separately gated decision listed above. diff --git a/packages/code/README.md b/packages/code/README.md index 48bf769d..08dc54a7 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -51,6 +51,50 @@ chosen non-overlapping project directories with `--workspace` or `--environment` Do not also register their parent directory. Treat the inventory as a snapshot; normal workspace admission must validate any directory selected from it. +## Register selected projects + +After pairing, use paths from `projects --root` to register individual checkouts: + +```bash +librechat-code run --project-root /srv/projects \ + --project web --project services/api \ + --allow-workspace-writes --allow-workspace-commands +``` + +Only the explicitly listed checkouts become execution roots. The discovery +directory is not registered, and adding a new sibling repository does not grant +access to it. In LibreChat, select the project in the existing workspace picker; +the conversation stores that selection for subsequent tools and approval resumes. +An agent's default workspace and the user's recent selection work as before. + +Project IDs are derived from the canonical discovery directory and relative +project path, not the branch or selection order. Keep both paths unchanged across +restarts to retain chat bindings. Moving a checkout changes its ID. These are +registration IDs, not the root-local IDs printed by the inventory command. + +Up to 32 selected projects are supported. Each must be a standalone Git checkout; +linked worktrees, symlink traversal, overlapping roots, and duplicate selections +are rejected. Existing native sandbox, command/write permissions, lease-slot and +quarantine rules still apply. This mode cannot be combined with `--environment`, +`--worker-dir`, `--workspace`, default-workspace, or workspace ID/name settings. +Existing registrations are not migrated automatically; use a new conversation +when switching registration mode. Non-Git directories still use the existing +workspace flags. Named environment setup/actions still use `--environment`. + +Selected projects require macOS or Linux (including WSL2). Each request opens +and verifies the admitted directory, then retains that descriptor through file +access, repository-instruction loading, command startup, and replay copying. +Renaming a project cannot redirect an in-flight request to a replacement checkout; +subsequent requests reject the changed identity. Restart with an explicitly +selected replacement to admit it. Descriptors close when requests settle, and +independent workspaces do not share a current directory or global execution lock. + +This reuses the existing workspace protocol. Programmatic tool calling requires +a LibreChat version that preserves the selected workspace across initial +execution and replay, plus the worker's normal programmatic prerequisites. +Installation alone does not restart workers or change registration; update your +worker service arguments explicitly. + ## Pair Hardened deployments use a one-time code instead of copying a long-lived @@ -648,6 +692,19 @@ librechat-code run \ --allow-workspace-commands ``` +Slots are per machine, not a fleet-wide execution limit. A busy machine does not +consume another machine's slots. Requests for the same root remain serialized, +including commands started through background tools. Independent checkouts can +use different slots; selecting subdirectories beneath one registered parent root +does not create separate scheduling boundaries. Linked Git worktrees share Git +metadata and are not supported by selected-project registration. + +Admission waits at most 30 seconds. A `WORKSPACE_QUEUE_TIMEOUT` response (HTTP +503, `Retry-After: 1`) means the operation was not assigned or started; wait for +capacity before submitting it again. This is distinct from `ASSIGNMENT_EXPIRED` +or a transport timeout after dispatch, where execution may have occurred and +mutations must not be blindly retried. No automatic retry is added by this policy. + Keep the existing URL, pairing/identity, and network policy configuration. The primary root keeps its configured workspace ID (default `primary`). Repeat `--workspace id=path` to add named roots, up to the protocol's 32-root limit. diff --git a/packages/code/REPOSITORY-INSTRUCTIONS.md b/packages/code/REPOSITORY-INSTRUCTIONS.md new file mode 100644 index 00000000..5f8ce529 --- /dev/null +++ b/packages/code/REPOSITORY-INSTRUCTIONS.md @@ -0,0 +1,29 @@ +# Repository instructions + +After updating Code API and LibreChat to versions supporting repository instruction metadata, +add `--repository-instructions` to the existing `librechat-code run` command. Keep all existing +pairing, workspace, sandbox and environment arguments. This is an explicit machine-owner opt-in; +without it, no instructions are advertised or automatically read. + +Only each registered workspace root is examined. `AGENTS.md` takes precedence; `CLAUDE.md` +is considered only when `AGENTS.md` does not exist. Symlinks, directories, unreadable files, +invalid UTF-8 and binary files are omitted. There is no parent-directory walk or globbing. + +Metadata refreshes with the existing worker registration heartbeat. It does not change worker +identity, lease slots or workspace permissions. The next run after that refresh sees changed +metadata; this is not a filesystem watch or a guarantee of immediate edit visibility. + +The descriptor contains only the relative filename, bounded snapshot byte count, SHA-256 and +truncation flag. SHA-256 identifies the delivered UTF-8 snapshot, not unseen bytes beyond the +32 KiB cap. An incomplete trailing UTF-8 character is omitted. Content travels only through the +authorized `read_file` operation, using `instructionSha256` to reject reads if the snapshot changed. +This mode preserves newlines and is independent of the ordinary line-oriented reader. + +LibreChat caches verified content with bounded capacity and principal/machine/workspace scoping. +Its per-agent setting selects `prefer`, `defer` or `off`; it cannot enable discovery on a worker +that did not opt in or widen filesystem, network, or approval permissions. + +Rollout: update LibreChat and Code API first, then update workers and enable the flag. Older +workers continue unchanged. An older Code API rejecting the metadata causes the worker to retry +without it and omit metadata until restart. Do not enable this flag against an older LibreChat +instance: older clients validate workspace descriptors strictly. diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 7406e267..524c5853 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -6,6 +6,7 @@ import { basename, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { discoverProjects } from './projects.js'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { loadCodeEnvironment, assertEnvironmentDefinitionsOutsideRoots, @@ -309,6 +310,26 @@ async function run( runtimeSessionId?: string, args: string[] = [], ): Promise { + const projectArgs = projectRootArguments(args); + if ( + projectArgs && + (runtimeSessionId != null || + args.some(arg => + ['--environment', '--worker-dir', '--default-workspace', + '--workspace', '--workspace-id', '--workspace-name'].some( + flag => arg === flag || arg.startsWith(`${flag}=`), + ), + ) || + [process.env.LIBRECHAT_CODE_WORKER_DIR, + process.env.LIBRECHAT_CODE_WORKSPACE_ID, + process.env.LIBRECHAT_CODE_WORKSPACE_NAME].some(value => value?.trim()) || + process.env.LIBRECHAT_CODE_DEFAULT_WORKSPACE?.trim().toLowerCase() === 'true') + ) { + throw new Error('Project selection cannot be combined with other workspace registration settings'); + } + const projectRoots = projectArgs + ? await loadProjectRoots(projectArgs.root, projectArgs.projects) + : []; const environmentPaths: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--environment') { @@ -426,11 +447,13 @@ async function run( runtimeSessionId == null && (fileRelayUpstream?.length ?? 0) > 0; const workspaceId = + projectRoots[0]?.id ?? environments[0]?.definition.name ?? option(args, '--workspace-id') ?? process.env.LIBRECHAT_CODE_WORKSPACE_ID?.trim() ?? 'primary'; const explicitWorkerDirectory = + projectRoots[0]?.root ?? environments[0]?.definition.root ?? (runtimeSessionId == null ? nonEmpty( @@ -468,6 +491,9 @@ async function run( if (environments.length && commandSandboxMode !== 'native-srt') { throw new Error('Environment definitions require native-srt'); } + if (projectRoots.length && commandSandboxMode !== 'native-srt') { + throw new Error('Project selections require native-srt'); + } if ( environments.some(environment => environment.definition.setup) && !allowWorkspaceCommands @@ -575,8 +601,10 @@ async function run( { id: workspaceId, root: canonicalWorkerDirectory, + identity: projectRoots[0]?.identity, writable: allowWorkspaceWrites, name: + projectRoots[0]?.name ?? environments[0]?.definition.name ?? option(args, '--workspace-name') ?? process.env.LIBRECHAT_CODE_WORKSPACE_NAME?.trim() ?? @@ -597,6 +625,9 @@ async function run( writable: allowWorkspaceWrites, }); } + for (const project of projectRoots.slice(1)) { + roots.push({ ...project, writable: allowWorkspaceWrites }); + } await assertEnvironmentDefinitionsOutsideRoots(environments, roots); for (let i = 0; i < args.length; i++) { if ( @@ -684,11 +715,13 @@ async function run( }), ]), ); - let workspaceTools: WorkspaceToolExecutor | undefined = workerDirectory + const localWorkspaceTools = workerDirectory ? await LocalWorkspaceTools.create({ workspaces: roots, + repositoryInstructions: args.includes('--repository-instructions'), }) : undefined; + let workspaceTools: WorkspaceToolExecutor | undefined = localWorkspaceTools; if (allowWorkspaceCommands && !canonicalWorkerDirectory) { throw new Error('Workspace commands require a registered directory'); } @@ -880,6 +913,7 @@ async function run( }); const nativeOptions: NativeProcessSandboxOptions = { workspaceRoot: canonicalWorkerDirectory!, + workspaceIdentity: roots[0]?.identity, commandPolicy, protectedPaths: [ identityPath, @@ -919,7 +953,7 @@ async function run( new Map( roots.map(root => [ root.id, - { ...nativeOptions, workspaceRoot: root.root }, + { ...nativeOptions, workspaceRoot: root.root, workspaceIdentity: root.identity }, ]), ), workspaceLeaseSlots, @@ -1030,6 +1064,7 @@ async function run( } try { const worker = new BridgeWorker({ + instructionDescriptors: () => localWorkspaceTools?.instructionDescriptors() ?? Promise.resolve(undefined), codeApiUrl, token: configuredToken, identity: workerIdentity, diff --git a/packages/code/src/instructions.test.ts b/packages/code/src/instructions.test.ts new file mode 100644 index 00000000..42baafe9 --- /dev/null +++ b/packages/code/src/instructions.test.ts @@ -0,0 +1,206 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm, symlink, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readRepositoryInstructions } from './instructions.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import { isWorkspaceToolResult } from './protocol.js'; +import { BridgeWorker } from './worker.js'; + +test('instruction discovery selects one fixed name and refuses symlink fallback', async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'repository-instructions-')), + ); + try { + await writeFile(join(root, 'CLAUDE.md'), 'fallback\n'); + assert.equal( + (await readRepositoryInstructions(root))?.content, + 'fallback\n', + ); + await writeFile(join(root, 'AGENTS.md'), 'preferred\n'); + assert.equal( + (await readRepositoryInstructions(root))?.content, + 'preferred\n', + ); + await rm(join(root, 'AGENTS.md')); + await symlink(join(root, 'CLAUDE.md'), join(root, 'AGENTS.md')); + assert.equal(await readRepositoryInstructions(root), undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('registration refreshes instruction metadata without changing the worker incarnation', async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'repository-instructions-')), + ); + try { + await writeFile(join(root, 'AGENTS.md'), 'first'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + repositoryInstructions: true, + }); + const registrations: Array<{ + incarnationId: string; + capabilities: { + workspaceTools: { + workspaces: Array<{ + instructions?: Array<{ sha256: string }>; + }>; + }; + }; + }> = []; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'test', + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'test', + runtimes: [], + workspaceTools: tools.capabilities, + }, + workspaceTools: tools, + instructionDescriptors: () => tools.instructionDescriptors(), + fetchImpl: async (_url, init) => { + registrations.push(JSON.parse(String(init?.body))); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + registeredAt: new Date().toISOString(), + leaseTtlMs: 60000, + supportedWorkspaceToolOperations: [ + 'read_file', + 'search_text', + 'list_files', + ], + supportedWorkspaceListFileFeatures: ['after_path'], + }); + }, + }); + await worker.register(); + const first = + registrations.at(-1)!.capabilities.workspaceTools.workspaces[0] + .instructions![0].sha256; + await writeFile(join(root, 'AGENTS.md'), 'second'); + await worker.register(); + assert.notEqual( + registrations.at(-1)!.capabilities.workspaceTools.workspaces[0] + .instructions![0].sha256, + first, + ); + assert.ok( + registrations.every( + item => item.incarnationId === 'incarnation-00000001', + ), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('bounded UTF-8 snapshots refresh and hash-fence authorized reads', async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'repository-instructions-')), + ); + try { + await writeFile(join(root, 'AGENTS.md'), 'a'.repeat(32767) + '🙂tail'); + const snapshot = await readRepositoryInstructions(root); + assert.equal(snapshot?.descriptor.truncated, true); + assert.equal(snapshot?.descriptor.bytes, 32767); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + repositoryInstructions: true, + }); + const request = { + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'AGENTS.md', + instructionSha256: snapshot!.descriptor.sha256, + }; + const result = await tools.execute(request); + assert.equal(isWorkspaceToolResult(request, result), true); + assert.equal(isWorkspaceToolResult(request, { ...result, content: 'forged', endLine: 1 }), false); + assert.equal( + (await tools.instructionDescriptors())?.get('primary')?.[0]?.sha256, + snapshot?.descriptor.sha256, + ); + await writeFile(join(root, 'AGENTS.md'), 'new instructions\n'); + await assert.rejects( + tools.execute(request), + /changed or are unavailable/, + ); + assert.notEqual( + (await tools.instructionDescriptors())?.get('primary')?.[0]?.sha256, + snapshot?.descriptor.sha256, + ); + const disabled = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + assert.equal(await disabled.instructionDescriptors(), undefined); + await assert.rejects(disabled.execute(request)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('an older bridge can reject metadata without breaking later registrations', async () => { + let metadataRequests = 0; + let accepted = 0; + const capabilities = { + protocolVersion: 1 as const, + operations: ['read_file' as const], + workspaces: [{ id: 'primary', name: 'Primary' }], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'test', + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'test', + runtimes: [], + workspaceTools: capabilities, + }, + workspaceTools: { + capabilities, + execute: async () => { + throw new Error('not expected'); + }, + }, + instructionDescriptors: async () => new Map([['primary', []]]), + fetchImpl: async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if ( + body.capabilities.workspaceTools?.workspaces[0].instructions !== + undefined + ) { + metadataRequests++; + return Response.json( + { error: 'Unknown workspace field' }, + { status: 400 }, + ); + } + accepted++; + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + registeredAt: new Date().toISOString(), + leaseTtlMs: 60000, + supportedWorkspaceToolOperations: ['read_file'], + }); + }, + }); + await worker.register(); + await worker.register(); + assert.equal(metadataRequests, 1); + assert.ok(accepted >= 2); +}); diff --git a/packages/code/src/instructions.ts b/packages/code/src/instructions.ts new file mode 100644 index 00000000..1a1e8cad --- /dev/null +++ b/packages/code/src/instructions.ts @@ -0,0 +1,89 @@ +import { constants } from 'node:fs'; +import { open, lstat, realpath, stat } from './root-access.js'; +import { createHash } from 'node:crypto'; +import { resolve, relative, isAbsolute, sep } from 'node:path'; + +import { REPOSITORY_INSTRUCTION_MAX_BYTES } from './protocol.js'; +import type { RepositoryInstructionDescriptor } from './protocol.js'; +export interface RepositoryInstructionSnapshot { + descriptor: RepositoryInstructionDescriptor; + content: string; +} + +/** Fixed-name, root-confined discovery. An unreadable AGENTS.md never selects a fallback. */ +export async function readRepositoryInstructions( + root: string, +): Promise { + let path: RepositoryInstructionDescriptor['path'] = 'AGENTS.md'; + try { + await lstat(resolve(root, path)); + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + return; + path = 'CLAUDE.md'; + } + let handle: Awaited> | undefined; + try { + const candidate = resolve(root, path); + if (!(await lstat(candidate)).isFile()) return; + handle = await open( + candidate, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const opened = await handle.stat(); + const canonical = await realpath(candidate); + const offset = relative(root, canonical); + const current = await stat(canonical); + if ( + !opened.isFile() || + isAbsolute(offset) || + offset === '..' || + offset.startsWith(`..${sep}`) || + opened.dev !== current.dev || + opened.ino !== current.ino + ) + return; + const buffer = Buffer.alloc(REPOSITORY_INSTRUCTION_MAX_BYTES + 1); + let length = 0; + while (length < buffer.length) { + const read = await handle.read( + buffer, + length, + buffer.length - length, + length, + ); + if (read.bytesRead === 0) break; + length += read.bytesRead; + } + const truncated = length > REPOSITORY_INSTRUCTION_MAX_BYTES; + const decoder = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }); + const content = decoder.decode( + buffer.subarray( + 0, + Math.min(length, REPOSITORY_INSTRUCTION_MAX_BYTES), + ), + { stream: truncated }, + ); + if (content.includes('\0')) return; + return { + descriptor: { + path, + bytes: Buffer.byteLength(content), + sha256: createHash('sha256').update(content).digest('hex'), + truncated, + }, + content, + }; + } catch { + return; + } finally { + await handle?.close().catch(() => undefined); + } +} diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 81bbd9c4..70cb2bd1 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -336,6 +336,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan child.on('disconnect', lost); const { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, @@ -348,6 +349,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan { options: { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, diff --git a/packages/code/src/native-programmatic-live.test.ts b/packages/code/src/native-programmatic-live.test.ts index 2bc22356..92cab6ea 100644 --- a/packages/code/src/native-programmatic-live.test.ts +++ b/packages/code/src/native-programmatic-live.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -8,17 +8,20 @@ import type { AddressInfo } from 'node:net'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { resolveNativeSrtCommandPolicy } from './native-policy.js'; -test('real SRT prevents speculative network effects under trusted-vm', { +for (const selected of [false, true]) { +test(`real SRT prevents speculative network effects under trusted-vm${selected ? ' for a selected project' : ''}`, { skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', timeout: 30_000, }, async () => { - const root = await mkdtemp(join(tmpdir(), 'native-ptc-effects-')); + const root = await realpath(await mkdtemp(join(tmpdir(), 'native-ptc-effects-'))); + const identity = await stat(root, { bigint: true }); let effects = 0; const server = createServer((_req, res) => { effects += 1; res.end('ok'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const port = (server.address() as AddressInfo).port; const executor = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: root, + ...(selected ? { workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) } } : {}), commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'), programmaticFileUpstream: `http://127.0.0.1:${port}`, }); @@ -39,3 +42,4 @@ test('real SRT prevents speculative network effects under trusted-vm', { } } }); +} diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 3252ee50..e610a0fc 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { access, chmod, @@ -236,6 +237,119 @@ test('programmatic probes use a copy-on-write workspace without mutating the pro assert.equal(await readFile(join(snapshot, 'state.txt'), 'utf8'), 'probe-only'); }); +test('selected command cwd stays bound when replacement happens while wrapping', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-command-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager({ beforeWrap: async () => { + await rename(root, `${root}.old`); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'replacement'); + } }).manager, + }); + t.after(() => sandbox.close()); + const result = await sandbox.execute({ ...request, command: 'cat identity.txt; printf written > result.txt' }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, 'original'); + assert.equal(await readFile(join(`${root}.old`, 'result.txt'), 'utf8'), 'written'); + await assert.rejects(access(join(root, 'result.txt'))); +}); + +test('selected command cancellation kills the exec trampoline process group', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-cancel-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = await realpath(parent); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager().manager, + }); + t.after(() => sandbox.close()); + const controller = new AbortController(); + const running = sandbox.execute({ ...request, timeoutMs: 5000, + command: 'printf started > started; sleep 3; printf late > late' }, controller.signal); + const rejected = assert.rejects(running, error => error instanceof WorkspaceToolError && error.code === 'EXECUTION_ABORTED'); + const deadline = Date.now() + 3000; + while (true) { + try { await access(join(root, 'started')); break; } catch { /* Wait for the actual child. */ } + if (Date.now() > deadline) throw new Error('Selected command did not start'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + controller.abort(); + await rejected; + await new Promise(resolve => setTimeout(resolve, 3100)); + await assert.rejects(access(join(root, 'late'))); +}); + +test('selected replay copy stays on the verified directory after pathname replacement', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-copy-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand(command, args, options) { + assert.equal(command, process.execPath); + renameSync(root, `${root}.old`); + mkdirSync(root); + writeFileSync(join(root, 'identity.txt'), 'replacement'); + return spawn(command, args, options); + }, + }); + t.after(() => sandbox.close()); + const directory = await sandbox.createExecutionDirectory(); + let snapshot: string; + try { + snapshot = await sandbox.createProgrammaticProbeWorkspace(directory); + } catch (error) { + if (error instanceof CopyOnWriteCloneUnavailableError) { + t.skip('host filesystem does not support copy-on-write cloning'); + return; + } + throw error; + } + assert.equal(await readFile(join(root, 'identity.txt'), 'utf8'), 'replacement'); + assert.equal(await readFile(join(snapshot, 'identity.txt'), 'utf8'), 'original'); +}); + +test('programmatic probes reject a replaced selected project before copying', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-probe-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + const identity = await stat(root, { bigint: true }); + let copies = 0; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand() { + copies++; + throw new Error('must not copy a replaced project'); + }, + }); + t.after(() => sandbox.close()); + const executionDirectory = await sandbox.createExecutionDirectory(); + await rename(root, join(parent, 'original')); + await mkdir(root); + await assert.rejects( + sandbox.createProgrammaticProbeWorkspace(executionDirectory), + /Selected project changed before probe staging/, + ); + assert.equal(copies, 0); +}); + test('programmatic probes do not hide clone implementation failures as unsupported filesystems', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 550d0d52..84602a35 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -13,6 +13,9 @@ import { import { constants as fsConstants } from 'node:fs'; import { access, mkdtemp, open, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; +import { withWorkspaceRoot, WorkspaceRootAccessError, spawnWithinWorkspace, realpath as rootedRealpath, stat as rootedStat } from './root-access.js'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; @@ -157,6 +160,7 @@ type SpawnCommand = ( ) => ChildProcessWithoutNullStreams; export interface NativeSrtWorkspaceCommandSandboxOptions { + workspaceIdentity?: WorkspaceRootIdentity; workspaceRoot: string; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -343,6 +347,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } const root = await realpath(this.options.workspaceRoot); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(root, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before sandbox admission', 'REGISTRATION_INVALID'); + } if (!(await stat(root)).isDirectory()) { throw new WorkspaceToolError( 'Native sandbox workspace is unavailable', @@ -573,7 +580,20 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox executionDirectory: string, signal?: AbortSignal, ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity, + () => this.createBoundProgrammaticProbeWorkspace(executionDirectory, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + throw error; + } + } + + private async createBoundProgrammaticProbeWorkspace(executionDirectory: string, signal?: AbortSignal): Promise { await this.initialize(); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + } const scratchDirectory = this.scratchDirectory; const root = this.canonicalRoot; let parent: string; @@ -608,8 +628,11 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.platform === 'darwin' ? ['-cR', root, destination] : ['--archive', '--reflink=always', root, destination]; + const identity = this.options.workspaceIdentity; + const copyArgs = identity ? [...args.slice(0, -2), '.', destination] : args; await new Promise((resolveCopy, rejectCopy) => { - const child = this.spawnCommand('/bin/cp', args, { + const child = spawnWithinWorkspace(this.spawnCommand, '/bin/cp', copyArgs, { + ...(identity ? { cwd: root } : {}), env: { PATH: this.environment.PATH, LANG: this.environment.LANG, @@ -800,6 +823,26 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox sandboxScratchDirectory?: string, workspaceRoot?: string, ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, workspaceRoot ? undefined : this.options.workspaceIdentity, + () => this.executeBound(request, signal, trustedEnvironment, customConfig, sandboxScratchDirectory, workspaceRoot)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceExecuteCommandRequest, + signal?: AbortSignal, + trustedEnvironment?: NodeJS.ProcessEnv, + customConfig?: Partial, + sandboxScratchDirectory?: string, + workspaceRoot?: string, + ): Promise { + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed after sandbox admission', 'REGISTRATION_INVALID'); + } if ( !isWorkspaceToolRequest(request) || request.operation !== 'execute_command' @@ -819,8 +862,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const root = workspaceRoot ?? this.canonicalRoot!; let cwd: string; try { - cwd = await realpath(resolve(root, request.cwd ?? '.')); - if (!isWithin(root, cwd) || !(await stat(cwd)).isDirectory()) + cwd = await rootedRealpath(resolve(root, request.cwd ?? '.')); + if (!isWithin(root, cwd) || !(await rootedStat(cwd)).isDirectory()) throw new Error('invalid cwd'); } catch { throw new WorkspaceToolError( @@ -939,7 +982,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox (resolvePromise, reject) => { let child: ChildProcessWithoutNullStreams; try { - child = this.spawnCommand( + child = spawnWithinWorkspace(this.spawnCommand, wrapped.argv[0], wrapped.argv.slice(1), { diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts new file mode 100644 index 00000000..f88e523f --- /dev/null +++ b/packages/code/src/project-roots.test.ts @@ -0,0 +1,383 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { + mkdtemp, + mkdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; +import type { BridgeWorkerCapabilities } from './protocol.js'; +import { WorkspaceRootAccess } from './root-access.js'; + +const exec = promisify(execFile); +test('admission validates the captured root even while a replacement checkout occupies its path', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await writeFile( + join(selected, '.git/commondir'), + '../../nested/api/.git\n', + ); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/, + ); + assert.equal( + await readFile(join(selected, '.git/commondir'), 'utf8'), + '../../nested/api/.git\n', + ); +}); + +test('admission cannot borrow a replacement checkout Git validity', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await rm(join(selected, '.git/HEAD')); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /standalone Git checkout/, + ); +}); +test( + 'real worker CLI registers only explicitly selected project roots', + { timeout: 10000 }, + async t => { + const root = await fixture(t); + let accept: (capabilities: BridgeWorkerCapabilities) => void = () => {}; + const registered = new Promise(resolve => { + accept = resolve; + }); + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + response.setHeader('Content-Type', 'application/json'); + if (request.url?.endsWith('/bridge/workers/register')) { + const body = JSON.parse( + Buffer.concat(chunks).toString(), + ) as { + workerId: string; + incarnationId: string; + capabilities: BridgeWorkerCapabilities; + }; + accept(body.capabilities); + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + incarnationId: body.incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60000, + }), + ); + } else + response.end( + JSON.stringify({ + protocolVersion: 1, + serverElapsedMs: 0, + }), + ); + }); + }); + await new Promise(resolve => + server.listen(0, '127.0.0.1', resolve), + ); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + '--project', + 'nested/api', + ], + { + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: `http://127.0.0.1:${address.port}/v1`, + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + stdio: 'ignore', + }, + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + const capabilities = await registered; + child.kill(); + await once(child, 'exit'); + assert.deepEqual( + capabilities.workspaceTools?.workspaces?.map( + workspace => workspace.name, + ), + ['app', 'nested/api'], + ); + assert.ok( + capabilities.workspaceTools?.workspaces?.every(workspace => + workspace.id.startsWith('project-'), + ), + ); + assert.equal(JSON.stringify(capabilities).includes(root), false); + }, +); + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'selected-projects-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const name of ['app', 'nested/api']) { + await mkdir(join(root, name), { recursive: true }); + await exec('git', ['init', '--initial-branch=dev', join(root, name)]); + } + return root; +} + +test('selected projects retain identity across order and branch changes without granting their parent', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + assert.deepEqual( + selected.map(project => project.name), + ['app', 'nested/api'], + ); + assert.ok( + selected.every(project => project.root !== root && !project.writable), + ); + await exec('git', [ + '-C', + join(root, 'app'), + 'symbolic-ref', + 'HEAD', + 'refs/heads/next', + ]); + const restarted = await loadProjectRoots(root, ['nested/api', 'app']); + assert.equal(restarted[1].id, selected[0].id); + assert.equal(restarted[0].id, selected[1].id); + const otherRoot = await fixture(t); + assert.notEqual( + (await loadProjectRoots(otherRoot, ['app']))[0].id, + selected[0].id, + ); +}); + +test('real file operations use the selected project boundary', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + const tools = await LocalWorkspaceTools.create({ + workspaces: selected.map(project => ({ ...project, writable: true })), + }); + await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected[1].id, + path: 'created.txt', + content: 'second project', + }); + assert.equal( + await readFile(join(root, 'nested/api/created.txt'), 'utf8'), + 'second project', + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: selected[0].id, + path: '../nested/api/created.txt', + }), + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'nested/api/created.txt', + }), + ); + await assert.rejects(readFile(join(root, 'app/created.txt'))); +}); + +test('rejects missing, escaped, aliased, duplicate and linked-worktree selections', async t => { + const root = await fixture(t); + await mkdir(join(root, 'linked')); + await writeFile( + join(root, 'linked/.git'), + 'gitdir: ../app/.git/worktrees/linked\n', + ); + await symlink(join(root, 'app'), join(root, 'alias'), 'dir'); + for (const projects of [ + [], + ['..'], + ['/tmp'], + ['missing'], + ['alias'], + ['linked'], + ['app', './app'], + Array(33).fill('app'), + ]) { + await assert.rejects(loadProjectRoots(root, projects)); + } + await assert.rejects( + loadProjectRoots(root, ['.']), + /standalone Git checkout/, + ); +}); + +test('project CLI arguments require explicit bounded selections', () => { + assert.equal(projectRootArguments(['run']), undefined); + assert.deepEqual( + projectRootArguments([ + 'run', + '--project-root=/srv/projects', + '--project', + 'app', + '--project=nested/api', + ]), + { root: '/srv/projects', projects: ['app', 'nested/api'] }, + ); + for (const args of [ + ['--project-root'], + ['--project-root=/srv'], + ['--project=app'], + ['--project-root=/srv', '--project-root=/other', '--project=app'], + ['--project-root=/srv', '--project', '--allow-workspace-writes'], + ]) { + assert.throws(() => projectRootArguments(args)); + } +}); + +test('rejects a directory-form git marker redirecting to shared metadata', async t => { + const root = await fixture(t); + await writeFile( + join(root, 'app/.git/commondir'), + '../../nested/api/.git\n', + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/, + ); +}); + +test('replacement after selection cannot become a file or native execution root', async t => { + const root = await fixture(t); + const outside = await fixture(t); + const [selected] = await loadProjectRoots(root, ['app']); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ ...selected, writable: true }], + }); + await rename(selected.root, `${selected.root}-previous`); + await symlink(join(outside, 'app'), selected.root, 'dir'); + await assert.rejects( + LocalWorkspaceTools.create({ workspaces: [selected] }), + /Invalid workspace registration/, + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected.id, + path: 'escaped.txt', + content: 'blocked', + }), + /changed after admission/, + ); + const executor = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: selected.root, + workspaceIdentity: selected.identity, + }); + try { + await assert.rejects(executor.prepare()); + } finally { + await executor.close().catch(() => undefined); + } + await assert.rejects(readFile(join(outside, 'app/escaped.txt')), { + code: 'ENOENT', + }); +}); + +test('CLI rejects mixed registration and overlapping selected projects before connecting', async t => { + const root = await fixture(t); + await exec('git', ['init', '--initial-branch=dev', root]); + for (const extra of [ + ['--worker-dir', root], + ['--project', '.'], + ]) { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + ...extra, + ], + { + encoding: 'utf8', + timeout: 5000, + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1', + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /cannot be combined|must not overlap/); + } +}); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts new file mode 100644 index 00000000..06e910ec --- /dev/null +++ b/packages/code/src/project-roots.ts @@ -0,0 +1,167 @@ +import { createHash } from 'node:crypto'; +import { lstat, realpath } from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; +import { + lstat as rootedLstat, + spawn, + withWorkspaceRoot, +} from './root-access.js'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { LocalWorkspaceConfig } from './workspace.js'; + +/** Validate the selected checkout itself, never rediscover it via its pathname. */ +async function validateCheckout(root: string): Promise { + const marker = await rootedLstat(resolve(root, '.git')).catch( + () => undefined, + ); + if (!marker?.isDirectory() || marker.isSymbolicLink()) + throw new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ); + const common = await rootedLstat(resolve(root, '.git', 'commondir')).catch( + error => { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error; + return undefined; + }, + ); + if (common) + throw new Error( + 'Selected projects must not share a Git common directory', + ); + await new Promise((accept, reject) => { + const child = spawn( + 'git', + [ + '--no-optional-locks', + '--git-dir=.git', + '--work-tree=.', + '-c', + 'core.fsmonitor=false', + 'rev-parse', + '--is-inside-work-tree', + ], + { + cwd: root, + env: { + PATH: process.env.PATH, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + LC_ALL: 'C', + }, + }, + ); + let output = ''; + let exceeded = false; + const timer = setTimeout(() => { + exceeded = true; + child.kill('SIGKILL'); + }, 1500); + child.stdout.on('data', (chunk: Buffer) => { + if (output.length + chunk.length > 4096) { + exceeded = true; + child.kill('SIGKILL'); + } else output += chunk.toString(); + }); + child.stderr.resume(); + child.stdin.end(); + child.once('error', reject); + child.once('close', code => { + clearTimeout(timer); + if (!exceeded && code === 0 && output.trim() === 'true') accept(); + else + reject( + new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ), + ); + }); + }); +} + +/** Explicit operator selections, not an automatically expanding execution grant. */ +export async function loadProjectRoots( + directory: string, + selections: string[], +): Promise { + if (!selections.length || selections.length > 32) + throw new Error('Choose between 1 and 32 projects'); + const root = await realpath(directory); + const paths = new Set(); + const projects: LocalWorkspaceConfig[] = []; + for (const selection of selections) { + if (!selection || isAbsolute(selection) || selection.includes('\0')) + throw new Error('Project paths must be relative to --project-root'); + const path = resolve(root, selection); + const rel = relative(root, path); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) + throw new Error('Project paths must stay inside --project-root'); + const canonical = await realpath(path); + const directoryIdentity = await lstat(path, { bigint: true }); + if (canonical !== path || !directoryIdentity.isDirectory()) + throw new Error( + 'Selected projects must be directories without symlink traversal', + ); + if (paths.has(canonical)) + throw new Error('Duplicate project selection'); + paths.add(canonical); + const portablePath = rel.split(sep).join('/') || '.'; + const identity = { + path: canonical, + dev: directoryIdentity.dev.toString(), + ino: directoryIdentity.ino.toString(), + }; + await withWorkspaceRoot(canonical, identity, () => + validateCheckout(canonical), + ); + if (!(await matchesWorkspaceRoot(canonical, identity))) + throw new Error('Selected project changed during admission'); + projects.push({ + identity, + id: `project-${createHash('sha256') + .update(`${root}\0${portablePath}`) + .digest('hex') + .slice(0, 32)}`, + name: (portablePath === '.' ? basename(root) : portablePath).slice( + 0, + 64, + ), + root: canonical, + }); + } + return projects; +} + +export function projectRootArguments( + args: string[], +): { root: string; projects: string[] } | undefined { + let root: string | undefined; + const projects: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + const flag = arg.split('=')[0]; + if (flag !== '--project-root' && flag !== '--project') continue; + const value = arg.includes('=') + ? arg.slice(flag.length + 1) + : args[++index]; + if (!value || value.startsWith('--')) + throw new Error(`${flag} requires a value`); + if (flag === '--project') projects.push(value); + else { + if (root !== undefined) + throw new Error('Only one --project-root may be supplied'); + root = value; + } + } + if (root === undefined && !projects.length) return undefined; + if (root === undefined || !projects.length) + throw new Error( + '--project-root requires at least one --project relative/path', + ); + return { root, projects }; +} diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 9199fcf0..c5353ae2 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export const BRIDGE_PROTOCOL_VERSION = 1 as const; export const BRIDGE_WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; export const BRIDGE_SANDBOX_PROFILE_MAX_LENGTH = 128; @@ -275,6 +277,7 @@ export type WorkspaceProgrammaticLanguage = 'bash'; export interface BridgeWorkspaceDescriptor { id: string; name?: string; + instructions?: RepositoryInstructionDescriptor[]; /** Optional per-workspace restriction. Omitted by protocol-v1 readers. */ operations?: BridgeWorkspaceToolOperation[]; environment?: { @@ -308,6 +311,27 @@ export interface WorkspaceReadFileRequest { path: string; startLine?: number; maxLines?: number; + /** Requests an exact bounded instruction snapshot, not a line-oriented file read. */ + instructionSha256?: string; +} + +export const REPOSITORY_INSTRUCTION_MAX_BYTES = 32 * 1024; +export interface RepositoryInstructionDescriptor { + path: 'AGENTS.md' | 'CLAUDE.md'; + /** Bytes in the bounded UTF-8 snapshot, whose digest is sha256. */ + bytes: number; + sha256: string; + truncated: boolean; +} + +export function isRepositoryInstructionDescriptor(value: unknown): value is RepositoryInstructionDescriptor { + if (value == null || typeof value !== 'object') return false; + const descriptor = value as Record; + return Object.keys(descriptor).every(key => ['path', 'bytes', 'sha256', 'truncated'].includes(key)) && + (descriptor.path === 'AGENTS.md' || descriptor.path === 'CLAUDE.md') && + Number.isSafeInteger(descriptor.bytes) && Number(descriptor.bytes) >= 0 && Number(descriptor.bytes) <= REPOSITORY_INSTRUCTION_MAX_BYTES && + typeof descriptor.sha256 === 'string' && /^[a-f0-9]{64}$/.test(descriptor.sha256) && + typeof descriptor.truncated === 'boolean'; } export interface WorkspaceReadFileResult { @@ -510,6 +534,7 @@ export type WorkspaceToolResult = | WorkspaceExecuteCommandResult; const WORKSPACE_READ_REQUEST_KEYS = new Set([ + 'instructionSha256', 'protocolVersion', 'operation', 'workspaceId', @@ -1139,6 +1164,12 @@ export function isWorkspaceToolRequest( return false; } if (request.operation === 'read_file') { + if (request.instructionSha256 !== undefined) { + return hasOnlyKeys(request, WORKSPACE_READ_REQUEST_KEYS) && + typeof request.instructionSha256 === 'string' && /^[a-f0-9]{64}$/.test(request.instructionSha256) && + (request.path === 'AGENTS.md' || request.path === 'CLAUDE.md') && + request.startLine === undefined && request.maxLines === undefined; + } return ( hasOnlyKeys(request, WORKSPACE_READ_REQUEST_KEYS) && isSafePortableRelativePath(request.path) && @@ -1282,6 +1313,13 @@ export function isWorkspaceToolResult( } if (request.operation === 'read_file') { + if (request.instructionSha256 !== undefined) { + return hasOnlyKeys(result, WORKSPACE_READ_RESULT_KEYS) && result.path === request.path && + typeof result.content === 'string' && new TextEncoder().encode(result.content).byteLength <= REPOSITORY_INSTRUCTION_MAX_BYTES && + createHash('sha256').update(result.content).digest('hex') === request.instructionSha256 && + result.startLine === 1 && result.endLine === result.content.split('\n').length && + result.nextStartLine === undefined; + } const startLine = request.startLine ?? 1; const maxLines = request.maxLines ?? 200; const content = @@ -1574,11 +1612,13 @@ export function isValidBridgeWorkspaceToolCapabilities( key !== 'id' && key !== 'name' && key !== 'operations' && + key !== 'instructions' && key !== 'environment', ) || typeof descriptor.id !== 'string' || !isValidBridgeWorkerId(descriptor.id) || workspaceIds.has(descriptor.id) || + (descriptor.instructions !== undefined && (!Array.isArray(descriptor.instructions) || descriptor.instructions.length > 1 || !descriptor.instructions.every(isRepositoryInstructionDescriptor))) || (descriptor.environment !== undefined && !isValidCodeEnvironmentDescriptor(descriptor.environment)) || (descriptor.name !== undefined && diff --git a/packages/code/src/root-access.test.ts b/packages/code/src/root-access.test.ts new file mode 100644 index 00000000..bb757464 --- /dev/null +++ b/packages/code/src/root-access.test.ts @@ -0,0 +1,359 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + WorkspaceRootAccess, + withWorkspaceRoot, + open, + realpath, + stat, + lstat, + rename, + link, + unlink, + spawn, +} from './root-access.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import type { WorkspaceToolRequest } from './protocol.js'; + +test('search-only directories support known files and command cwd without enumeration', async t => { + if (process.getuid?.() === 0) + return t.skip( + 'requires an unprivileged user to verify search permissions', + ); + const root = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-search-')), + ); + const nested = join(root, 'nested'); + await fs.mkdir(nested); + await fs.writeFile(join(nested, 'known'), 'known-value'); + const identity = await fs.stat(root, { bigint: true }); + t.after(async () => { + await fs.chmod(root, 0o700); + await fs.chmod(nested, 0o700); + await fs.rm(root, { recursive: true, force: true }); + }); + await fs.chmod(root, 0o111); + await fs.chmod(nested, 0o111); + await assert.rejects(fs.readdir(nested), { code: 'EACCES' }); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + const reader = await open(join(nested, 'known'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'known-value'); + } finally { + await reader.close(); + } + assert.equal((await stat(nested)).isDirectory(), true); + assert.equal(await realpath(nested), nested); + await new Promise((accept, reject) => { + const child = spawn('/bin/cat', ['known'], { cwd: nested }); + let output = ''; + child.stdout!.on('data', chunk => { + output += chunk.toString(); + }); + child.once('error', reject); + child.once('close', code => { + try { + assert.equal(code, 0); + assert.equal(output, 'known-value'); + accept(); + } catch (error) { + reject(error); + } + }); + }); + }, + ); +}); + +test('held roots allow internal directory links and reject external ancestors', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-links-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'root'); + await fs.mkdir(join(root, 'nested'), { recursive: true }); + await fs.mkdir(join(directory, 'outside')); + await fs.writeFile(join(root, 'nested', 'value'), 'inside'); + await fs.symlink('nested', join(root, 'inside')); + await fs.symlink('../outside', join(root, 'outside')); + const identity = await fs.stat(root, { bigint: true }); + const originalOpen = WorkspaceRootAccess.open; + let held: WorkspaceRootAccess | undefined; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + held = await originalOpen(...args); + return held; + }, + ); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + assert.equal( + (await lstat(join(root, 'inside'))).isSymbolicLink(), + true, + ); + const reader = await open(join(root, 'inside', 'value'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'inside'); + } finally { + await reader.close(); + } + await assert.rejects( + open( + join(root, 'outside', 'escape'), + constants.O_CREAT | constants.O_WRONLY, + 0o600, + ), + { code: 'EACCES' }, + ); + }, + ); + assert.equal(held?.handle.fd, -1); + assert.deepEqual(await fs.readdir(join(directory, 'outside')), []); +}); + +test('held root file operations cannot be redirected by replacing its pathname', async () => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-access-')), + ); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'original'); + const identity = await fs.stat(root, { bigint: true }); + try { + await withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'replacement'); + assert.equal( + await realpath(join(root, 'original')), + join(root, 'original'), + ); + assert.equal((await stat(root)).isDirectory(), true); + assert.equal( + (await lstat(join(root, 'original'))).isFile(), + true, + ); + const reader = await open(join(root, 'original'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'original'); + } finally { + await reader.close(); + } + const writer = await open( + join(root, 'new'), + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ); + try { + await writer.writeFile('new'); + await writer.sync(); + } finally { + await writer.close(); + } + await link(join(root, 'new'), join(root, 'linked')); + await rename(join(root, 'new'), join(root, 'renamed')); + await unlink(join(root, 'linked')); + const child = spawn('/bin/sh', ['-c', 'cat original'], { + cwd: root, + }); + let output = ''; + let error = ''; + child.stdout.on('data', chunk => { + output += chunk; + }); + child.stderr.on('data', chunk => { + error += chunk; + }); + child.stdin.end(); + const code = await new Promise(resolve => + child.on('close', resolve), + ); + assert.equal(code, 0, error); + assert.equal(output, 'original'); + }, + ); + assert.equal( + await fs.readFile(join(root, 'original'), 'utf8'), + 'replacement', + ); + assert.equal( + await fs.readFile(join(`${root}.old`, 'renamed'), 'utf8'), + 'new', + ); + assert.deepEqual(await fs.readdir(root), ['original']); + await assert.rejects( + WorkspaceRootAccess.open(root, { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }), + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +for (const operation of [ + 'read_file', + 'write_file', + 'edit_file', + 'list_files', + 'search_text', + 'instructions', +] as const) { + test(`selected ${operation} stays bound when the root is replaced after acquisition`, async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-caller-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original.txt'), 'original'); + await fs.writeFile(join(root, 'AGENTS.md'), 'Original instructions'); + const identity = await fs.stat(root, { bigint: true }); + const tools = await LocalWorkspaceTools.create({ + repositoryInstructions: true, + workspaces: [ + { + id: 'selected', + root, + writable: true, + identity: { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + }, + ], + }); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const access = await originalOpen(...args); + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile( + join(root, 'replacement.txt'), + 'replacement', + ); + await fs.writeFile( + join(root, 'AGENTS.md'), + 'Replacement instructions', + ); + return access; + }, + ); + if (operation === 'instructions') { + const descriptors = await tools.instructionDescriptors(); + const { createHash } = await import('node:crypto'); + assert.equal( + descriptors?.get('selected')?.[0].sha256, + createHash('sha256') + .update('Original instructions') + .digest('hex'), + ); + } else { + const request = { + protocolVersion: 1, + workspaceId: 'selected', + operation, + ...(operation === 'write_file' + ? { path: 'new.txt', content: 'created', overwrite: false } + : {}), + ...(operation === 'read_file' ? { path: 'original.txt' } : {}), + ...(operation === 'edit_file' + ? { + path: 'original.txt', + oldText: 'original', + newText: 'edited', + } + : {}), + ...(operation === 'search_text' ? { query: 'original' } : {}), + } as WorkspaceToolRequest; + const result = await tools.execute(request); + assert.equal( + JSON.stringify(result).includes('replacement.txt'), + false, + ); + if (operation === 'read_file') + assert.equal( + (result as { content: string }).content, + 'original', + ); + if (operation === 'write_file') + assert.equal( + await fs.readFile(join(`${root}.old`, 'new.txt'), 'utf8'), + 'created', + ); + if (operation === 'edit_file') + assert.equal( + await fs.readFile( + join(`${root}.old`, 'original.txt'), + 'utf8', + ), + 'edited', + ); + } + assert.deepEqual((await fs.readdir(root)).sort(), [ + 'AGENTS.md', + 'replacement.txt', + ]); + }); +} + +test('simultaneous roots retain independent descriptor contexts and release on failure', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-concurrency-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await Promise.all( + ['a', 'b'].map(async name => { + const root = join(directory, name); + await fs.mkdir(root); + await fs.writeFile(join(root, 'value'), name); + const identity = await fs.stat(root, { bigint: true }); + await assert.rejects( + withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + const file = await open(join(root, 'value'), 'r'); + try { + assert.equal(await file.readFile('utf8'), name); + } finally { + await file.close(); + } + throw new Error('cancelled request'); + }, + ), + /cancelled request/, + ); + }), + ); +}); diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts new file mode 100644 index 00000000..4aeb6100 --- /dev/null +++ b/packages/code/src/root-access.ts @@ -0,0 +1,460 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { spawn as spawnProcess } from 'node:child_process'; +import { constants, closeSync, fstatSync, readlinkSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import type { + SpawnOptionsWithoutStdio, + ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import type { WorkspaceRootIdentity } from './root-identity.js'; + +type NativeCall = (...args: (string | number | Buffer)[]) => number; +interface NativeLibrary { + func(signature: string): NativeCall; +} +interface NativeRuntime { + load(path: null): NativeLibrary; + errno(): number; + os: { errno: Record }; +} +let nativeRuntime: NativeRuntime | undefined; +let library: NativeLibrary | undefined; +function runtime(): NativeRuntime { + // Code API imports workspace contracts without installing native worker + // dependencies. Load the POSIX implementation only for selected roots. + return (nativeRuntime ??= createRequire(import.meta.url)( + 'koffi', + ) as NativeRuntime); +} +function bind(signature: string): NativeCall | undefined { + if (!['darwin', 'linux'].includes(process.platform)) return undefined; + let call: NativeCall | undefined; + return (...args) => { + library ??= runtime().load(null); + call ??= library.func(signature); + return call(...args); + }; +} +const nativeOpenAt = bind( + 'int openat(int dirfd, const char *path, int flags, ...)', +); +const O_CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000; +// Anchors need search, not directory enumeration permission. +const DIRECTORY_ACCESS = + constants.O_DIRECTORY | + (process.platform === 'darwin' + ? 0x40000000 /* O_SEARCH */ + : 0x200000) /* O_PATH */; +const openAt = nativeOpenAt + ? (fd: number, path: string, flags: number, mode: number): number => + nativeOpenAt(fd, path, flags | O_CLOEXEC, 'unsigned int', mode) + : undefined; +const renameAt = bind( + 'int renameat(int fromfd, const char *from, int tofd, const char *to)', +); +const linkAt = bind( + 'int linkat(int fromfd, const char *from, int tofd, const char *to, int flags)', +); +const unlinkAt = bind('int unlinkat(int dirfd, const char *path, int flags)'); +const getPath = + process.platform === 'darwin' + ? bind('int fcntl(int fd, int command, ...)') + : undefined; + +function nativeError(): NodeJS.ErrnoException { + const errno = runtime().errno(); + const code = + Object.entries(runtime().os.errno).find( + ([, value]) => value === errno, + )?.[0] ?? 'EIO'; + return Object.assign( + new Error(`Workspace descriptor access failed: ${code}`), + { code }, + ); +} + +function checked(fd: number): number { + if (fd < 0) throw nativeError(); + return fd; +} + +function descriptorPath(fd: number): string { + return process.platform === 'linux' + ? `/proc/self/fd/${fd}` + : `/dev/fd/${fd}`; +} + +function physicalPath(fd: number): string { + if (process.platform === 'linux') return readlinkSync(descriptorPath(fd)); + const buffer = Buffer.alloc(1024); + if (!getPath || getPath(fd, 50 /* F_GETPATH */, 'void *', buffer) !== 0) + throw nativeError(); + return buffer.subarray(0, buffer.indexOf(0)).toString(); +} + +function offset(root: string, path: string): string { + const value = relative(root, path); + if (isAbsolute(value) || value === '..' || value.startsWith(`..${sep}`)) { + throw Object.assign(new Error('Path is outside the held workspace'), { + code: 'EACCES', + }); + } + return value || '.'; +} + +/** A request owns one directory descriptor, not a replaceable pathname grant. */ +export class WorkspaceRootAccessError extends Error {} +export class WorkspaceRootAccess { + private constructor( + readonly path: string, + readonly handle: fs.FileHandle, + ) {} + + static async open( + path: string, + identity: WorkspaceRootIdentity, + ): Promise { + if (!openAt || path !== identity.path) + throw new WorkspaceRootAccessError( + 'Selected project root access is unavailable', + ); + const handle = await fs + .open(path, DIRECTORY_ACCESS | constants.O_NOFOLLOW) + .catch(() => { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + }); + try { + const current = await handle.stat({ bigint: true }); + if ( + !current.isDirectory() || + current.dev.toString() !== identity.dev || + current.ino.toString() !== identity.ino + ) { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + } + return new WorkspaceRootAccess(path, handle); + } catch (error) { + await handle.close(); + throw error; + } + } + + close(): Promise { + return this.handle.close(); + } + + directory(path: string): number { + const fd = checked( + openAt!( + this.handle.fd, + offset(this.path, path), + DIRECTORY_ACCESS, + 0, + ), + ); + try { + this.assertDirectoryAncestor(fd); + this.canonical(fd); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } + } + + private canonical(fd: number): string { + return resolve( + this.path, + offset(physicalPath(this.handle.fd), physicalPath(fd)), + ); + } + + /** Paths are presentation, not proof of ancestry: a renamed root can make + * an outside symlink target temporarily occupy its old textual prefix. */ + private assertDirectoryAncestor(fd: number): void { + const root = fstatSync(this.handle.fd, { bigint: true }); + let current = fd; + try { + for (let depth = 0; depth <= 128; depth++) { + const identity = fstatSync(current, { bigint: true }); + if (identity.dev === root.dev && identity.ino === root.ino) + return; + const parent = checked( + openAt!( + current, + '..', + DIRECTORY_ACCESS | constants.O_NOFOLLOW, + 0, + ), + ); + if (current !== fd) closeSync(current); + current = parent; + const ancestor = fstatSync(parent, { bigint: true }); + if ( + ancestor.dev === identity.dev && + ancestor.ino === identity.ino + ) + break; + } + throw Object.assign( + new Error( + 'Directory is outside the held workspace or exceeds its ancestry limit', + ), + { code: 'EACCES' }, + ); + } finally { + if (current !== fd) closeSync(current); + } + } + + private parent(path: string): { fd: number; name: string } { + const local = offset(this.path, path); + const fd = checked( + openAt!(this.handle.fd, dirname(local), DIRECTORY_ACCESS, 0), + ); + try { + this.assertDirectoryAncestor(fd); + this.canonical(fd); + return { fd, name: basename(local) }; + } catch (error) { + closeSync(fd); + throw error; + } + } + + async openFile( + path: string, + flags: number, + mode = 0o666, + ): Promise { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = checked(openAt!(parent.fd, parent.name, flags, mode)); + // Node owns the duplicate, so callers retain native FileHandle semantics + // for asynchronous I/O, fsync, ownership and deterministic close. + const accessMode = flags & (constants.O_WRONLY | constants.O_RDWR); + const duplicate = await fs.open( + descriptorPath(fd), + accessMode | constants.O_NONBLOCK, + ); + try { + const expected = fstatSync(fd, { bigint: true }); + const actual = await duplicate.stat({ bigint: true }); + if (expected.dev !== actual.dev || expected.ino !== actual.ino) + throw new Error('Descriptor duplication changed identity'); + return duplicate; + } catch (error) { + await duplicate.close(); + throw error; + } + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + stat(path: string, follow = true): ReturnType { + const parent = this.parent(path); + let fd: number | undefined; + try { + const flags = + process.platform === 'linux' + ? 0x200000 /* O_PATH */ | + (follow ? 0 : constants.O_NOFOLLOW) + : 0x8000 /* O_EVTONLY */ | + (follow ? 0 : 0x200000); /* O_SYMLINK */ + fd = this.metadataDescriptor(parent.fd, parent.name, flags); + return fstatSync(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + private metadataDescriptor( + parent: number, + name: string, + flags: number, + ): number { + if (process.platform === 'darwin') { + // O_EVTONLY still requests read permission on a directory. Search-only + // descriptors preserve known-path metadata/cwd access without enumeration. + const directory = openAt!( + parent, + name, + DIRECTORY_ACCESS | + (flags & 0x200000 /* O_SYMLINK */ + ? constants.O_NOFOLLOW + : 0), + 0, + ); + if (directory >= 0) return directory; + const error = nativeError(); + if (error.code !== 'ENOTDIR' && error.code !== 'ELOOP') throw error; + } + return checked(openAt!(parent, name, flags, 0)); + } + + realpath(path: string): string { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = this.metadataDescriptor( + parent.fd, + parent.name, + process.platform === 'linux' + ? 0x200000 /* O_PATH */ + : 0x8000 /* O_EVTONLY */, + ); + return this.canonical(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + install(from: string, to: string, link: boolean): void { + const source = this.parent(from); + let target: ReturnType | undefined; + try { + target = this.parent(to); + const result = link + ? linkAt!(source.fd, source.name, target.fd, target.name, 0) + : renameAt!(source.fd, source.name, target.fd, target.name); + if (result !== 0) throw nativeError(); + } finally { + closeSync(source.fd); + if (target) closeSync(target.fd); + } + } + + unlink(path: string): void { + const parent = this.parent(path); + try { + if (unlinkAt!(parent.fd, parent.name, 0) !== 0) throw nativeError(); + } finally { + closeSync(parent.fd); + } + } +} + +const context = new AsyncLocalStorage(); +export async function withWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity | undefined, + action: () => Promise, +): Promise { + if (!identity) return action(); + const access = await WorkspaceRootAccess.open(root, identity); + try { + return await context.run(access, action); + } finally { + await access.close(); + } +} + +type SpawnCommand = ( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => ChildProcessWithoutNullStreams; +export function spawnWithinWorkspace( + spawner: SpawnCommand, + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +): ChildProcessWithoutNullStreams { + const access = context.getStore(); + if (!access) return spawner(command, args, options); + if (typeof options.cwd !== 'string') + throw new Error('Workspace process requires a working directory'); + const fd = access.directory(options.cwd); + try { + const env = { ...(options.env ?? process.env) }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + return spawner( + process.execPath, + [ + fileURLToPath(new URL('./root-exec.js', import.meta.url)), + command, + ...args, + ], + { + ...options, + cwd: '/', + env, + // The trusted bootstrap consumes fd 3 before exec. Commands receive no + // root descriptor, bridge socket or new long-lived supervising process. + stdio: [ + ...(Array.isArray(options.stdio) + ? options.stdio.slice(0, 3) + : ['pipe', 'pipe', 'pipe']), + fd, + ], + } as SpawnOptionsWithoutStdio, + ); + } finally { + closeSync(fd); + } +} + +export const spawn = (( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => + spawnWithinWorkspace( + spawnProcess, + command, + args, + options, + )) as typeof spawnProcess; + +// Only workspace filesystem consumers import these adapters. Unselected legacy +// roots retain their existing behavior; concurrent selected roots never share fd state. +export const open = async ( + path: string, + flags: number | 'r', + mode?: number, +): Promise => + context + .getStore() + ?.openFile(path, flags === 'r' ? constants.O_RDONLY : flags, mode) ?? + fs.open(path, flags, mode); +export const stat = async (path: string) => + context.getStore()?.stat(path) ?? fs.stat(path); +export const lstat = async (path: string) => + context.getStore()?.stat(path, false) ?? fs.lstat(path); +export const realpath = async (path: string): Promise => + context.getStore()?.realpath(path) ?? fs.realpath(path); +export const rename = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, false); + else await fs.rename(from, to); +}; +export const link = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, true); + else await fs.link(from, to); +}; +export const unlink = async (path: string): Promise => { + const access = context.getStore(); + if (access) access.unlink(path); + else await fs.unlink(path); +}; diff --git a/packages/code/src/root-exec.ts b/packages/code/src/root-exec.ts new file mode 100644 index 00000000..4cd7132c --- /dev/null +++ b/packages/code/src/root-exec.ts @@ -0,0 +1,18 @@ +import koffi from 'koffi'; + +// Private exec trampoline. The parent supplies an already validated directory +// on fd 3. fchdir is process-local here and never changes the bridge's cwd. +const lib = koffi.load(null); +const fchdir = lib.func('int fchdir(int fd)'); +const close = lib.func('int close(int fd)'); +const execvp = lib.func('int execvp(const char *file, const char **argv)'); +const fcntl = lib.func('int fcntl(int fd, int command, ...)'); +const args = process.argv.slice(2); +if (args.length === 0 || fchdir(3) !== 0 || close(3) !== 0) process.exit(125); +// Node marks its standard streams close-on-exec during startup. Preserve only +// the three conventional streams; every internal descriptor stays closed. +for (const fd of [0, 1, 2]) { + if (fcntl(fd, 2 /* F_SETFD */, 'int', 0) !== 0) process.exit(125); +} +execvp(args[0], [...args, null]); +process.exit(126); diff --git a/packages/code/src/root-identity.ts b/packages/code/src/root-identity.ts new file mode 100644 index 00000000..3ddfe453 --- /dev/null +++ b/packages/code/src/root-identity.ts @@ -0,0 +1,27 @@ +import { lstat, realpath } from 'node:fs/promises'; + +export interface WorkspaceRootIdentity { + path: string; + dev: string; + ino: string; +} + +/** Revalidation of a trusted snapshot, never a fresh grant to a replacement. */ +export async function matchesWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity +): Promise { + if (root !== identity.path) return false; + try { + const current = await lstat(root, { bigint: true }); + return ( + current.isDirectory() && + !current.isSymbolicLink() && + current.dev.toString() === identity.dev && + current.ino.toString() === identity.ino && + (await realpath(root)) === identity.path + ); + } catch { + return false; + } +} diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 86e85872..697a3c55 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -23,6 +23,7 @@ import type { BridgeWorkerRegistrationResponse, BridgeWorkspaceToolOperation, BridgeWorkspaceProgrammaticRequest, + RepositoryInstructionDescriptor, } from './protocol.js'; import type { RuntimeLease, RuntimeSupervisor } from './runtime.js'; import type { WorkspaceToolExecutor } from './workspace.js'; @@ -37,6 +38,7 @@ export interface BridgeWorkerOptions { runtimeSupervisor?: RuntimeSupervisor; capabilities: BridgeWorkerCapabilities; workspaceTools?: WorkspaceToolExecutor; + instructionDescriptors?: () => Promise | undefined>; workspaceProgrammatic?: { /** * True when a WorkspaceToolError without mutation uncertainty proves the @@ -396,6 +398,7 @@ export class BridgeWorker { private readonly compatibleCapabilities: BridgeWorkerCapabilities; private registrationCapabilities: BridgeWorkerCapabilities; private activeCapabilities: BridgeWorkerCapabilities; + private instructionMetadataSupported = true; private registrationTtlMs = DEFAULT_REGISTRATION_TTL_MS; private lastRegisteredAtMs = 0; private maintenanceOnly = false; @@ -597,6 +600,8 @@ export class BridgeWorker { const registrationStartedAtMs = Date.now(); let registration: BridgeWorkerRegistrationResponse; try { + const instructions = this.instructionMetadataSupported ? await this.options.instructionDescriptors?.() : undefined; + let includeInstructions = this.instructionMetadataSupported; const register = (capabilities: BridgeWorkerCapabilities) => this.request( `${this.codeApiUrl}/bridge/workers/register`, @@ -609,12 +614,29 @@ export class BridgeWorker { ...capabilities, requiresReadyConfirmation: true, } - : capabilities, + : includeInstructions && instructions && capabilities.workspaceTools ? { + ...capabilities, + workspaceTools: { ...capabilities.workspaceTools, + workspaces: capabilities.workspaceTools.workspaces.map(workspace => ({ ...workspace, + ...((workspace.operations ?? capabilities.workspaceTools!.operations).includes('read_file') + ? { instructions: [...(instructions.get(workspace.id) ?? [])] } : {}), + })), + }, + } : capabilities, }, registrationController.signal, ); try { - registration = await register(this.registrationCapabilities); + try { + registration = await register(this.registrationCapabilities); + } catch (error) { + if (!(instructions && error instanceof BridgeProtocolError && error.status === 400)) { + throw error; + } + includeInstructions = false; + this.instructionMetadataSupported = false; + registration = await register(this.registrationCapabilities); + } } catch (error) { if ( !(error instanceof BridgeProtocolError) || diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 91e89f54..65757d39 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,10 +1,11 @@ -import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; +import { link, lstat, open, realpath, rename, stat, unlink, spawn, withWorkspaceRoot, WorkspaceRootAccessError } from './root-access.js'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; import { BRIDGE_PROTOCOL_VERSION, @@ -45,6 +46,7 @@ import type { } from './protocol.js'; export { isWorkspaceToolRequest, isWorkspaceToolResult }; +import { readRepositoryInstructions } from './instructions.js'; export type { WorkspaceReadFileRequest, WorkspaceReadFileResult, @@ -66,6 +68,7 @@ export type { }; export interface LocalWorkspaceConfig { + identity?: WorkspaceRootIdentity; id: string; name?: string; root: string; @@ -75,6 +78,7 @@ export interface LocalWorkspaceConfig { export interface LocalWorkspaceToolsOptions { workspaces: LocalWorkspaceConfig[]; + repositoryInstructions?: boolean; } export interface WorkspaceToolExecutor { @@ -334,6 +338,7 @@ async function readConfinedFile( } interface WorkspaceRoot { + identity?: WorkspaceRootIdentity; root: string; writable: boolean; } @@ -1429,6 +1434,16 @@ async function withinListDeadline( export class LocalWorkspaceTools implements WorkspaceToolExecutor { readonly capabilities: BridgeWorkspaceToolCapabilities; readonly mutationFailuresAreAtomic = true as const; + private repositoryInstructions = false; + + async instructionDescriptors() { + if (!this.repositoryInstructions) return undefined; + const entries = await Promise.all([...this.roots].map(async ([id, { root, identity }]) => { + const snapshot = await withWorkspaceRoot(root, identity, () => readRepositoryInstructions(root)); + return [id, snapshot ? [snapshot.descriptor] : []] as const; + })); + return new Map(entries); + } private constructor( private readonly roots: ReadonlyMap, @@ -1496,6 +1511,8 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { let canonicalRoot: string; try { canonicalRoot = await realpath(workspace.root); + if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); + await withWorkspaceRoot(canonicalRoot, workspace.identity, async () => undefined); if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); } catch { throw new WorkspaceToolError( @@ -1504,11 +1521,12 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); } roots.set(workspace.id, { + identity: workspace.identity, root: canonicalRoot, writable: workspace.writable === true, }); } - return new LocalWorkspaceTools( + const tools = new LocalWorkspaceTools( roots, operations, workspaces, @@ -1517,11 +1535,27 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { capabilities.editFileFeatures, capabilities.listFileFeatures, ); + tools.repositoryInstructions = options.repositoryInstructions === true; + return tools; } async execute( request: WorkspaceToolRequest, signal?: AbortSignal, + ): Promise { + const workspace = this.roots.get(request?.workspaceId); + if (!workspace?.identity) return this.executeBound(request, signal); + try { + return await withWorkspaceRoot(workspace.root, workspace.identity, () => this.executeBound(request, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceToolRequest, + signal?: AbortSignal, ): Promise { if (signal?.aborted) { throw new WorkspaceToolError( @@ -1579,6 +1613,15 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); } + if (request.instructionSha256 !== undefined) { + const snapshot = this.repositoryInstructions ? await readRepositoryInstructions(root) : undefined; + if (!snapshot || snapshot.descriptor.path !== request.path || snapshot.descriptor.sha256 !== request.instructionSha256) { + throw new WorkspaceToolError('Repository instructions changed or are unavailable', 'INVALID_PATH'); + } + return { protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'read_file', workspaceId: request.workspaceId, + path: request.path, content: snapshot.content, startLine: 1, endLine: snapshot.content.split('\n').length, + truncated: snapshot.descriptor.truncated }; + } const startLine = request.startLine ?? 1; const maxLines = request.maxLines ?? 200; if ( diff --git a/service/src/bridge/concurrent-worker.test.ts b/service/src/bridge/concurrent-worker.test.ts index ea5ac601..c107576a 100644 --- a/service/src/bridge/concurrent-worker.test.ts +++ b/service/src/bridge/concurrent-worker.test.ts @@ -281,7 +281,7 @@ for (const failure of [ ).rejects.toMatchObject({ code: failure === 'delivery-outage' - ? 'ASSIGNMENT_EXPIRED' + ? 'WORKSPACE_QUEUE_TIMEOUT' : 'WORKSPACE_QUARANTINED', }); await expect( diff --git a/service/src/bridge/fleet.test.ts b/service/src/bridge/fleet.test.ts new file mode 100644 index 00000000..3eac5088 --- /dev/null +++ b/service/src/bridge/fleet.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from 'bun:test'; +import Redis from 'ioredis'; +import { randomUUID } from 'node:crypto'; +import { RedisBridgeStore } from './store'; +import type { CodeBridgeAssignment } from './store'; + +/** Opt-in integration check against a disposable Redis, never a deployment database. */ +test.skipIf(!process.env.BRIDGE_TEST_REDIS_URL)( + 'admission saturation is isolated across machines and independent roots', + async () => { + const redis = new Redis(process.env.BRIDGE_TEST_REDIS_URL!); + const store = new RedisBridgeStore(redis, 60, 1000, 2); + const prefix = `fleet-${randomUUID()}`; + const machines = [`${prefix}-a`, `${prefix}-b`]; + const incarnationId = 'fleet-incarnation'; + const pending: Promise[] = []; + const controller = new AbortController(); + const dispatch = ( + workerId: string, + workspaceId: string, + budgetMs = 3000, + ) => { + const result = store.dispatchWorkspaceTool({ + workerId, + signal: controller.signal, + deadlineAtMs: Date.now() + budgetMs, + executionTimeoutMs: 5000, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId, + path: 'probe', + }, + }); + void result.catch(() => undefined); + pending.push(result); + return result; + }; + const settle = async (assignment: CodeBridgeAssignment) => { + await store.acknowledgeLease( + assignment.workerId, + incarnationId, + assignment.assignmentId, + assignment.generation, + assignment.leaseToken, + ); + await store.settle(assignment.workerId, assignment.assignmentId, { + protocolVersion: 1, + incarnationId, + generation: assignment.generation, + leaseToken: assignment.leaseToken, + status: 'rejected', + error: 'probe complete', + }); + }; + try { + for (const workerId of machines) { + const generation = await store.register({ + protocolVersion: 1, + workerId, + incarnationId, + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'native-srt', + runtimes: [], + workspaceLeaseSlots: 2, + requiresReadyConfirmation: true, + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'a' }, { id: 'b' }], + }, + }, + }); + await store.confirmReady(workerId, incarnationId, generation); + } + const busy = dispatch(machines[0], 'a'); + const held = await store.lease( + machines[0], + incarnationId, + 1000, + undefined, + undefined, + 0, + ); + expect(held).toBeDefined(); + const blocked = dispatch(machines[0], 'a', 300); + const independent = dispatch(machines[0], 'b'); + const otherMachine = dispatch(machines[1], 'a'); + const root = await store.lease( + machines[0], + incarnationId, + 1000, + undefined, + undefined, + 1, + ); + const remote = await store.lease( + machines[1], + incarnationId, + 1000, + undefined, + undefined, + 0, + ); + expect(root?.request).toMatchObject({ workspaceId: 'b' }); + expect(remote?.workerId).toBe(machines[1]); + await settle(root!); + await settle(remote!); + await Promise.all([independent, otherMachine]); + await expect(blocked).rejects.toMatchObject({ + code: 'WORKSPACE_QUEUE_TIMEOUT', + }); + await settle(held!); + await busy; + expect( + await store.lease( + machines[0], + incarnationId, + 20, + undefined, + undefined, + 0, + ), + ).toBeUndefined(); + } finally { + controller.abort(); + await Promise.allSettled(pending); + await redis.quit(); + } + }, +); diff --git a/service/src/bridge/instructions.test.ts b/service/src/bridge/instructions.test.ts new file mode 100644 index 00000000..63edf614 --- /dev/null +++ b/service/src/bridge/instructions.test.ts @@ -0,0 +1,123 @@ +import { createServer } from 'node:http'; +import { mkdtemp, realpath, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'bun:test'; +import express from 'express'; +import RedisMock from 'ioredis-mock'; +import type Redis from 'ioredis'; +import { BridgeWorker } from '../../../packages/code/src/worker'; +import { LocalWorkspaceTools } from '../../../packages/code/src/workspace'; +import { applyPrincipal } from '../auth/principal'; +import { createWorkspaceToolsRouter } from '../workspace-tools/router'; +import { createBridgeRouter } from './router'; +import { RedisBridgeStore } from './store'; +import { RedisBridgePairingStore } from './pairing'; + +test('repository snapshots traverse the HTTP bridge and real workspace executor', async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'instruction-http-')), + ); + const redis = new RedisMock() as unknown as Redis; + const store = new RedisBridgeStore(redis); + const controller = new AbortController(); + const app = express(); + app.use(express.json()); + app.use( + '/v1/bridge', + createBridgeRouter({ + store, + pairings: new RedisBridgePairingStore(redis), + authMode: 'static', + adminToken: 'local-test-only', + configuredWorkerId: 'instructions-worker', + }), + ); + app.use( + '/v1', + (req, _res, next) => { + applyPrincipal(req, { + userId: 'test-user', + tenantId: 'test-tenant', + principalSource: 'librechat_jwt', + }); + next(); + }, + createWorkspaceToolsRouter({ + backend: 'remote-bridge', + configuredWorkerId: 'instructions-worker', + dynamicWorkers: false, + store, + }), + ); + const server = createServer(app); + let running: Promise | undefined; + try { + await new Promise(resolve => + server.listen(0, '127.0.0.1', resolve), + ); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Missing address'); + const base = `http://127.0.0.1:${address.port}/v1`; + await writeFile( + join(root, 'AGENTS.md'), + 'Exact\r\nrepository guidance\n', + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + repositoryInstructions: true, + }); + const worker = new BridgeWorker({ + codeApiUrl: base, + token: 'local-test-only', + workerId: 'instructions-worker', + sandboxEndpoint: 'http://127.0.0.1:1/api/v2', + leaseWaitMs: 50, + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'test', + runtimes: [], + workspaceTools: tools.capabilities, + }, + workspaceTools: tools, + instructionDescriptors: () => tools.instructionDescriptors(), + }); + await worker.register(); + const status = await fetch( + `${base}/bridge/workers/instructions-worker/status`, + { headers: { Authorization: 'Bearer local-test-only' } }, + ).then(r => r.json()); + const descriptor = + status.capabilities.workspaceTools.workspaces[0].instructions[0]; + expect(descriptor.path).toBe('AGENTS.md'); + running = worker.run(controller.signal); + const request = { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'AGENTS.md', + instructionSha256: descriptor.sha256, + }; + const read = () => + fetch(`${base}/workspace-tools/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + const response = await read(); + expect(response.status).toBe(200); + expect((await response.json()).content).toBe( + 'Exact\r\nrepository guidance\n', + ); + await writeFile(join(root, 'AGENTS.md'), 'changed'); + expect((await read()).status).toBe(422); + } finally { + controller.abort(); + await running; + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + redis.disconnect(); + await rm(root, { recursive: true, force: true }); + } +}, 10000); diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index d6b91469..4eaabd7f 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -47,6 +47,7 @@ export class BridgeStoreError extends Error { | 'WORKER_UNAUTHORIZED' | 'WORKER_BUSY' | 'WORKER_QUEUE_FULL' + | 'WORKSPACE_QUEUE_TIMEOUT' | 'ASSIGNMENT_EXPIRED' | 'ASSIGNMENT_FENCED' | 'ASSIGNMENT_NOT_FOUND' @@ -838,6 +839,7 @@ export class RedisBridgeStore { ); const lockIncarnationId = registration.incarnationId; let assignment: StoredAssignment | undefined; + let enqueueAttempted = false; let workspaceLeaseSlot: number | undefined; const selectedWorkspaceId = args.workspaceRequest?.workspaceId ?? args.workspaceId; @@ -1024,12 +1026,14 @@ export class RedisBridgeStore { this.assertDispatchActive(args.signal, args.deadlineAtMs); assignment.incarnationId = registration.incarnationId; queued = await this.dispatchCommand( - () => - this.enqueueForActiveIncarnation( + () => { + enqueueAttempted = true; + return this.enqueueForActiveIncarnation( assignment!, ttlSeconds, readyToken, - ), + ); + }, args, 'Bridge assignment enqueue', ); @@ -1130,6 +1134,18 @@ export class RedisBridgeStore { } throw error; } + } catch (error) { + // Once enqueue starts, even a lost Redis response may hide execution. + if ( + admission != null && !enqueueAttempted && !args.signal.aborted && + error instanceof BridgeStoreError && error.code === 'ASSIGNMENT_EXPIRED' + ) { + throw new BridgeStoreError( + 'WORKSPACE_QUEUE_TIMEOUT', + 'Workspace capacity was unavailable before the queue deadline. The operation was not started. Wait for active work to finish or select an independent workspace on a machine with available capacity.', + ); + } + throw error; } finally { if (admission != null) { // Expiry remains the fallback if Redis is unavailable during cancellation. diff --git a/service/src/bridge/worker-admission.test.ts b/service/src/bridge/worker-admission.test.ts index db2e5956..655748b1 100644 --- a/service/src/bridge/worker-admission.test.ts +++ b/service/src/bridge/worker-admission.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from 'bun:test'; +import { afterEach, expect, spyOn, test } from 'bun:test'; import RedisMock from 'ioredis-mock'; import type Redis from 'ioredis'; import { BRIDGE_PROTOCOL_VERSION } from '../../../packages/code/src/protocol'; @@ -114,7 +114,7 @@ test('an expired queued call never reaches the worker and does not strand later const assignment = await store.lease(workerId, incarnationId, 1000); await expect( dispatch('expired', new AbortController(), 25, 1000), - ).rejects.toMatchObject({ code: 'ASSIGNMENT_EXPIRED' }); + ).rejects.toMatchObject({ code: 'WORKSPACE_QUEUE_TIMEOUT' }); const third = dispatch('third'); await settle(assignment); await first; @@ -170,3 +170,23 @@ test('execution expires independently of an unused queue allowance', async () => expect(Date.parse(assignment!.expiresAt) - Date.now()).toBeLessThanOrEqual(150); await expect(completion).rejects.toMatchObject({ code: 'ASSIGNMENT_EXPIRED' }); }); + +test('expiry after generation allocation but before enqueue is definitely not started', async () => { + await register(); + const now = Date.now; + const incr = redis.incr.bind(redis); + let expired = false; + const clock = spyOn(Date, 'now').mockImplementation(() => now() + (expired ? 10_000 : 0)); + const generation = spyOn(redis, 'incr').mockImplementation(async (key) => { + const value = await incr(key); + expired = true; + return value; + }); + try { + await expect(dispatch('not-enqueued')).rejects.toMatchObject({ code: 'WORKSPACE_QUEUE_TIMEOUT' }); + } finally { + clock.mockRestore(); + generation.mockRestore(); + } + expect(await store.lease(workerId, incarnationId, 20)).toBeUndefined(); +}); diff --git a/service/src/sandbox-backend/remote-bridge.test.ts b/service/src/sandbox-backend/remote-bridge.test.ts index 697271ee..4f50898e 100644 --- a/service/src/sandbox-backend/remote-bridge.test.ts +++ b/service/src/sandbox-backend/remote-bridge.test.ts @@ -108,6 +108,7 @@ describe('RemoteBridgeSandboxBackend', () => { WORKER_UNAUTHORIZED: ['BRIDGE_WORKER_UNAUTHORIZED', false, 403, 'Code environment is not authorized for this tenant'], WORKER_BUSY: ['BRIDGE_WORKER_BUSY', false, 409, 'Code environment is busy'], WORKER_QUEUE_FULL: ['BRIDGE_WORKER_BUSY', false, 409, 'Code environment is busy'], + WORKSPACE_QUEUE_TIMEOUT: ['BRIDGE_WORKER_BUSY', false, 409, 'Code environment is busy'], ASSIGNMENT_EXPIRED: ['BRIDGE_DEADLINE_EXCEEDED', false, 504, 'Code environment execution timed out'], ASSIGNMENT_FENCED: ['BRIDGE_ASSIGNMENT_FENCED', false, 409, 'Code environment assignment is fenced; inspect the execution before retrying'], ASSIGNMENT_NOT_FOUND: ['BRIDGE_ASSIGNMENT_NOT_FOUND', false, 409, 'Code environment assignment is no longer available; inspect the execution before retrying'], diff --git a/service/src/sandbox-backend/remote-bridge.ts b/service/src/sandbox-backend/remote-bridge.ts index 6e06eda8..2cdbd767 100644 --- a/service/src/sandbox-backend/remote-bridge.ts +++ b/service/src/sandbox-backend/remote-bridge.ts @@ -19,6 +19,7 @@ const bridgeErrorCodes = { WORKER_UNAUTHORIZED: 'BRIDGE_WORKER_UNAUTHORIZED', WORKER_BUSY: 'BRIDGE_WORKER_BUSY', WORKER_QUEUE_FULL: 'BRIDGE_WORKER_BUSY', + WORKSPACE_QUEUE_TIMEOUT: 'BRIDGE_WORKER_BUSY', ASSIGNMENT_EXPIRED: 'BRIDGE_DEADLINE_EXCEEDED', ASSIGNMENT_FENCED: 'BRIDGE_ASSIGNMENT_FENCED', ASSIGNMENT_NOT_FOUND: 'BRIDGE_ASSIGNMENT_NOT_FOUND', diff --git a/service/src/workspace-tools/router.test.ts b/service/src/workspace-tools/router.test.ts index ae291a4b..04738b8a 100644 --- a/service/src/workspace-tools/router.test.ts +++ b/service/src/workspace-tools/router.test.ts @@ -320,6 +320,7 @@ test.each([ ['ASSIGNMENT_EXPIRED', 504], ['WORKER_OFFLINE', 503], ['WORKER_BUSY', 503], + ['WORKSPACE_QUEUE_TIMEOUT', 503], ['WORKER_MISMATCH', 409], ] as const)('logs store rejection %s with actual HTTP %i', async (errorCode, expectedStatus) => { const app = express(); @@ -360,6 +361,7 @@ test.each([ }), }); expect(response.status).toBe(expectedStatus); + expect(response.headers.get('retry-after')).toBe(errorCode === 'WORKSPACE_QUEUE_TIMEOUT' ? '1' : null); await response.text(); expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( diff --git a/service/src/workspace-tools/router.ts b/service/src/workspace-tools/router.ts index 17085963..eb89370e 100644 --- a/service/src/workspace-tools/router.ts +++ b/service/src/workspace-tools/router.ts @@ -36,6 +36,7 @@ function asyncRoute(handler: (req: AuthenticatedRequest, res: Response) => Promi } export function bridgeStoreStatus(error: BridgeStoreError): number { + if (error.code === 'WORKSPACE_QUEUE_TIMEOUT') return 503; if (error.code === 'WORKER_QUEUE_FULL') return 429; if (error.code === 'WORKER_UNAUTHORIZED') return 403; if (error.code === 'ASSIGNMENT_INVALID') return 400; @@ -172,6 +173,7 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) } catch (error) { if (error instanceof BridgeStoreError) { outcome.errorCode = error.code; + if (error.code === 'WORKSPACE_QUEUE_TIMEOUT') res.setHeader('Retry-After', '1'); res.status(bridgeStoreStatus(error)).json({ error: error.message, code: error.code,