diff --git a/.github/workflows/contextivity-candidate.yml b/.github/workflows/contextivity-candidate.yml new file mode 100644 index 0000000000000..3c99b69aa92e6 --- /dev/null +++ b/.github/workflows/contextivity-candidate.yml @@ -0,0 +1,251 @@ +name: Contextivity candidate + +on: + schedule: + - cron: "15 */3 * * *" + workflow_dispatch: + inputs: + tag: + description: Exact official pingdotgg/t3code nightly tag (vX.Y.Z-nightly.YYYYMMDD.N) + required: false + type: string + +permissions: + contents: write + issues: write + id-token: write + attestations: write + actions: read + +concurrency: + group: contextivity-candidate + cancel-in-progress: false + +jobs: + sync: + name: Sync exact upstream nightly + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + tag: ${{ steps.sync.outputs.tag }} + sha: ${{ steps.sync.outputs.sha }} + upstream_commit: ${{ steps.sync.outputs.upstream_commit }} + steps: + - name: Checkout downstream + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: package.json + + - id: discover + name: Discover official upstream nightly + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + set -eu + if test -n "${INPUT_TAG:-}"; then + echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" + else + node --experimental-strip-types contextivity/src/cli.ts discover-upstream > "$RUNNER_TEMP/upstream.json" + node -e 'const fs=require("fs"); const n=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/upstream.json","utf8")); fs.appendFileSync(process.env.GITHUB_OUTPUT, "tag="+n.tag+"\n")' + fi + + - id: sync + name: Merge exact upstream tag + run: | + set -eu + tag="${{ steps.discover.outputs.tag }}" + if ! node --experimental-strip-types contextivity/src/cli.ts sync-tag --tag "$tag" > "$RUNNER_TEMP/sync.json"; then + reason=$(node -e 'const fs=require("fs"); const s=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/sync.json","utf8")); process.stdout.write(s.reason||"sync failed")') + node --experimental-strip-types contextivity/src/cli.ts report-sync-failure --tag "$tag" --reason "$reason" || true + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "upstream_commit=$(node -e 'const fs=require("fs"); const s=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/sync.json","utf8")); process.stdout.write(s.upstreamCommit||"")')" >> "$GITHUB_OUTPUT" + + - name: Downstream unit tests + run: node --test --experimental-strip-types contextivity/src/*.test.ts + + - name: Persist synced commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + sha=$(git rev-parse HEAD) + if git merge-base --is-ancestor "$sha" "origin/${GITHUB_REF_NAME}" 2>/dev/null; then + exit 0 + fi + git push origin "HEAD:refs/heads/contextivity-sync/${GITHUB_RUN_ID}" + + verify: + name: ACP tests and server build + needs: sync + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout synced SHA + uses: actions/checkout@v6 + with: + ref: ${{ needs.sync.outputs.sha }} + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Print candidate test plan + run: node --experimental-strip-types contextivity/src/cli.ts candidate-test-plan + + - name: Generic ACP and sub-agent tests + run: | + vp test run \ + apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts \ + apps/server/src/provider/Layers/AcpRegistryProvider.test.ts \ + apps/server/src/provider/acp/AcpAdapterSupport.test.ts \ + apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts \ + apps/server/src/provider/acp/AcpRegistrySupport.test.ts \ + apps/server/src/provider/acp/AcpRuntimeModel.test.ts \ + apps/server/src/provider/acp/AcpSessionRuntime.policy.test.ts \ + apps/server/src/provider/acp/ContextivityAcpExtension.test.ts \ + apps/server/src/provider/acp/ContextivityAcpSubagentMapper.test.ts \ + packages/client-runtime/src/state/subagentRuntime.test.ts \ + apps/server/src/distribution/contextivityDistribution.test.ts \ + apps/server/src/cloud/selfUpdate.test.ts + + - name: Server typecheck and build + run: | + vp run --filter t3 typecheck + vp run --filter t3 build + + - name: Upload server dist + uses: actions/upload-artifact@v4 + with: + name: t3-server-dist + path: apps/server/dist + if-no-files-found: error + + pack: + name: Pack ${{ matrix.platform }} + needs: [sync, verify] + strategy: + fail-fast: true + matrix: + include: + - os: ubuntu-24.04 + platform: linux-x64 + - os: ubuntu-24.04-arm + platform: linux-arm64 + - os: macos-13 + platform: darwin-x64 + - os: macos-14 + platform: darwin-arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout synced SHA + uses: actions/checkout@v6 + with: + ref: ${{ needs.sync.outputs.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Build server + run: vp run --filter t3 build + + - name: Stage self-contained archive + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -eu + mkdir -p contextivity-dist + node --experimental-strip-types contextivity/src/pack-server.ts \ + --platform "$PLATFORM" \ + --out contextivity-dist \ + --upstream-version "$(node -e 'console.log(require("./apps/server/package.json").version)')" \ + --contextivity-revision "$(git rev-parse --short=7 HEAD)" + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: archive-${{ matrix.platform }} + path: contextivity-dist/t3-server-${{ matrix.platform }}.tar.gz + if-no-files-found: error + + publish: + name: Publish immutable candidate + needs: [sync, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.sync.outputs.sha }} + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: package.json + + - name: Download archives + uses: actions/download-artifact@v4 + with: + pattern: archive-* + merge-multiple: true + path: contextivity-dist + + - name: Write candidate manifest + env: + TAG: ${{ needs.sync.outputs.tag }} + SHA: ${{ needs.sync.outputs.sha }} + run: | + set -eu + node --experimental-strip-types contextivity/src/write-candidate-manifest.ts \ + --tag "$TAG" \ + --sha "${{ needs.sync.outputs.upstream_commit }}" \ + --dir contextivity-dist \ + --build-revision "${GITHUB_RUN_ID}-${GITHUB_SHA}" \ + --node-engine "$(node -e 'console.log(require("./apps/server/package.json").engines.node)')" \ + --contextivity-revision "$(git rev-parse --short=7 HEAD)" + + - name: Attest artifacts + uses: actions/attest-build-provenance@v3 + with: + subject-path: contextivity-dist/* + + - name: Publish immutable candidate release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + node --experimental-strip-types contextivity/src/cli.ts publish-candidate \ + --dir contextivity-dist \ + --repo "${{ github.repository }}" + + - name: Advance downstream branch after a successful candidate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + git push origin "${{ needs.sync.outputs.sha }}:refs/heads/${GITHUB_REF_NAME}" + git push origin --delete "contextivity-sync/${GITHUB_RUN_ID}" || true + + - name: Upload candidate assets + uses: actions/upload-artifact@v4 + with: + name: contextivity-candidate + path: contextivity-dist + if-no-files-found: error diff --git a/.github/workflows/contextivity-promote.yml b/.github/workflows/contextivity-promote.yml new file mode 100644 index 0000000000000..aa7db440e57de --- /dev/null +++ b/.github/workflows/contextivity-promote.yml @@ -0,0 +1,81 @@ +name: Contextivity promote + +on: + workflow_dispatch: + inputs: + channel: + description: Fleet pointer to advance + required: true + type: choice + options: + - nightly + - stable + version: + description: Exact candidate identity (upstreamVersion-ctx.revision) + required: true + type: string + macClientVersion: + description: Installed official Mac desktop version (must equal manifest upstreamVersion) + required: true + type: string + +permissions: + contents: write + issues: write + +concurrency: + group: contextivity-promote + cancel-in-progress: false + +jobs: + promote: + name: Manual fleet promotion + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: package.json + + - name: Download matching candidate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + mkdir -p contextivity-dist + gh release download "contextivity-candidate/${{ github.event.inputs.version }}" \ + --repo "${{ github.repository }}" \ + --dir contextivity-dist \ + --pattern "manifest.json" + + - name: Exact-version Mac client gate + run: | + set -eu + node --experimental-strip-types contextivity/src/cli.ts promote \ + --channel "${{ github.event.inputs.channel }}" \ + --manifest contextivity-dist/manifest.json \ + --version "${{ github.event.inputs.version }}" \ + --mac-client-version "${{ github.event.inputs.macClientVersion }}" \ + > contextivity-dist/pointer.json + + - name: Publish pointer without touching the official desktop app + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + channel="${{ github.event.inputs.channel }}" + tag="contextivity-${channel}" + notes="Pointer only. Install with t3-ctx updater update --channel ${channel}. Do not use the public npm t3 package." + if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + gh release delete "$tag" --repo "${{ github.repository }}" --yes --cleanup-tag + fi + gh release create "$tag" contextivity-dist/pointer.json contextivity-dist/manifest.json \ + --repo "${{ github.repository }}" \ + --title "Contextivity ${channel} pointer" \ + --notes "$notes" \ + --prerelease \ + --latest=false diff --git a/.gitignore b/.gitignore index 57262578a786d..1c10e1c99264a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ node_modules/ *.log .env* !.env.example +contextivity-dist/ diff --git a/AGENTS.md b/AGENTS.md index 784b37cc47b25..6ab2bde040e34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode, and generic ACP Registry agents) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -48,7 +48,7 @@ We need to be on the same page with terminology. When communicating, use this la - **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. - **user** means the person using T3 Code to direct coding agents. - **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. -- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. +- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, OpenCode, or a generic ACP Registry agent. - **client** means the web, desktop, or mobile UI. - **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. - **project** means an environment-local workspace record rooted at a directory. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and ACP Registry each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. diff --git a/README.md b/README.md index 8ec101387f678..3379c2b170399 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode, plus generic ACP agents such as Contextivity. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and generic ACP agents. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - ACP Registry: add a configured ACP executable in Settings (for Contextivity, `contextivity-agent --mode acp`). See [ACP Registry](./docs/user/providers-acp-registry.md) ### Try it out (install-free) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58df..dbe2e9aa87a54 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -23,6 +23,18 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "acpRegistry") { + const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + + + ); + } + if (props.provider === "grok") { const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; return ( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd85474..e0dcfabc2dc6d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -36,6 +36,23 @@ const emitStaleXAiPromptCompleteBeforeSecondHang = const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; +const rejectMcpServers = process.env.T3_ACP_REJECT_MCP === "1"; +const advertiseHttpMcp = process.env.T3_ACP_ADVERTISE_HTTP_MCP === "1"; +const emitThought = process.env.T3_ACP_EMIT_THOUGHT === "1"; +const emitToolMeta = process.env.T3_ACP_EMIT_TOOL_META === "1"; +const advertiseSubagentEvents = process.env.T3_ACP_ADVERTISE_SUBAGENT_EVENTS === "1"; +const advertiseSubagentEventsVersion = Number(process.env.T3_ACP_SUBAGENT_EVENTS_VERSION ?? "1"); +const subagentScenario = process.env.T3_ACP_SUBAGENT_SCENARIO ?? ""; +const emitUnnegotiatedSubagentEvents = process.env.T3_ACP_EMIT_SUBAGENT_EVENTS_UNNEGOTIATED === "1"; +const authMethodsEnv = process.env.T3_ACP_AUTH_METHODS; +const advertisedAuthMethods = + authMethodsEnv === undefined + ? undefined + : authMethodsEnv + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + .map((id) => ({ id, name: id })); const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -56,6 +73,254 @@ let currentFast = false; let promptCount = 0; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); +let clientAdvertisedSubagentEvents = false; +let subagentSequence = 0; +const MOCK_NOW_MS = 1_700_000_000_000; + +function clientWantsSubagentEvents(): boolean { + return clientAdvertisedSubagentEvents || emitUnnegotiatedSubagentEvents; +} + +function nextSubagentSequence(): number { + subagentSequence += 1; + return subagentSequence; +} + +function mockSubagentRecord(overrides: Record = {}): Record { + return { + agentId: "child-1", + displayName: "Scout", + typeName: "scout", + role: "scout", + state: "running", + updatedAt: MOCK_NOW_MS, + ...overrides, + }; +} + +function emitSubagentEvent(payload: Record): void { + if (!clientWantsSubagentEvents()) { + return; + } + writeJsonRpcNotification("_contextivity/subagent_event", payload); +} + +function emitSubagentScenarioOnSession(requestedSessionId: string): void { + if (!advertiseSubagentEvents && !emitUnnegotiatedSubagentEvents) { + return; + } + if (subagentScenario === "snapshot" || subagentScenario === "nested" || subagentScenario === "") { + const records = + subagentScenario === "nested" + ? [ + mockSubagentRecord({ + agentId: "child-1", + displayName: "Coordinator", + typeName: "coordinator", + role: "coordinator", + spawnDepth: 0, + lineage: ["child-1"], + }), + mockSubagentRecord({ + agentId: "child-2", + parentAgentId: "child-1", + displayName: "Worker", + typeName: "worker", + role: "worker", + spawnDepth: 1, + lineage: ["child-1", "child-2"], + description: "Inspect nested files", + }), + ] + : [ + mockSubagentRecord({ + description: "Inspect the repo", + taskSubject: "Inspect", + modelId: "gpt-5.4-mini", + latestActivity: "reading package.json", + lastToolName: "read", + }), + ]; + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "snapshot", + records, + }); + } +} + +function emitSubagentScenarioOnPrompt(requestedSessionId: string): void { + if (!advertiseSubagentEvents && !emitUnnegotiatedSubagentEvents) { + return; + } + if (subagentScenario === "malformed") { + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: "nope", + kind: "delta", + }); + return; + } + if (subagentScenario === "lifecycle") { + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "started", + records: [ + mockSubagentRecord({ + state: "spawned", + description: "Inspect the repo", + modelId: "gpt-5.4-mini", + }), + ], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "updated", + records: [ + mockSubagentRecord({ + state: "waiting_approval", + latestActivity: "waiting on permission", + }), + ], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "updated", + records: [ + mockSubagentRecord({ + state: "idle", + latestActivity: "paused", + }), + ], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "terminal", + records: [ + mockSubagentRecord({ + state: "completed", + terminal: { kind: "completed", endedAt: MOCK_NOW_MS, summary: "done" }, + }), + ], + }); + return; + } + if (subagentScenario === "sequence") { + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: 1, + kind: "delta", + change: "started", + records: [mockSubagentRecord({ state: "spawned" })], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: 1, + kind: "delta", + change: "terminal", + records: [ + mockSubagentRecord({ state: "failed", terminal: { kind: "failed", endedAt: MOCK_NOW_MS } }), + ], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: 3, + kind: "delta", + change: "updated", + records: [mockSubagentRecord({ state: "running", latestActivity: "seq-3" })], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: 2, + kind: "delta", + change: "terminal", + records: [ + mockSubagentRecord({ state: "failed", terminal: { kind: "failed", endedAt: MOCK_NOW_MS } }), + ], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: 4, + kind: "delta", + change: "terminal", + records: [ + mockSubagentRecord({ + state: "completed", + terminal: { kind: "completed", endedAt: MOCK_NOW_MS, summary: "seq-4" }, + }), + ], + }); + return; + } + if (subagentScenario === "usage") { + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "started", + records: [mockSubagentRecord({ state: "running" })], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "updated", + records: [ + mockSubagentRecord({ + state: "running", + latestActivity: "counting tokens", + usage: { totalTokens: 42, inputTokens: 10, outputTokens: 32 }, + }), + ], + }); + return; + } + if (subagentScenario === "cancel") { + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "started", + records: [mockSubagentRecord({ state: "running" })], + }); + emitSubagentEvent({ + version: 1, + sessionId: requestedSessionId, + sequence: nextSubagentSequence(), + kind: "delta", + change: "terminal", + records: [ + mockSubagentRecord({ + state: "cancelled", + terminal: { kind: "cancelled", endedAt: MOCK_NOW_MS }, + }), + ], + }); + } +} function promptIdFromRequestMeta( request: Pick, @@ -300,21 +565,53 @@ const program = Effect.gen(function* () { Effect.sync(() => { parameterizedModelPicker = request.clientCapabilities?._meta?.parameterizedModelPicker === true; + const ns = request.clientCapabilities?._meta?.contextivity; + clientAdvertisedSubagentEvents = + typeof ns === "object" && + ns !== null && + !Array.isArray(ns) && + typeof (ns as { subagentEvents?: { version?: unknown } }).subagentEvents === "object" && + (ns as { subagentEvents?: { version?: unknown } }).subagentEvents?.version === 1; return { protocolVersion: 1, - agentCapabilities: { loadSession: true }, + agentCapabilities: { + loadSession: true, + ...(advertiseHttpMcp ? { mcpCapabilities: { http: true } } : {}), + ...(advertiseSubagentEvents + ? { + _meta: { + contextivity: { + subagentEvents: { version: advertiseSubagentEventsVersion }, + }, + }, + } + : {}), + }, + ...(advertisedAuthMethods ? { authMethods: advertisedAuthMethods } : {}), }; }), ); yield* agent.handleAuthenticate(() => Effect.succeed({})); - yield* agent.handleCreateSession(() => - Effect.succeed({ - sessionId, - modes: modeState(), - models: modelState(), - configOptions: configOptions(), + yield* agent.handleCreateSession((request) => + Effect.gen(function* () { + if (rejectMcpServers && Array.isArray(request.mcpServers) && request.mcpServers.length > 0) { + return yield* AcpError.AcpRequestError.invalidParams( + "Mock agent does not support mcpServers", + { + method: "session/new", + params: request, + }, + ); + } + emitSubagentScenarioOnSession(sessionId); + return { + sessionId, + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; }), ); @@ -465,6 +762,8 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + emitSubagentScenarioOnPrompt(requestedSessionId); + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { return { stopReason: "end_turn", @@ -644,6 +943,13 @@ const program = Effect.gen(function* () { rawInput: { command: ["cat", "server/package.json"], }, + ...(emitToolMeta + ? { + _meta: { + "contextivity.dev/subagent": { id: "child-1", title: "Subagent" }, + }, + } + : {}), }, }); @@ -865,6 +1171,16 @@ const program = Effect.gen(function* () { }, }); + if (emitThought) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "thinking out loud" }, + }, + }); + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 71880ec4ecfd9..847a4903b516f 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -18,6 +18,7 @@ interface HarnessOptions { readonly mode?: "web" | "desktop"; readonly managed?: boolean; readonly preflight?: "ready" | "blocked"; + readonly env?: NodeJS.ProcessEnv; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; } @@ -85,7 +86,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const config = yield* ServerConfig.ServerConfig.pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), ); - const selfUpdate = yield* ServerSelfUpdate.make().pipe( + const selfUpdate = yield* ServerSelfUpdate.make( + options.env === undefined ? {} : { env: options.env }, + ).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), @@ -107,6 +110,18 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); + it.effect("refuses upstream npm package installs on Contextivity distributions", () => + Effect.gen(function* () { + const { selfUpdate, order } = yield* makeHarness({ + env: { CONTEXTIVITY_T3_DISTRIBUTION: "1" }, + }); + expect( + (yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason, + ).toContain("t3-ctx"); + expect(order).toEqual([]); + }), + ); + it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { const web = yield* makeHarness(); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 015fd557d3bd7..21542475226d6 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -23,6 +23,10 @@ import { } from "./pinnedRuntime.ts"; import { decodeServicePreflightResult } from "./servicePreflight.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; +import { + isContextivityDistributionFromEnv, + UPSTREAM_NPM_UPDATE_REFUSAL, +} from "../distribution/contextivityDistribution.ts"; import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; const PREFLIGHT_TIMEOUT = Duration.seconds(30); @@ -30,7 +34,9 @@ const PREFLIGHT_TIMEOUT = Duration.seconds(30); export function resolveServerSelfUpdateCapability(input: { readonly desktopManaged: boolean; readonly launcherManaged: boolean; + readonly contextivityDistribution?: boolean; }): ServerSelfUpdateCapability | null { + if (input.contextivityDistribution) return null; if (input.desktopManaged) return "desktop-managed" as const; return input.launcherManaged ? ("boot-service" as const) : null; } @@ -45,7 +51,9 @@ export class ServerSelfUpdate extends Context.Service< } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} -export const make = Effect.fn("cloud.server_self_update.make")(function* () { +export const make = Effect.fn("cloud.server_self_update.make")(function* ( + options: { readonly env?: NodeJS.ProcessEnv } = {}, +) { const serverConfig = yield* ServerConfig.ServerConfig; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const runner = yield* ProcessRunner.ProcessRunner; @@ -53,9 +61,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const path = yield* Path.Path; const execPath = yield* HostProcessExecutablePath; const inFlight = yield* Ref.make(false); + const env = options.env ?? process.env; + const contextivityDistribution = isContextivityDistributionFromEnv(env); - const capability: ServerSelfUpdateCapability | null = - serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null; + const capability: ServerSelfUpdateCapability | null = resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + launcherManaged: launcher.managed, + contextivityDistribution, + }); const failWith = (reason: string, cause?: unknown) => cause === undefined ? new ServerSelfUpdateError({ reason }) @@ -64,6 +77,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void) { + if (contextivityDistribution) { + return yield* failWith(UPSTREAM_NPM_UPDATE_REFUSAL); + } if (capability === "desktop-managed") { return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", diff --git a/apps/server/src/distribution/contextivityDistribution.test.ts b/apps/server/src/distribution/contextivityDistribution.test.ts new file mode 100644 index 0000000000000..bad69e86a280c --- /dev/null +++ b/apps/server/src/distribution/contextivityDistribution.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + CONTEXTIVITY_DISTRIBUTION_ENV, + isContextivityDistributionFromEnv, + UPSTREAM_NPM_UPDATE_REFUSAL, +} from "./contextivityDistribution.ts"; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; + +describe("Contextivity distribution identity", () => { + it("detects the explicit distribution env and ignores other values", () => { + expect(isContextivityDistributionFromEnv({ [CONTEXTIVITY_DISTRIBUTION_ENV]: "1" })).toBe(true); + expect(isContextivityDistributionFromEnv({ [CONTEXTIVITY_DISTRIBUTION_ENV]: "true" })).toBe( + true, + ); + expect(isContextivityDistributionFromEnv({ [CONTEXTIVITY_DISTRIBUTION_ENV]: "0" })).toBe(false); + expect(isContextivityDistributionFromEnv({})).toBe(false); + }); + + it("suppresses boot-service self-update so clients are not offered npx t3@", () => { + expect( + resolveServerSelfUpdateCapability({ + desktopManaged: false, + launcherManaged: true, + contextivityDistribution: true, + }), + ).toBeNull(); + expect( + resolveServerSelfUpdateCapability({ + desktopManaged: false, + launcherManaged: true, + }), + ).toBe("boot-service"); + expect(UPSTREAM_NPM_UPDATE_REFUSAL).not.toMatch(/npx t3@/); + expect(UPSTREAM_NPM_UPDATE_REFUSAL).toContain("t3-ctx"); + }); +}); diff --git a/apps/server/src/distribution/contextivityDistribution.ts b/apps/server/src/distribution/contextivityDistribution.ts new file mode 100644 index 0000000000000..cf5488b19e43f --- /dev/null +++ b/apps/server/src/distribution/contextivityDistribution.ts @@ -0,0 +1,16 @@ +/** + * Fork-only Contextivity distribution detection. Official T3 clients ignore + * unknown local metadata; protocol-visible serverVersion stays the upstream + * nightly from package.json. This module must not rewrite that version. + */ +export const CONTEXTIVITY_DISTRIBUTION_ENV = "CONTEXTIVITY_T3_DISTRIBUTION"; + +export function isContextivityDistributionFromEnv( + env: Record = process.env, +): boolean { + const raw = env[CONTEXTIVITY_DISTRIBUTION_ENV]?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes"; +} + +export const UPSTREAM_NPM_UPDATE_REFUSAL = + "This Contextivity T3 server does not install upstream package t3@. Use t3-ctx update or t3-ctx fleet on the host instead."; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd52..4558515f5e7b9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -12,6 +12,7 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; +import { isContextivityDistributionFromEnv } from "../distribution/contextivityDistribution.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -133,6 +134,7 @@ export const make = Effect.gen(function* () { const serverSelfUpdate = resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, + contextivityDistribution: isContextivityDistributionFromEnv(process.env), }); const descriptor: ExecutionEnvironmentDescriptor = { diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 0000000000000..bd92a1ab8dbe3 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,168 @@ +import { AcpRegistrySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAcpRegistryTextGeneration } from "../../textGeneration/AcpRegistryTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAcpRegistryAdapter } from "../Layers/AcpRegistryAdapter.ts"; +import { + buildInitialAcpRegistryProviderSnapshot, + checkAcpRegistryProviderStatus, + enrichAcpRegistrySnapshot, +} from "../Layers/AcpRegistryProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); + +const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AcpRegistryDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AcpRegistryDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ACP Registry", + supportsMultipleInstances: true, + }, + configSchema: AcpRegistrySettings, + defaultConfig: (): AcpRegistrySettings => decodeAcpRegistrySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeAcpRegistryAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeAcpRegistryTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkAcpRegistryProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAcpRegistryProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichAcpRegistrySnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build ACP Registry snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts new file mode 100644 index 0000000000000..b39246af7c327 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -0,0 +1,1224 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { + AcpRegistrySettings, + ApprovalRequestId, + classifyTaskAgentKind, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + acpRegistryPromptSettlementBelongsToContext, + makeAcpRegistryAdapter, +} from "./AcpRegistryAdapter.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +async function makeMockAcpRegistryWrapper(extraEnv?: Record, argLogPath?: string) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-mock-")); + const wrapperPath = NodePath.join(dir, "fake-acp-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const argLogLine = argLogPath ? `printf '%s\\n' "$@" > ${JSON.stringify(argLogPath)}` : ""; + const script = `#!/bin/sh +${envExports} +${argLogLine} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const acpRegistryAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-acp-registry-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const isTaskRuntimeEvent = ( + event: ProviderRuntimeEvent, +): event is Extract< + ProviderRuntimeEvent, + { type: "task.started" | "task.progress" | "task.updated" | "task.completed" } +> => + event.type === "task.started" || + event.type === "task.progress" || + event.type === "task.updated" || + event.type === "task.completed"; + +const collectUntil = ( + adapter: { readonly streamEvents: Stream.Stream }, + predicate: (events: ReadonlyArray) => boolean, +) => + Effect.gen(function* () { + const runtimeEvents: ProviderRuntimeEvent[] = []; + const done = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (predicate(runtimeEvents)) { + yield* Deferred.succeed(done, undefined); + } + }), + ).pipe(Effect.forkChild); + return { runtimeEvents, done, runtimeEventsFiber }; + }); + +const initializeClientSubagentEventsVersion = ( + requests: ReadonlyArray>, +) => { + const initialize = requests.find((entry) => entry.method === "initialize"); + const params = initialize?.params as + | { + clientCapabilities?: { + _meta?: { contextivity?: { subagentEvents?: { version?: unknown } } }; + }; + } + | undefined; + return params?.clientCapabilities?._meta?.contextivity?.subagentEvents?.version; +}; + +const makeTestAdapter = ( + input: { readonly binaryPath: string } & Partial, + options?: Parameters[1], +) => makeAcpRegistryAdapter(decodeAcpRegistrySettings(input), options).pipe(Effect.orDie); + +it("requires a settlement to match the live ACP Registry turn", () => { + const staleTurnId = ThreadId.make("stale-turn") as unknown as import("@t3tools/contracts").TurnId; + const replacementTurnId = ThreadId.make( + "replacement-turn", + ) as unknown as import("@t3tools/contracts").TurnId; + + assert.isFalse( + acpRegistryPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + acpRegistryPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + +it.layer(acpRegistryAdapterTestLayer)("AcpRegistryAdapterLive", (it) => { + it.effect("passes launch arguments to the ACP agent", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-launch-args"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-args-")), + ); + const argLogPath = NodePath.join(tempDir, "args.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper(undefined, argLogPath), + ); + const adapter = yield* makeTestAdapter({ + binaryPath: wrapperPath, + launchArgs: "--mode acp", + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const argLog = yield* Effect.promise(() => NodeFSP.readFile(argLogPath, "utf8")); + assert.equal(argLog.trim(), "--mode\nacp"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("authenticates with the configured method id", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-auth-configured"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-auth-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_AUTH_METHODS: "token,api_key", + }), + ); + const adapter = yield* makeTestAdapter({ + binaryPath: wrapperPath, + authMethodId: "api_key", + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + entry.method === "authenticate" && + typeof entry.params === "object" && + entry.params !== null && + (entry.params as { methodId?: string }).methodId === "api_key", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("skips authenticate when the agent advertises no methods", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-auth-none"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-auth-none-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_AUTH_METHODS: "", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "authenticate")); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("omits T3 MCP servers when the agent does not advertise HTTP MCP", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mcp-omit"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-mcp-omit-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_REJECT_MCP: "1", + }), + ); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("env-1"), + threadId, + providerSessionId: "mcp-session", + providerInstanceId: ProviderInstanceId.make("acpRegistry"), + endpoint: "http://127.0.0.1:9/mcp", + authorizationHeader: "Bearer test", + }); + const adapter = yield* makeTestAdapter({ + binaryPath: wrapperPath, + attachMcpWhenSupported: true, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const sessionNew = requests.find((entry) => entry.method === "session/new"); + assert.isDefined(sessionNew); + const params = sessionNew?.params as { mcpServers?: unknown[] } | undefined; + assert.deepStrictEqual(params?.mcpServers ?? [], []); + + McpProviderSession.clearMcpProviderSession(threadId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("attaches T3 MCP when the agent advertises HTTP MCP", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mcp-attach"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-mcp-attach-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_ADVERTISE_HTTP_MCP: "1", + }), + ); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("env-1"), + threadId, + providerSessionId: "mcp-session", + providerInstanceId: ProviderInstanceId.make("acpRegistry"), + endpoint: "http://127.0.0.1:9/mcp", + authorizationHeader: "Bearer test", + }); + const adapter = yield* makeTestAdapter({ + binaryPath: wrapperPath, + attachMcpWhenSupported: true, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const sessionNew = requests.find((entry) => entry.method === "session/new"); + const params = sessionNew?.params as + | { mcpServers?: ReadonlyArray<{ name?: string }> } + | undefined; + assert.isTrue(params?.mcpServers?.some((server) => server.name === "t3-code") === true); + + McpProviderSession.clearMcpProviderSession(threadId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("never sends MCP servers when the attach switch is off", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mcp-never"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-mcp-never-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_ADVERTISE_HTTP_MCP: "1", + T3_ACP_REJECT_MCP: "1", + }), + ); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("env-1"), + threadId, + providerSessionId: "mcp-session", + providerInstanceId: ProviderInstanceId.make("acpRegistry"), + endpoint: "http://127.0.0.1:9/mcp", + authorizationHeader: "Bearer test", + }); + const adapter = yield* makeTestAdapter({ + binaryPath: wrapperPath, + attachMcpWhenSupported: false, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const sessionNew = requests.find((entry) => entry.method === "session/new"); + const params = sessionNew?.params as { mcpServers?: unknown[] } | undefined; + assert.deepStrictEqual(params?.mcpServers ?? [], []); + + McpProviderSession.clearMcpProviderSession(threadId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("starts a session, switches models, and maps prompt events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-prompt-flow"); + const wrapperPath = yield* Effect.promise(() => makeMockAcpRegistryWrapper()); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("acpRegistry"), + model: "grok-mock-alt", + }, + }); + + assert.equal(session.provider, "acpRegistry"); + assert.equal(session.model, "grok-mock-alt"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello registry", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "turn.completed", + ] as const); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + assert.equal(delta.payload.streamKind, "assistant_text"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("applies config option selections after session start", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-config-options"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-config-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("acpRegistry"), + model: "grok-build", + options: [{ id: "mode", value: "code" }], + }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + entry.method === "session/set_config_option" && + typeof entry.params === "object" && + entry.params !== null && + (entry.params as { configId?: string; value?: string }).configId === "mode" && + (entry.params as { value?: string }).value === "code", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps reasoning chunks to reasoning_text content deltas", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-thought"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_EMIT_THOUGHT: "1", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "think first", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const thought = runtimeEvents.find( + (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text", + ); + assert.isDefined(thought); + if (thought?.type === "content.delta") { + assert.equal(thought.payload.delta, "thinking out loud"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("preserves namespaced tool-call metadata on canonical tool data", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-tool-meta"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_EMIT_TOOL_META: "1", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (event.type === "request.opened") { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "run a tool", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const toolEvent = runtimeEvents.find( + (event) => + (event.type === "item.updated" || event.type === "item.completed") && + event.payload.itemType !== "assistant_message", + ); + assert.isDefined(toolEvent); + if (toolEvent?.type === "item.updated" || toolEvent?.type === "item.completed") { + const data = toolEvent.payload.data as { _meta?: unknown } | undefined; + assert.deepStrictEqual(data?._meta, { + "contextivity.dev/subagent": { id: "child-1", title: "Subagent" }, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends image attachments as ACP image prompt parts", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-image"); + const serverConfig = yield* ServerConfig; + const attachmentId = "acp-registry-image-11111111-1111-4111-8111-111111111111"; + const imagePath = NodePath.join(serverConfig.attachmentsDir, `${attachmentId}.png`); + yield* Effect.promise(() => NodeFSP.writeFile(imagePath, PNG_1X1)); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-image-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" ? Deferred.succeed(turnCompleted, undefined) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "describe this", + attachments: [ + { + type: "image", + id: attachmentId, + name: "pixel.png", + mimeType: "image/png", + sizeBytes: PNG_1X1.byteLength, + }, + ], + }); + yield* Deferred.await(turnCompleted); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const prompt = requests.find((entry) => entry.method === "session/prompt"); + const params = prompt?.params as + | { prompt?: ReadonlyArray<{ type?: string; mimeType?: string; data?: string }> } + | undefined; + assert.isTrue( + params?.prompt?.some( + (part) => + part.type === "image" && + part.mimeType === "image/png" && + typeof part.data === "string" && + part.data.length > 0, + ) === true, + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("responds to ACP approvals using provider-supplied option ids", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-approval"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-approval-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "agent-defined-approval-id", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores replayed session/load updates when resuming", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-load-replay"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ T3_ACP_EMIT_LOAD_REPLAY: "1" }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "after resume", + attachments: [], + }); + + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.isFalse( + runtimeEvents.some( + (event) => event.type === "item.completed" && event.payload.title === "Replay tool", + ), + ); + assert.isFalse( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.delta === "replayed assistant text", + ), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancels an in-flight prompt and accepts a follow-up turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-cancel"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1" }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* Effect.gen(function* () { + yield* Effect.sleep("500 millis"); + yield* adapter.interruptTurn(threadId); + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* adapter.sendTurn({ + threadId, + input: "hang forever", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(cancelledEvents, 1); + assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); + + const followUpEventsBefore = runtimeEvents.length; + yield* adapter.sendTurn({ + threadId, + input: "continue after stop", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const followUpCompletedEvents = runtimeEvents + .slice(followUpEventsBefore) + .filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(followUpCompletedEvents, 1); + assert.equal(followUpCompletedEvents[0]?.payload.state, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect( + "advertises client subagent-event caps and maps a session snapshot without a turn", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-snapshot"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-subagent-snapshot-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "snapshot", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil(adapter, (events) => + events.some( + (event) => event.type === "task.started" && event.payload.taskId === "child-1", + ), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* Deferred.await(done); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(initializeClientSubagentEventsVersion(requests), 1); + + const started = runtimeEvents.find( + (event): event is Extract => + event.type === "task.started" && event.payload.taskId === "child-1", + ); + assert.isDefined(started); + assert.equal(started?.turnId, undefined); + assert.equal(started?.raw?.source, "acp.contextivity.extension"); + assert.equal(started?.payload.taskType, "subagent"); + assert.equal(started?.payload.timelineBypass, true); + assert.equal(started?.payload.title, "Scout"); + assert.equal(started?.payload.role, "scout"); + assert.equal(started?.payload.model, "gpt-5.4-mini"); + assert.isUndefined(started?.payload.agentId); + assert.equal( + classifyTaskAgentKind({ + taskType: started?.payload.taskType, + agentId: started?.payload.agentId, + }), + "agent", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps nested snapshot children onto parentAgentId without a turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-nested"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "nested", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil( + adapter, + (events) => events.filter((event) => event.type === "task.started").length >= 2, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* Deferred.await(done); + + const started = runtimeEvents.filter( + (event): event is Extract => + event.type === "task.started", + ); + assert.deepStrictEqual( + started.map((event) => event.payload.taskId), + ["child-1", "child-2"], + ); + assert.equal(started[0]?.payload.title, "Coordinator"); + assert.equal(started[1]?.payload.parentAgentId, "child-1"); + assert.equal(started[1]?.payload.title, "Worker"); + assert.isUndefined(started[1]?.payload.agentId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps lifecycle deltas including cancel onto canonical task events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-lifecycle"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "lifecycle", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil( + adapter, + (events) => + events.some( + (event) => event.type === "task.completed" && event.payload.status === "completed", + ) && events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "run lifecycle", + attachments: [], + }); + yield* Deferred.await(done); + + const tasks = runtimeEvents.filter(isTaskRuntimeEvent); + assert.isTrue(tasks.some((event) => event.type === "task.started")); + assert.isTrue( + tasks.some((event) => event.type === "task.updated" && event.payload.status === "waiting"), + ); + assert.isTrue( + tasks.some((event) => event.type === "task.updated" && event.payload.status === "idle"), + ); + assert.isTrue( + tasks.some( + (event) => event.type === "task.completed" && event.payload.status === "completed", + ), + ); + assert.includeMembers( + runtimeEvents.map((event) => event.type), + ["content.delta", "turn.completed"], + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps cancellation to cancelled then stopped", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-cancel"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "cancel", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil( + adapter, + (events) => + events.some( + (event) => event.type === "task.completed" && event.payload.status === "stopped", + ) && events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "cancel child", + attachments: [], + }); + yield* Deferred.await(done); + + const tasks = runtimeEvents.filter(isTaskRuntimeEvent); + const types = tasks.map((event) => + event.type === "task.updated" || event.type === "task.completed" + ? `${event.type}:${event.payload.status}` + : event.type, + ); + assert.includeMembers(types, [ + "task.started", + "task.updated:cancelled", + "task.completed:stopped", + ]); + assert.isFalse(types.includes("task.completed:cancelled")); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores duplicate and out-of-order sequences and emits one terminal", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-sequence"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "sequence", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil( + adapter, + (events) => + events.some( + (event) => event.type === "task.completed" && event.payload.status === "completed", + ) && events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "sequence", + attachments: [], + }); + yield* Deferred.await(done); + + const completed = runtimeEvents.filter((event) => event.type === "task.completed"); + assert.lengthOf(completed, 1); + assert.equal(completed[0]?.payload.status, "completed"); + assert.equal(completed[0]?.payload.summary, "seq-4"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not fabricate usage from an extra record field", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-usage"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "usage", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil( + adapter, + (events) => + events.some( + (event) => + event.type === "task.progress" && event.payload.summary === "counting tokens", + ) && events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "usage", + attachments: [], + }); + yield* Deferred.await(done); + + const progress = runtimeEvents.find( + (event): event is Extract => + event.type === "task.progress" && event.payload.summary === "counting tokens", + ); + assert.isDefined(progress); + assert.isUndefined(progress?.payload.typedUsage); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores malformed subagent events and still completes the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-malformed"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_SCENARIO: "malformed", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil(adapter, (events) => + events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "still works", + attachments: [], + }); + yield* Deferred.await(done); + + assert.lengthOf(runtimeEvents.filter(isTaskRuntimeEvent), 0); + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal( + completed && completed.type === "turn.completed" ? completed.payload.state : undefined, + "completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not ingest unnegotiated subagent events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-unnegotiated"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-subagent-unnegotiated-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_SUBAGENT_EVENTS_UNNEGOTIATED: "1", + T3_ACP_SUBAGENT_SCENARIO: "snapshot", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil(adapter, (events) => + events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + }); + yield* Deferred.await(done); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(initializeClientSubagentEventsVersion(requests), 1); + assert.lengthOf(runtimeEvents.filter(isTaskRuntimeEvent), 0); + assert.isTrue(runtimeEvents.some((event) => event.type === "content.delta")); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not ingest a mismatched subagent-event version", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-subagent-mismatch"); + const wrapperPath = yield* Effect.promise(() => + makeMockAcpRegistryWrapper({ + T3_ACP_ADVERTISE_SUBAGENT_EVENTS: "1", + T3_ACP_SUBAGENT_EVENTS_VERSION: "2", + T3_ACP_SUBAGENT_SCENARIO: "snapshot", + }), + ); + const adapter = yield* makeTestAdapter({ binaryPath: wrapperPath }); + const { runtimeEvents, done, runtimeEventsFiber } = yield* collectUntil(adapter, (events) => + events.some((event) => event.type === "turn.completed"), + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("acpRegistry"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + }); + yield* Deferred.await(done); + + assert.lengthOf(runtimeEvents.filter(isTaskRuntimeEvent), 0); + assert.isTrue(runtimeEvents.some((event) => event.type === "content.delta")); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts new file mode 100644 index 0000000000000..c33f766a544f1 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -0,0 +1,1486 @@ +import { + ApprovalRequestId, + type AcpRegistrySettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpTaskEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { + ACP_SUBAGENT_EVENT_METHOD, + agentAdvertisesContextivitySubagentEvents, + summarizeAcpSubagentEvent, +} from "../acp/ContextivityAcpExtension.ts"; +import { + emptyContextivitySubagentMapperState, + mapContextivitySubagentEvent, + type ContextivitySubagentMapperState, +} from "../acp/ContextivityAcpSubagentMapper.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyAcpRegistryConfigOptionSelections, + applyAcpRegistryModelSelection, + currentAcpRegistryModelIdFromSessionSetup, + makeAcpRegistryRuntime, + resolveAcpRegistryModelId, +} from "../acp/AcpRegistrySupport.ts"; +import { type AcpRegistryAdapterShape } from "../Services/AcpRegistryAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("acpRegistry"); +const ACP_REGISTRY_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface AcpRegistryAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface AcpRegistrySessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + stopped: boolean; + subagentEventsEnabled: boolean; + subagentMapperState: ContextivitySubagentMapperState; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: AcpRegistrySessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: AcpRegistrySessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: AcpRegistrySessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseAcpRegistryResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== ACP_REGISTRY_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + if (response === undefined) { + return null; + } + return response.stopReason; +} + +export function acpRegistryPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeAcpRegistryAdapter( + acpRegistrySettings: AcpRegistrySettings, + options?: AcpRegistryAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("acpRegistry"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate ACP Registry runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process ACP Registry callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = acpRegistryPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native ACP Registry notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: AcpRegistrySessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: AcpRegistrySessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: AcpRegistryAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const modelSelectionForInstance = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseAcpRegistryResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeAcpRegistryRuntime({ + settings: acpRegistrySettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = modelSelectionForInstance?.model + ? resolveAcpRegistryModelId(modelSelectionForInstance.model) + : undefined; + const boundModelId = yield* applyAcpRegistryModelSelection({ + runtime: acp, + currentModelId: currentAcpRegistryModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + yield* applyAcpRegistryConfigOptionSelections({ + runtime: acp, + selections: Array.isArray(modelSelectionForInstance?.options) + ? modelSelectionForInstance.options + : undefined, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveAcpRegistryModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: ACP_REGISTRY_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: AcpRegistrySessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + stopped: false, + subagentEventsEnabled: agentAdvertisesContextivitySubagentEvents( + started.initializeResult.agentCapabilities, + ), + subagentMapperState: emptyContextivitySubagentMapperState(), + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + if (event._tag === "ContextivitySubagentEvent") { + yield* logNative( + ctx.threadId, + ACP_SUBAGENT_EVENT_METHOD, + summarizeAcpSubagentEvent(event.payload), + ); + if (!ctx.subagentEventsEnabled || event.payload.sessionId !== ctx.acpSessionId) { + return; + } + const mapped = mapContextivitySubagentEvent( + event.payload, + ctx.subagentMapperState, + ); + ctx.subagentMapperState = mapped.state; + const turnId = resolveNotificationTurnId(ctx); + for (const mappedEvent of mapped.events) { + yield* offerRuntimeEvent( + makeAcpTaskEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + event: mappedEvent, + source: "acp.contextivity.extension", + method: ACP_SUBAGENT_EVENT_METHOD, + rawPayload: summarizeAcpSubagentEvent(event.payload), + }), + ); + } + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + ...(event.streamKind ? { streamKind: event.streamKind } : {}), + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process ACP Registry runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "ACP Registry session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: AcpRegistryAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveAcpRegistryModelId(turnModelSelection.model) + : undefined; + const currentModelId = yield* applyAcpRegistryModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + yield* applyAcpRegistryConfigOptionSelections({ + runtime: ctx.acp, + selections: Array.isArray(turnModelSelection?.options) + ? turnModelSelection.options + : undefined, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveAcpRegistryModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "ACP Registry prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "ACP Registry prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "ACP Registry session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "ACP Registry session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "ACP Registry session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "ACP Registry prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: AcpRegistryAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: AcpRegistryAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: AcpRegistryAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/ask_user_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); + }); + + const readThread: AcpRegistryAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: AcpRegistryAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "ACP Registry sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: AcpRegistryAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: AcpRegistryAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: AcpRegistryAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: AcpRegistryAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies AcpRegistryAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts b/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts new file mode 100644 index 0000000000000..46bfaf94a1215 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts @@ -0,0 +1,197 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { AcpRegistrySettings } from "@t3tools/contracts"; + +import { + buildInitialAcpRegistryProviderSnapshot, + checkAcpRegistryProviderStatus, +} from "./AcpRegistryProvider.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); + +const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); +}); + +const writeExecutable = Effect.fn("writeExecutable")(function* ( + filePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + yield* fileSystem.chmod(filePath, 0o755); +}); + +const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* (input: { + readonly prefix: string; + readonly versionMode: "ok" | "fail"; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: input.prefix }); + const wrapperPath = path.join(dir, "fake-acp-agent.sh"); + const mockAgentCommand = [process.execPath, mockAgentPath] + .map((arg) => JSON.stringify(arg)) + .join(" "); + const versionBlock = + input.versionMode === "ok" + ? `if [ "$1" = "--version" ]; then + printf 'acp-agent 1.2.3\\n' + exit 0 +fi` + : `if [ "$1" = "--version" ]; then + printf 'version unsupported\\n' >&2 + exit 2 +fi`; + yield* writeExecutable( + wrapperPath, + ["#!/bin/sh", versionBlock, `exec ${mockAgentCommand} "$@"`, ""].join("\n"), + ); + return wrapperPath; +}); + +describe("buildInitialAcpRegistryProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeAcpRegistrySettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + }), + ); + + it.effect("returns a not-installed snapshot when the binary path is empty", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeAcpRegistrySettings({ enabled: true, binaryPath: "" }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("binary path"); + }), + ); + + it.effect("returns a pending snapshot when a binary path is set", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeAcpRegistrySettings({ enabled: true, binaryPath: "contextivity-agent" }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking ACP agent"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + }), + ); +}); + +it.layer(NodeServices.layer)("checkAcpRegistryProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeAcpRegistrySettings({ + enabled: true, + binaryPath: "/definitely/not/installed/acp-registry-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH/); + }), + ); + + it.effect("keeps an empty binary path as not installed instead of probing", () => + Effect.gen(function* () { + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeAcpRegistrySettings({ enabled: true, binaryPath: " " }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("binary path"); + }), + ); + + it.effect("treats initialize/session as health even when --version fails", () => + Effect.scoped( + Effect.gen(function* () { + const wrapperPath = yield* makeMockAgentWrapper({ + prefix: "t3code-acp-registry-version-fail-", + versionMode: "fail", + }); + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeAcpRegistrySettings({ enabled: true, binaryPath: wrapperPath }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build", "grok-mock-alt"]); + expect(snapshot.message).toBeUndefined(); + }), + ), + ); + + it.effect("reports ready when ACP initialize/session succeeds", () => + Effect.scoped( + Effect.gen(function* () { + const wrapperPath = yield* makeMockAgentWrapper({ + prefix: "t3code-acp-registry-ready-", + versionMode: "ok", + }); + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeAcpRegistrySettings({ enabled: true, binaryPath: wrapperPath }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build", "grok-mock-alt"]); + }), + ), + ); + + it.effect("reports an error when ACP initialize/session is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-acp-registry-fail-probe-", + }); + const wrapperPath = path.join(dir, "fake-acp-agent.sh"); + yield* writeExecutable( + wrapperPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + " printf 'acp-agent 0.0.1\\n'", + " exit 0", + "fi", + 'printf "not an acp agent\\n" >&2', + "exit 1", + "", + ].join("\n"), + ); + + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeAcpRegistrySettings({ enabled: true, binaryPath: wrapperPath }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.version).toBe("0.0.1"); + expect(snapshot.message).toContain("initialize/session failed"); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/AcpRegistryProvider.ts b/apps/server/src/provider/Layers/AcpRegistryProvider.ts new file mode 100644 index 0000000000000..581ce9b6a18fb --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryProvider.ts @@ -0,0 +1,326 @@ +import { + type AcpRegistrySettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeAcpRegistryRuntime, resolveAcpRegistryModelId } from "../acp/AcpRegistrySupport.ts"; + +const ACP_REGISTRY_PRESENTATION = { + displayName: "ACP Registry", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const ACP_REGISTRY_PROBE_TIMEOUT_MS = 15_000; + +export function buildInitialAcpRegistryProviderSnapshot( + settings: AcpRegistrySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = acpRegistryModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ACP Registry is disabled in T3 Code settings.", + }, + }); + } + + if (!settings.binaryPath.trim()) { + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Set a binary path for this ACP agent.", + }, + }); + } + + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking ACP agent availability...", + }, + }); + }); +} + +function acpRegistryModelsFromSettings( + customModels: ReadonlyArray | undefined, + discoveredModels: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings(discoveredModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function buildAcpRegistryDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveAcpRegistryModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +const discoverAcpRegistryModelsViaAcp = ( + settings: AcpRegistrySettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeAcpRegistryRuntime({ + settings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildAcpRegistryDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runAcpRegistryVersionCommand = ( + settings: AcpRegistrySettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath.trim(); + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkAcpRegistryProviderStatus = Effect.fn("checkAcpRegistryProviderStatus")( + function* ( + settings: AcpRegistrySettings, + environment: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + > { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = acpRegistryModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ACP Registry is disabled in T3 Code settings.", + }, + }); + } + + if (!settings.binaryPath.trim()) { + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Set a binary path for this ACP agent.", + }, + }); + } + + const versionResult = yield* runAcpRegistryVersionCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult) && isCommandMissingCause(versionResult.failure)) { + yield* Effect.logWarning("ACP Registry binary is not installed.", { + errorTag: versionResult.failure._tag, + }); + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "ACP agent executable is not installed or not on PATH.", + }, + }); + } + + const version = + Result.isSuccess(versionResult) && Option.isSome(versionResult.success) + ? parseGenericCliVersion( + `${versionResult.success.value.stdout}\n${versionResult.success.value.stderr}`, + ) + : null; + + const discoveryExit = yield* discoverAcpRegistryModelsViaAcp(settings, environment).pipe( + Effect.timeoutOption(ACP_REGISTRY_PROBE_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("ACP Registry initialize/session probe failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "ACP agent executable was found but initialize/session failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `ACP Registry initialize/session probe timed out after ${ACP_REGISTRY_PROBE_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `ACP agent executable was found but initialize/session timed out after ${ACP_REGISTRY_PROBE_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? acpRegistryModelsFromSettings(settings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: ACP_REGISTRY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); + }, +); + +export const enrichAcpRegistrySnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("ACP Registry version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae89..ed913eb335e41 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -791,6 +791,8 @@ export function makeCursorAdapter( return; case "ModeChanged": return; + case "ContextivitySubagentEvent": + return; case "AssistantItemStarted": yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5fb..171bb61a66dfd 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -800,6 +800,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } + if (event._tag === "ContextivitySubagentEvent") { + return; + } + const notificationTurnId = resolveNotificationTurnId(ctx); if ( notificationTurnId === undefined || diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb09..190e4ea77c2a4 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,21 +10,23 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single - * `ProviderInstanceConfigMap` and asserts the registry boots them all - * without cross-contamination. This proves the driver SPI is uniform - * across every provider — any driver plugs into the registry through - * the same `ProviderDriver` value contract. + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `acpRegistry`) + * in a single `ProviderInstanceConfigMap` and asserts the registry boots + * them all without cross-contamination. This proves the driver SPI is + * uniform across every provider — any driver plugs into the registry + * through the same `ProviderDriver` value contract. * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / + * `opencode` / ACP Registry binaries. That keeps the assertions focused on + * registry routing behaviour rather than the runtime details of each + * provider. */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + type AcpRegistrySettings, type ClaudeSettings, type CodexSettings, type CursorSettings, @@ -43,6 +45,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpRegistryDriver } from "../Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; @@ -133,6 +136,16 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeAcpRegistryConfig = (overrides: Partial): AcpRegistrySettings => ({ + enabled: false, + binaryPath: "", + launchArgs: "", + authMethodId: "", + attachMcpWhenSupported: true, + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -321,12 +334,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const acpRegistryId = ProviderInstanceId.make("acpRegistry_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const acpRegistryDriverKind = ProviderDriverKind.make("acpRegistry"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -362,10 +377,23 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [acpRegistryId]: { + driver: acpRegistryDriverKind, + displayName: "ACP Registry", + enabled: false, + config: makeAcpRegistryConfig({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [ + CodexDriver, + ClaudeDriver, + CursorDriver, + GrokDriver, + OpenCodeDriver, + AcpRegistryDriver, + ], configMap, }); @@ -375,9 +403,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, acpRegistryId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -388,16 +416,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const acpRegistry = yield* registry.getInstance(acpRegistryId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(acpRegistry?.driverKind).toBe(acpRegistryDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(acpRegistry?.displayName).toBe("ACP Registry"); // Every instance owns its own set of closures — no sharing across // drivers. `adapter` / `textGeneration` / `snapshot` are all @@ -410,6 +441,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.adapter, grok!.adapter, openCode!.adapter, + acpRegistry!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -418,6 +450,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + acpRegistry!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -426,6 +459,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + acpRegistry!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -468,6 +502,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(openCodeSnapshot.continuation?.groupKey).toBe( `${openCodeDriverKind}:instance:${openCodeId}`, ); + + const acpRegistrySnapshot = yield* acpRegistry!.snapshot.getSnapshot; + expect(acpRegistrySnapshot.instanceId).toBe(acpRegistryId); + expect(acpRegistrySnapshot.driver).toBe(acpRegistryDriverKind); + expect(acpRegistrySnapshot.enabled).toBe(false); + expect(acpRegistrySnapshot.continuation?.groupKey).toBe( + `${acpRegistryDriverKind}:instance:${acpRegistryId}`, + ); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Services/AcpRegistryAdapter.ts b/apps/server/src/provider/Services/AcpRegistryAdapter.ts new file mode 100644 index 0000000000000..844776b237bbd --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryAdapter.ts @@ -0,0 +1,16 @@ +/** + * AcpRegistryAdapter — shape type for the generic ACP Registry provider adapter. + * + * The driver model ({@link ../Drivers/AcpRegistryDriver}) bundles one adapter + * per instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module AcpRegistryAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * AcpRegistryAdapterShape — per-instance ACP Registry adapter contract. + */ +export interface AcpRegistryAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index bd25e9815aef5..cb52c96e27ee7 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -12,6 +12,8 @@ import { type TurnId, } from "@t3tools/contracts"; +import type { ContextivityMappedTaskEvent } from "./ContextivityAcpSubagentMapper.ts"; + import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; type AcpAdapterRawSource = Extract< @@ -220,6 +222,7 @@ export function makeAcpContentDeltaEvent(input: { readonly turnId: TurnId | undefined; readonly itemId?: string; readonly text: string; + readonly streamKind?: "assistant_text" | "reasoning_text"; readonly rawPayload: unknown; }): ProviderRuntimeEvent { return { @@ -230,7 +233,7 @@ export function makeAcpContentDeltaEvent(input: { turnId: input.turnId, ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), payload: { - streamKind: "assistant_text", + streamKind: input.streamKind ?? streamKindFromRawPayload(input.rawPayload), delta: input.text, }, raw: { @@ -240,3 +243,49 @@ export function makeAcpContentDeltaEvent(input: { }, }; } + +export function makeAcpTaskEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly event: ContextivityMappedTaskEvent; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const base = { + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + ...(input.turnId ? { turnId: input.turnId } : {}), + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; + switch (input.event.type) { + case "task.started": + return { ...base, type: "task.started", payload: input.event.payload }; + case "task.progress": + return { ...base, type: "task.progress", payload: input.event.payload }; + case "task.updated": + return { ...base, type: "task.updated", payload: input.event.payload }; + case "task.completed": + return { ...base, type: "task.completed", payload: input.event.payload }; + } +} + +function streamKindFromRawPayload(rawPayload: unknown): "assistant_text" | "reasoning_text" { + if (typeof rawPayload !== "object" || rawPayload === null) { + return "assistant_text"; + } + const update = "update" in rawPayload ? rawPayload.update : undefined; + if (typeof update !== "object" || update === null) { + return "assistant_text"; + } + return "sessionUpdate" in update && update.sessionUpdate === "agent_thought_chunk" + ? "reasoning_text" + : "assistant_text"; +} diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts new file mode 100644 index 0000000000000..57e0f1c930264 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { AcpRegistrySettings } from "@t3tools/contracts"; + +import { + buildAcpRegistrySpawnInput, + resolveAcpRegistryMcpPolicy, + tokenizeAcpRegistryLaunchArgs, +} from "./AcpRegistrySupport.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +describe("tokenizeAcpRegistryLaunchArgs", () => { + it("tokenizes a Contextivity-style argument string", () => { + expect(tokenizeAcpRegistryLaunchArgs("--mode acp")).toEqual(["--mode", "acp"]); + }); + + it("returns an empty list when launch args are blank", () => { + expect(tokenizeAcpRegistryLaunchArgs("")).toEqual([]); + expect(tokenizeAcpRegistryLaunchArgs(undefined)).toEqual([]); + }); +}); + +describe("buildAcpRegistrySpawnInput", () => { + it("uses the configured binary and tokenized launch args", () => { + const settings = decodeSettings({ + binaryPath: "/usr/local/bin/contextivity-agent", + launchArgs: "--mode acp", + }); + expect(buildAcpRegistrySpawnInput(settings, "/tmp/project")).toEqual({ + command: "/usr/local/bin/contextivity-agent", + args: ["--mode", "acp"], + cwd: "/tmp/project", + }); + }); +}); + +describe("resolveAcpRegistryMcpPolicy", () => { + it("maps the attach switch to auto or never", () => { + expect(resolveAcpRegistryMcpPolicy({ attachMcpWhenSupported: true })).toBe("auto"); + expect(resolveAcpRegistryMcpPolicy({ attachMcpWhenSupported: false })).toBe("never"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts new file mode 100644 index 0000000000000..707f7e7197b06 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -0,0 +1,128 @@ +import { type AcpRegistrySettings, ProviderDriverKind } from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { acpRegistryClientCapabilities } from "./ContextivityAcpExtension.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); + +export type AcpRegistryMcpPolicy = Extract; + +interface AcpRegistryRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" | "mcpPolicy" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly settings: AcpRegistrySettings; + readonly environment?: NodeJS.ProcessEnv; +} + +export function tokenizeAcpRegistryLaunchArgs( + launchArgs: string | undefined, +): ReadonlyArray { + return tokenizeCliArgs(launchArgs); +} + +export function buildAcpRegistrySpawnInput( + settings: Pick, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const command = settings.binaryPath.trim(); + return { + command, + args: tokenizeAcpRegistryLaunchArgs(settings.launchArgs), + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export function resolveAcpRegistryMcpPolicy( + settings: Pick, +): AcpRegistryMcpPolicy { + return settings.attachMcpWhenSupported ? "auto" : "never"; +} + +export const makeAcpRegistryRuntime = ( + input: AcpRegistryRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildAcpRegistrySpawnInput(input.settings, input.cwd, input.environment), + authMethodId: input.settings.authMethodId, + mcpPolicy: resolveAcpRegistryMcpPolicy(input.settings), + clientCapabilities: acpRegistryClientCapabilities(), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveAcpRegistryModelId(model: string | null | undefined): string | undefined { + const normalized = normalizeModelSlug(model?.trim() || undefined, ACP_REGISTRY_DRIVER_KIND); + return normalized ?? undefined; +} + +export function currentAcpRegistryModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyAcpRegistryModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} + +export function applyAcpRegistryConfigOptionSelections(input: { + readonly runtime: Pick; + readonly selections: + | ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> + | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + if (!input.selections || input.selections.length === 0) { + return Effect.void; + } + return Effect.forEach( + input.selections, + (selection) => + input.runtime + .setConfigOption(selection.id, selection.value) + .pipe(Effect.mapError(input.mapError)), + { discard: true }, + ); +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb7..5e5d98d4926d3 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -322,6 +322,7 @@ describe("AcpRuntimeModel", () => { { _tag: "ContentDelta", text: "hello from acp", + streamKind: "assistant_text", rawPayload: { sessionId: "session-1", update: { @@ -336,6 +337,61 @@ describe("AcpRuntimeModel", () => { ]); }); + it("maps agent thought chunks to reasoning content deltas", () => { + const thoughtResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "considering the next step", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(thoughtResult.events).toEqual([ + { + _tag: "ContentDelta", + text: "considering the next step", + streamKind: "reasoning_text", + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "considering the next step", + }, + }, + }, + }, + ]); + }); + + it("preserves namespaced tool-call metadata on canonical tool data", () => { + const created = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-subagent-1", + title: "Subagent", + kind: "other", + status: "pending", + _meta: { + "contextivity.dev/subagent": { id: "child-1" }, + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(created.events[0]?._tag).toBe("ToolCallUpdated"); + const event = created.events[0]; + if (event?._tag === "ToolCallUpdated") { + expect(event.toolCall.data._meta).toEqual({ + "contextivity.dev/subagent": { id: "child-1" }, + }); + } + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e90..b05dab2c02238 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -107,6 +107,7 @@ export type AcpParsedSessionEvent = readonly _tag: "ContentDelta"; readonly itemId?: string; readonly text: string; + readonly streamKind?: "assistant_text" | "reasoning_text"; readonly rawPayload: unknown; }; @@ -315,6 +316,7 @@ function makeToolCallState( readonly rawOutput?: unknown; readonly content?: ReadonlyArray | null | undefined; readonly locations?: ReadonlyArray | null | undefined; + readonly meta?: { readonly [x: string]: unknown } | null | undefined; }, options?: { readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; @@ -351,6 +353,9 @@ function makeToolCallState( if (input.locations !== undefined) { data.locations = input.locations; } + if (input.meta !== undefined && input.meta !== null) { + data._meta = input.meta; + } const fallbackDetail = command ?? normalizedTitle ?? textContent; const hasPresentationSeed = title !== undefined || @@ -383,6 +388,7 @@ function parseTypedToolCallState( event: AcpToolCallUpdate, options?: { readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + readonly notificationMeta?: { readonly [x: string]: unknown } | null; }, ): AcpToolCallState | undefined { return makeToolCallState( @@ -395,11 +401,25 @@ function parseTypedToolCallState( rawOutput: event.rawOutput, content: event.content, locations: event.locations, + meta: mergeAcpMeta(options?.notificationMeta, event._meta), }, options, ); } +function mergeAcpMeta( + notificationMeta: { readonly [x: string]: unknown } | null | undefined, + updateMeta: { readonly [x: string]: unknown } | null | undefined, +): { readonly [x: string]: unknown } | undefined { + const notification = + notificationMeta && typeof notificationMeta === "object" ? notificationMeta : undefined; + const update = updateMeta && typeof updateMeta === "object" ? updateMeta : undefined; + if (!notification && !update) { + return undefined; + } + return { ...notification, ...update }; +} + export function mergeToolCallState( previous: AcpToolCallState | undefined, next: AcpToolCallState, @@ -543,6 +563,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat case "tool_call": { const toolCall = parseTypedToolCallState(upd, { fallbackStatus: "pending", + ...(params._meta !== undefined ? { notificationMeta: params._meta } : {}), }); if (toolCall) { events.push({ @@ -554,7 +575,9 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat break; } case "tool_call_update": { - const toolCall = parseTypedToolCallState(upd); + const toolCall = parseTypedToolCallState(upd, { + ...(params._meta !== undefined ? { notificationMeta: params._meta } : {}), + }); if (toolCall) { events.push({ _tag: "ToolCallUpdated", @@ -569,6 +592,18 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ContentDelta", text: upd.content.text, + streamKind: "assistant_text", + rawPayload: params, + }); + } + break; + } + case "agent_thought_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ContentDelta", + text: upd.content.text, + streamKind: "reasoning_text", rawPayload: params, }); } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.policy.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.policy.test.ts new file mode 100644 index 0000000000000..416b5f09925fd --- /dev/null +++ b/apps/server/src/provider/acp/AcpSessionRuntime.policy.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolveAcpAuthMethodId, resolveAcpSessionMcpServers } from "./AcpSessionRuntime.ts"; + +describe("resolveAcpAuthMethodId", () => { + it("uses the configured method when set", () => { + expect( + resolveAcpAuthMethodId({ + configuredAuthMethodId: " api_key ", + advertisedAuthMethods: [{ id: "token" }, { id: "api_key" }], + }), + ).toBe("api_key"); + }); + + it("falls back to the first advertised method", () => { + expect( + resolveAcpAuthMethodId({ + advertisedAuthMethods: [{ id: "token" }, { id: "api_key" }], + }), + ).toBe("token"); + }); + + it("skips authentication when nothing is advertised or configured", () => { + expect(resolveAcpAuthMethodId({ advertisedAuthMethods: [] })).toBeUndefined(); + expect(resolveAcpAuthMethodId({})).toBeUndefined(); + }); +}); + +describe("resolveAcpSessionMcpServers", () => { + const requested = [ + { + type: "http" as const, + name: "t3-code", + url: "http://127.0.0.1:9/mcp", + headers: [{ name: "Authorization", value: "Bearer test" }], + }, + ]; + + it("always passes requested servers when policy is always", () => { + expect( + resolveAcpSessionMcpServers({ + requested, + policy: "always", + initializeResult: { protocolVersion: 1 }, + }), + ).toEqual(requested); + }); + + it("omits servers when the agent does not advertise HTTP MCP under auto", () => { + expect( + resolveAcpSessionMcpServers({ + requested, + policy: "auto", + initializeResult: { protocolVersion: 1, agentCapabilities: { loadSession: true } }, + }), + ).toEqual([]); + }); + + it("passes servers when auto policy sees HTTP MCP", () => { + expect( + resolveAcpSessionMcpServers({ + requested, + policy: "auto", + initializeResult: { + protocolVersion: 1, + agentCapabilities: { mcpCapabilities: { http: true } }, + }, + }), + ).toEqual(requested); + }); + + it("never passes servers under never policy", () => { + expect( + resolveAcpSessionMcpServers({ + requested, + policy: "never", + initializeResult: { + protocolVersion: 1, + agentCapabilities: { mcpCapabilities: { http: true } }, + }, + }), + ).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d4..58f04e396a8b1 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -12,6 +12,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -21,6 +22,13 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + ACP_SUBAGENT_EVENT_METHOD, + parseAcpSubagentEvent, + summarizeAcpSubagentEvent, + type AcpSubagentEventPayload, +} from "./ContextivityAcpExtension.ts"; + import { collectSessionConfigOptionValues, extractModelConfigId, @@ -45,7 +53,16 @@ export interface AcpSessionEventStreamBarrier { readonly acknowledge: Deferred.Deferred; } -export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; +export type AcpContextivitySubagentRuntimeEvent = { + readonly _tag: "ContextivitySubagentEvent"; + readonly payload: AcpSubagentEventPayload; + readonly rawPayload: unknown; +}; + +export type AcpSessionRuntimeEvent = + | AcpParsedSessionEvent + | AcpSessionEventStreamBarrier + | AcpContextivitySubagentRuntimeEvent; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); @@ -57,6 +74,8 @@ export interface AcpSpawnInput { readonly env?: NodeJS.ProcessEnv; } +export type AcpMcpPolicy = "always" | "auto" | "never"; + export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; @@ -68,8 +87,20 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP `authenticate` method id. When omitted or blank, the runtime uses the + * first advertised `authMethods` entry from `initialize`, or skips + * authentication when the agent advertises none. + */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; + /** + * How to treat `mcpServers` after `initialize`. + * - `always` (default): pass the requested servers, matching Grok/Cursor. + * - `auto`: pass them only when the agent advertises HTTP MCP. + * - `never`: always send an empty array. + */ + readonly mcpPolicy?: AcpMcpPolicy; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -78,6 +109,36 @@ export interface AcpSessionRuntimeOptions { }; } +export function resolveAcpAuthMethodId(input: { + readonly configuredAuthMethodId?: string; + readonly advertisedAuthMethods?: ReadonlyArray<{ readonly id: string }> | null; +}): string | undefined { + const configured = input.configuredAuthMethodId?.trim(); + if (configured) { + return configured; + } + const firstAdvertised = input.advertisedAuthMethods?.find( + (method) => method.id.trim().length > 0, + ); + return firstAdvertised?.id.trim() || undefined; +} + +export function resolveAcpSessionMcpServers(input: { + readonly requested?: ReadonlyArray; + readonly policy?: AcpMcpPolicy; + readonly initializeResult: EffectAcpSchema.InitializeResponse; +}): ReadonlyArray { + const requested = input.requested ?? []; + const policy = input.policy ?? "always"; + if (policy === "never" || requested.length === 0) { + return []; + } + if (policy === "always") { + return requested; + } + return input.initializeResult.agentCapabilities?.mcpCapabilities?.http === true ? requested : []; +} + export interface AcpSessionRequestLogEvent { readonly method: string; readonly payload: unknown; @@ -403,6 +464,23 @@ export const make = ( }); }), ); + yield* acp.handleExtNotification(ACP_SUBAGENT_EVENT_METHOD, Schema.Unknown, (params) => + Effect.gen(function* () { + const parsed = parseAcpSubagentEvent(params); + if (!parsed) { + yield* Effect.logDebug("Ignored malformed ACP subagent event.", { + method: ACP_SUBAGENT_EVENT_METHOD, + payload: summarizeAcpSubagentEvent(params), + }); + return; + } + yield* Queue.offer(eventQueue, { + _tag: "ContextivitySubagentEvent", + payload: parsed, + rawPayload: params, + }); + }).pipe(Effect.catchCause(() => Effect.void)), + ); const initializeClientCapabilities = { fs: { readTextFile: false, @@ -541,15 +619,31 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + const authMethodId = resolveAcpAuthMethodId({ + ...(options.authMethodId !== undefined + ? { configuredAuthMethodId: options.authMethodId } + : {}), + ...(initializeResult.authMethods !== undefined + ? { advertisedAuthMethods: initializeResult.authMethods } + : {}), + }); + if (authMethodId !== undefined) { + const authenticatePayload = { + methodId: authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + const mcpServers = resolveAcpSessionMcpServers({ + ...(options.mcpServers !== undefined ? { requested: options.mcpServers } : {}), + ...(options.mcpPolicy !== undefined ? { policy: options.mcpPolicy } : {}), + initializeResult, + }); let sessionId: string; let sessionSetupResult: @@ -560,7 +654,7 @@ export const make = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: options.mcpServers ?? [], + mcpServers, } satisfies EffectAcpSchema.LoadSessionRequest; const sessionLoadTimeout = Duration.fromInputUnsafe( options.sessionLoadTimeout ?? defaultSessionLoadTimeout, @@ -633,7 +727,7 @@ export const make = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: options.mcpServers ?? [], + mcpServers, } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", diff --git a/apps/server/src/provider/acp/ContextivityAcpExtension.test.ts b/apps/server/src/provider/acp/ContextivityAcpExtension.test.ts new file mode 100644 index 0000000000000..baddd7d518af7 --- /dev/null +++ b/apps/server/src/provider/acp/ContextivityAcpExtension.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACP_SUBAGENT_EVENT_METHOD, + ACP_SUBAGENT_EVENTS_VERSION, + acpRegistryClientCapabilities, + agentAdvertisesContextivitySubagentEvents, + parseAcpSubagentEvent, + summarizeAcpSubagentEvent, +} from "./ContextivityAcpExtension.ts"; + +function record(overrides: Record = {}) { + return { + agentId: "child-1", + state: "running", + updatedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe("ContextivityAcpExtension", () => { + it("advertises client capability version 1", () => { + expect(acpRegistryClientCapabilities()).toEqual({ + _meta: { contextivity: { subagentEvents: { version: 1 } } }, + }); + expect(ACP_SUBAGENT_EVENT_METHOD).toBe("_contextivity/subagent_event"); + expect(ACP_SUBAGENT_EVENTS_VERSION).toBe(1); + }); + + it("negotiates matching agent _meta version 1 only", () => { + expect(agentAdvertisesContextivitySubagentEvents(undefined)).toBe(false); + expect(agentAdvertisesContextivitySubagentEvents({ _meta: {} })).toBe(false); + expect( + agentAdvertisesContextivitySubagentEvents({ + _meta: { contextivity: { subagentEvents: { version: 2 } } }, + }), + ).toBe(false); + expect( + agentAdvertisesContextivitySubagentEvents({ + _meta: { contextivity: { subagentEvents: { version: 1 } } }, + }), + ).toBe(true); + }); + + it("parses producer snapshot and delta envelopes", () => { + const snapshot = parseAcpSubagentEvent({ + version: 1, + sessionId: "sess-1", + sequence: 1, + kind: "snapshot", + records: [record({ parentAgentId: "root", displayName: "Scout", typeName: "scout" })], + extra: "ignored", + }); + expect(snapshot).toMatchObject({ + version: 1, + sessionId: "sess-1", + sequence: 1, + kind: "snapshot", + records: [{ agentId: "child-1", parentAgentId: "root", typeName: "scout" }], + }); + expect(snapshot).not.toHaveProperty("change"); + + const delta = parseAcpSubagentEvent({ + version: 1, + sessionId: "sess-1", + sequence: 2, + kind: "delta", + change: "started", + records: [record()], + }); + expect(delta?.change).toBe("started"); + }); + + it("rejects draft kind aliases and non-records envelopes", () => { + for (const kind of ["started", "progress", "status", "completed", "updated", "terminal"]) { + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind, + records: [record()], + }), + ).toBeUndefined(); + } + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 2, + kind: "delta", + change: "progress", + records: [record()], + }), + ).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 3, + kind: "delta", + change: "started", + subagent: record(), + }), + ).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 4, + kind: "snapshot", + subagents: [record()], + }), + ).toBeUndefined(); + }); + + it("rejects malformed envelopes and unparseable records", () => { + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 0, + kind: "snapshot", + records: [], + }), + ).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 2, + sessionId: "s", + sequence: 1, + kind: "snapshot", + records: [], + }), + ).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "delta", + records: [record()], + }), + ).toBeUndefined(); + expect(parseAcpSubagentEvent("nope")).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "snapshot", + records: [record(), { agentId: "bad" }], + }), + ).toBeUndefined(); + }); + + it("redacts and truncates display fields without inventing usage", () => { + const parsed = parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "delta", + change: "updated", + records: [ + record({ + description: `api_key=sk-live-123 ${"x".repeat(400)}`, + latestActivity: `token=super-secret ${"y".repeat(400)}`, + unknownField: { nested: true }, + usage: { totalTokens: 9, inputTokens: 3, outputTokens: -1 }, + recentActivity: { + updatedAt: 2, + assistantMessages: ["one", "two", "three", "four", "five", "six"], + toolCalls: ["read"], + }, + }), + ], + }); + expect(parsed?.records).toHaveLength(1); + expect(parsed?.records[0]?.description).toContain("[redacted]"); + expect(parsed?.records[0]?.description?.length).toBeLessThanOrEqual(160); + expect(parsed?.records[0]?.latestActivity).toContain("[redacted]"); + expect(parsed?.records[0]?.latestActivity?.length).toBeLessThanOrEqual(160); + expect(parsed?.records[0]).not.toHaveProperty("unknownField"); + expect(parsed?.records[0]).not.toHaveProperty("usage"); + expect(parsed?.records[0]?.recentActivity?.assistantMessages).toHaveLength(5); + }); + + it("rejects oversized identities and caps snapshot, delta, and lineage bounds", () => { + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s".repeat(129), + sequence: 1, + kind: "snapshot", + records: [record()], + }), + ).toBeUndefined(); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "snapshot", + records: [record({ agentId: "x".repeat(129) })], + }), + ).toBeUndefined(); + + const snapshot = parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "snapshot", + records: Array.from({ length: 64 }, (_, index) => record({ agentId: `a${index}` })), + }); + expect(snapshot?.records).toHaveLength(64); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 1, + kind: "snapshot", + records: Array.from({ length: 65 }, (_, index) => record({ agentId: `a${index}` })), + }), + ).toBeUndefined(); + + const delta = parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 2, + kind: "delta", + change: "updated", + records: Array.from({ length: 12 }, (_, index) => record({ agentId: `d${index}` })), + }); + expect(delta?.records).toHaveLength(8); + + const withLineage = parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 3, + kind: "snapshot", + records: [ + record({ + lineage: Array.from({ length: 12 }, (_, index) => `id-${index}`), + }), + ], + }); + expect(withLineage?.records[0]?.lineage).toHaveLength(8); + expect( + parseAcpSubagentEvent({ + version: 1, + sessionId: "s", + sequence: 4, + kind: "snapshot", + records: [record({ lineage: ["ok", "x".repeat(129), "kept"] })], + })?.records[0]?.lineage, + ).toEqual(["ok", "kept"]); + }); + + it("summarizes payloads without copying secrets", () => { + const summary = summarizeAcpSubagentEvent({ + version: 1, + sessionId: "sess", + sequence: 8, + kind: "delta", + change: "updated", + records: [record({ description: "token=secret-value" })], + }); + expect(summary).toMatchObject({ + version: 1, + sessionId: "sess", + sequence: 8, + kind: "delta", + recordCount: 1, + agentIds: ["child-1"], + }); + expect(JSON.stringify(summary)).not.toContain("secret-value"); + }); +}); diff --git a/apps/server/src/provider/acp/ContextivityAcpExtension.ts b/apps/server/src/provider/acp/ContextivityAcpExtension.ts new file mode 100644 index 0000000000000..a6e273be045e1 --- /dev/null +++ b/apps/server/src/provider/acp/ContextivityAcpExtension.ts @@ -0,0 +1,341 @@ +/** + * Typed ACP extension for namespaced sub-agent lifecycle events. + * + * Capability: client/agent `_meta.contextivity.subagentEvents.version = 1`. + * Notification: `_contextivity/subagent_event`. + * + * Any ACP agent may advertise this contract. Contextivity is the first producer. + * Parsing is defensive: a malformed envelope, including any unparseable record, + * is ignored. Never throw into the ACP turn. + */ + +export const ACP_SUBAGENT_EVENT_METHOD = "_contextivity/subagent_event"; +export const ACP_SUBAGENT_EVENTS_VERSION = 1; +export const CONTEXTIVITY_META_NAMESPACE = "contextivity"; + +export const ACP_SUBAGENT_LIFECYCLE_STATES = [ + "spawned", + "running", + "waiting_input", + "waiting_approval", + "idle", + "resetting", + "completed", + "failed", + "cancelled", +] as const; + +export type AcpSubagentLifecycleState = (typeof ACP_SUBAGENT_LIFECYCLE_STATES)[number]; +export type AcpSubagentEventKind = "snapshot" | "delta"; +export type AcpSubagentDeltaChange = "started" | "updated" | "terminal"; + +const LIFECYCLE_STATE_SET = new Set(ACP_SUBAGENT_LIFECYCLE_STATES); +const TERMINAL_STATES = new Set(["completed", "failed", "cancelled"]); +const TERMINAL_KINDS = new Set(["completed", "reported", "failed", "cancelled"]); +const REPORT_STATUSES = new Set(["completed", "failed", "needs_review", "findings"]); + +const MAX_ID = 128; +const MAX_SESSION_ID = 128; +const MAX_NAME = 160; +const MAX_TYPE = 64; +const MAX_MODEL = 128; +const MAX_TASK_FIELD = 160; +const MAX_DESCRIPTION = 160; +const MAX_ACTIVITY = 160; +const MAX_SUMMARY = 240; +const MAX_ERROR = 240; +const MAX_RECENT = 5; +const MAX_RECENT_ITEM = 80; +const MAX_LINEAGE = 8; +const MAX_SNAPSHOT_RECORDS = 64; +const MAX_DELTA_RECORDS = 8; +const MAX_SPAWN_DEPTH = 7; + +export interface AcpSubagentRecentActivity { + readonly updatedAt: number; + readonly assistantMessages: ReadonlyArray; + readonly toolCalls: ReadonlyArray; +} + +export interface AcpSubagentTerminal { + readonly kind: string; + readonly endedAt: number; + readonly summary?: string; + readonly error?: string; + readonly reportStatus?: "completed" | "failed" | "needs_review" | "findings"; +} + +export interface AcpSubagentRecord { + readonly agentId: string; + readonly parentAgentId?: string; + readonly displayName?: string; + readonly typeName?: string; + readonly role?: string; + readonly lineage?: ReadonlyArray; + readonly spawnDepth?: number; + readonly taskId?: string; + readonly taskSubject?: string; + readonly description?: string; + readonly state: AcpSubagentLifecycleState; + readonly modelId?: string; + readonly latestActivity?: string; + readonly lastToolName?: string; + readonly recentActivity?: AcpSubagentRecentActivity; + readonly terminal?: AcpSubagentTerminal; + readonly runtimeEpoch?: number; + readonly startedAt?: number; + readonly completedAt?: number; + readonly updatedAt: number; +} + +export interface AcpSubagentEventPayload { + readonly version: typeof ACP_SUBAGENT_EVENTS_VERSION; + readonly sessionId: string; + readonly sequence: number; + readonly kind: AcpSubagentEventKind; + readonly change?: AcpSubagentDeltaChange; + readonly records: ReadonlyArray; +} + +export interface AcpSubagentEventDiagnostic { + readonly version?: unknown; + readonly sessionId?: string; + readonly sequence?: unknown; + readonly kind?: unknown; + readonly change?: unknown; + readonly recordCount: number; + readonly agentIds: ReadonlyArray; + readonly parseFailed?: boolean; +} + +export function contextivitySubagentEventsClientMeta(): { + readonly contextivity: { readonly subagentEvents: { readonly version: 1 } }; +} { + return { + [CONTEXTIVITY_META_NAMESPACE]: { subagentEvents: { version: ACP_SUBAGENT_EVENTS_VERSION } }, + }; +} + +export function acpRegistryClientCapabilities(): { + readonly _meta: { + readonly contextivity: { readonly subagentEvents: { readonly version: 1 } }; + }; +} { + return { _meta: contextivitySubagentEventsClientMeta() }; +} + +export function agentAdvertisesContextivitySubagentEvents( + capabilities?: { readonly _meta?: unknown } | null, +): boolean { + return metaAdvertisesSubagentEvents(capabilities?._meta); +} + +export function isAcpSubagentTerminalState(state: AcpSubagentLifecycleState): boolean { + return TERMINAL_STATES.has(state); +} + +export function summarizeAcpSubagentEvent(value: unknown): AcpSubagentEventDiagnostic { + if (!isRecord(value)) { + return { recordCount: 0, agentIds: [], parseFailed: true }; + } + const records = recordsFromUnknown(value) ?? []; + const agentIds: string[] = []; + for (const item of records) { + if (!isRecord(item) || typeof item.agentId !== "string") continue; + const id = item.agentId.slice(0, MAX_ID); + if (id.length > 0) agentIds.push(id); + if (agentIds.length >= 20) break; + } + return { + ...(value.version !== undefined ? { version: value.version } : {}), + ...(typeof value.sessionId === "string" ? { sessionId: value.sessionId.slice(0, MAX_ID) } : {}), + ...(value.sequence !== undefined ? { sequence: value.sequence } : {}), + ...(value.kind !== undefined ? { kind: value.kind } : {}), + ...(value.change !== undefined ? { change: value.change } : {}), + recordCount: records.length, + agentIds, + }; +} + +export function parseAcpSubagentEvent(value: unknown): AcpSubagentEventPayload | undefined { + try { + if (!isRecord(value)) return undefined; + if (value.version !== ACP_SUBAGENT_EVENTS_VERSION) return undefined; + const sessionId = boundIdentity(value.sessionId, MAX_SESSION_ID); + if (!sessionId) return undefined; + if (!Number.isInteger(value.sequence) || (value.sequence as number) < 1) return undefined; + if (value.kind !== "snapshot" && value.kind !== "delta") return undefined; + if ( + value.kind === "delta" && + value.change !== "started" && + value.change !== "updated" && + value.change !== "terminal" + ) { + return undefined; + } + if (!Array.isArray(value.records)) return undefined; + // Snapshots are authoritative. Truncating would drop children, and the mapper + // would then cancel the missing ones. Reject the whole envelope instead. + if (value.kind === "snapshot" && value.records.length > MAX_SNAPSHOT_RECORDS) { + return undefined; + } + const limit = value.kind === "snapshot" ? MAX_SNAPSHOT_RECORDS : MAX_DELTA_RECORDS; + const records: AcpSubagentRecord[] = []; + for (const item of value.records) { + if (records.length >= limit) break; + const parsed = parseAcpSubagentRecord(item); + if (!parsed) return undefined; + records.push(parsed); + } + return { + version: ACP_SUBAGENT_EVENTS_VERSION, + sessionId, + sequence: value.sequence as number, + kind: value.kind, + ...(value.kind === "delta" ? { change: value.change as AcpSubagentDeltaChange } : {}), + records, + }; + } catch { + return undefined; + } +} + +export function parseAcpSubagentRecord(value: unknown): AcpSubagentRecord | undefined { + if (!isRecord(value)) return undefined; + const agentId = boundIdentity(value.agentId); + if (!agentId) return undefined; + if (!LIFECYCLE_STATE_SET.has(value.state as string)) return undefined; + const updatedAt = asTimestamp(value.updatedAt); + if (updatedAt === undefined) return undefined; + + const parentAgentId = boundIdentity(value.parentAgentId); + const displayName = boundedText(value.displayName, MAX_NAME); + const typeName = boundedText(value.typeName, MAX_TYPE); + const role = boundedText(value.role, MAX_TYPE); + const taskId = boundIdentity(value.taskId, MAX_TASK_FIELD); + const taskSubject = boundedText(value.taskSubject, MAX_TASK_FIELD); + const description = boundedText(value.description, MAX_DESCRIPTION); + const modelId = boundedText(value.modelId, MAX_MODEL); + const latestActivity = boundedText(value.latestActivity, MAX_ACTIVITY); + const lastToolName = boundedText(value.lastToolName, MAX_RECENT_ITEM); + const lineage = Array.isArray(value.lineage) + ? value.lineage + .flatMap((id) => { + const next = boundIdentity(id); + return next ? [next] : []; + }) + .slice(0, MAX_LINEAGE) + : []; + const recent = parseRecentActivity(value.recentActivity); + const terminal = parseTerminal(value.terminal); + const startedAt = asTimestamp(value.startedAt); + const completedAt = asTimestamp(value.completedAt); + + return { + agentId, + state: value.state as AcpSubagentLifecycleState, + updatedAt, + ...(parentAgentId ? { parentAgentId } : {}), + ...(displayName ? { displayName } : {}), + ...(typeName ? { typeName } : {}), + ...(role ? { role } : {}), + ...(taskId ? { taskId } : {}), + ...(taskSubject ? { taskSubject } : {}), + ...(description ? { description } : {}), + ...(modelId ? { modelId } : {}), + ...(latestActivity ? { latestActivity } : {}), + ...(lastToolName ? { lastToolName } : {}), + ...(lineage.length > 0 ? { lineage } : {}), + ...(typeof value.spawnDepth === "number" && Number.isFinite(value.spawnDepth) + ? { spawnDepth: Math.min(Math.max(0, Math.trunc(value.spawnDepth)), MAX_SPAWN_DEPTH) } + : {}), + ...(typeof value.runtimeEpoch === "number" && Number.isFinite(value.runtimeEpoch) + ? { runtimeEpoch: value.runtimeEpoch } + : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(completedAt !== undefined ? { completedAt } : {}), + ...(recent ? { recentActivity: recent } : {}), + ...(terminal ? { terminal } : {}), + }; +} + +function metaAdvertisesSubagentEvents(meta: unknown): boolean { + if (!isRecord(meta)) return false; + const ns = meta[CONTEXTIVITY_META_NAMESPACE]; + if (!isRecord(ns)) return false; + const advertised = ns.subagentEvents; + if (!isRecord(advertised)) return false; + return advertised.version === ACP_SUBAGENT_EVENTS_VERSION; +} + +function recordsFromUnknown(value: Record): unknown[] | undefined { + return Array.isArray(value.records) ? value.records : undefined; +} + +function parseRecentActivity(value: unknown): AcpSubagentRecentActivity | undefined { + if (!isRecord(value)) return undefined; + const updatedAt = asTimestamp(value.updatedAt); + if (updatedAt === undefined) return undefined; + return { + updatedAt, + assistantMessages: boundStringList(value.assistantMessages), + toolCalls: boundStringList(value.toolCalls), + }; +} + +function parseTerminal(value: unknown): AcpSubagentTerminal | undefined { + if (!isRecord(value)) return undefined; + const endedAt = asTimestamp(value.endedAt); + if (!TERMINAL_KINDS.has(value.kind as string) || endedAt === undefined) { + return undefined; + } + const summary = boundedText(value.summary, MAX_SUMMARY); + const error = boundedText(value.error, MAX_ERROR); + const reportStatus = + typeof value.reportStatus === "string" && REPORT_STATUSES.has(value.reportStatus) + ? (value.reportStatus as AcpSubagentTerminal["reportStatus"]) + : undefined; + return { + kind: value.kind as string, + endedAt, + ...(summary ? { summary } : {}), + ...(error ? { error } : {}), + ...(reportStatus ? { reportStatus } : {}), + }; +} + +function boundStringList(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const item of value) { + const next = boundedText(item, MAX_RECENT_ITEM); + if (next) out.push(next); + if (out.length >= MAX_RECENT) break; + } + return out; +} + +function asTimestamp(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function boundIdentity(value: unknown, max = MAX_ID): string | undefined { + if (typeof value !== "string" || value.length === 0 || value.length > max) return undefined; + return value; +} + +function boundedText(value: unknown, max: number): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) return undefined; + const redacted = normalized.replace( + /\b(?:api[_-]?key|token|secret|password|authorization|bearer)\s*[:=]\s*\S+/gi, + "[redacted]", + ); + if (redacted.length <= max) return redacted; + return `${redacted.slice(0, Math.max(0, max - 1))}…`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.test.ts b/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.test.ts new file mode 100644 index 0000000000000..4156435b52fad --- /dev/null +++ b/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.test.ts @@ -0,0 +1,420 @@ +import { classifyTaskAgentKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { AcpSubagentEventPayload, AcpSubagentRecord } from "./ContextivityAcpExtension.ts"; +import { + emptyContextivitySubagentMapperState, + mapContextivitySubagentEvent, + type ContextivityMappedTaskEvent, + type ContextivitySubagentMapperState, +} from "./ContextivityAcpSubagentMapper.ts"; + +function record(overrides: Partial = {}): AcpSubagentRecord { + return { + agentId: "child-1", + state: "running", + updatedAt: 1, + ...overrides, + }; +} + +function event( + overrides: Partial & Pick, +): AcpSubagentEventPayload { + return { + version: 1, + sessionId: "sess", + records: [record()], + ...overrides, + }; +} + +function apply( + payload: AcpSubagentEventPayload, + state: ContextivitySubagentMapperState = emptyContextivitySubagentMapperState(), +) { + return mapContextivitySubagentEvent(payload, state); +} + +function types(events: ReadonlyArray) { + return events.map((entry) => entry.type); +} + +describe("ContextivityAcpSubagentMapper", () => { + it("maps start/progress/waiting/idle/completion and classifies as an agent task", () => { + let state = emptyContextivitySubagentMapperState(); + const started = apply( + event({ + sequence: 1, + kind: "delta", + change: "started", + records: [ + record({ + displayName: "Scout", + typeName: "scout", + description: "Inspect the repo", + modelId: "gpt-5.4-mini", + parentAgentId: "root", + lineage: ["root", "child-1"], + state: "spawned", + }), + ], + }), + state, + ); + state = started.state; + expect(started.events[0]).toMatchObject({ + type: "task.started", + payload: { + taskId: "child-1", + title: "Scout", + role: "scout", + model: "gpt-5.4-mini", + parentAgentId: "root", + taskType: "subagent", + timelineBypass: true, + agentPath: "/root/child-1", + }, + }); + expect(started.events[0]?.payload).not.toHaveProperty("agentId"); + expect( + classifyTaskAgentKind({ + taskType: + started.events[0]?.type === "task.started" + ? started.events[0].payload.taskType + : undefined, + }), + ).toBe("agent"); + + const waiting = apply( + event({ + sequence: 2, + kind: "delta", + change: "updated", + records: [record({ state: "waiting_approval", latestActivity: "needs permission" })], + }), + state, + ); + expect( + waiting.events.some( + (entry) => entry.type === "task.updated" && entry.payload.status === "waiting", + ), + ).toBe(true); + expect(waiting.events.some((entry) => entry.type === "task.progress")).toBe(true); + state = waiting.state; + + const idle = apply( + event({ + sequence: 3, + kind: "delta", + change: "updated", + records: [record({ state: "idle" })], + }), + state, + ); + expect( + idle.events.some((entry) => entry.type === "task.updated" && entry.payload.status === "idle"), + ).toBe(true); + state = idle.state; + + const completed = apply( + event({ + sequence: 4, + kind: "delta", + change: "terminal", + records: [ + record({ + state: "completed", + terminal: { kind: "completed", endedAt: 2, summary: "done" }, + }), + ], + }), + state, + ); + expect(completed.events).toMatchObject([ + { type: "task.completed", payload: { status: "completed", summary: "done" } }, + ]); + }); + + it("emits cancelled then stopped for cancellation", () => { + const { events } = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [record({ state: "cancelled", terminal: { kind: "cancelled", endedAt: 2 } })], + }), + ); + expect(events).toMatchObject([ + { type: "task.started" }, + { type: "task.updated", payload: { status: "cancelled" } }, + { type: "task.completed", payload: { status: "stopped" } }, + ]); + }); + + it("treats a later summary patch as updated, not a second terminal", () => { + let state = emptyContextivitySubagentMapperState(); + const completed = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [ + record({ + runtimeEpoch: 1, + state: "completed", + terminal: { kind: "completed", endedAt: 2, summary: "done" }, + }), + ], + }), + state, + ); + state = completed.state; + const patch = apply( + event({ + sequence: 2, + kind: "delta", + change: "updated", + records: [ + record({ + runtimeEpoch: 1, + state: "completed", + latestActivity: "wrote report", + terminal: { kind: "completed", endedAt: 2, summary: "done, with notes" }, + }), + ], + }), + state, + ); + expect(patch.events.some((entry) => entry.type === "task.completed")).toBe(false); + expect(patch.events.some((entry) => entry.type === "task.progress")).toBe(true); + }); + + it("supports concurrent children and nesting", () => { + const { events } = apply( + event({ + sequence: 1, + kind: "snapshot", + records: [ + record({ agentId: "parent", displayName: "Coordinator", typeName: "coordinator" }), + record({ + agentId: "child-a", + parentAgentId: "parent", + displayName: "Worker A", + typeName: "worker", + }), + record({ + agentId: "child-b", + parentAgentId: "parent", + displayName: "Worker B", + typeName: "worker", + latestActivity: "searching", + }), + ], + }), + ); + const startedIds = events + .filter((entry) => entry.type === "task.started") + .map((entry) => entry.payload.taskId); + expect(startedIds).toEqual(["parent", "child-a", "child-b"]); + expect( + events.find((entry) => entry.type === "task.started" && entry.payload.taskId === "child-a") + ?.payload.parentAgentId, + ).toBe("parent"); + }); + + it("ignores duplicate and out-of-order sequences and does not emit duplicate terminals", () => { + let state = emptyContextivitySubagentMapperState(); + const first = apply( + event({ + sequence: 1, + kind: "delta", + change: "started", + records: [record({ state: "spawned" })], + }), + state, + ); + state = first.state; + const dup = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [record({ state: "failed" })], + }), + state, + ); + expect(dup.events).toEqual([]); + const third = apply( + event({ + sequence: 3, + kind: "delta", + change: "terminal", + records: [ + record({ + state: "completed", + terminal: { kind: "completed", endedAt: 2, summary: "first terminal" }, + }), + ], + }), + state, + ); + state = third.state; + expect(types(third.events)).toContain("task.completed"); + const late = apply( + event({ + sequence: 2, + kind: "delta", + change: "terminal", + records: [record({ state: "failed", terminal: { kind: "failed", endedAt: 3 } })], + }), + state, + ); + expect(late.events).toEqual([]); + const duplicateTerminal = apply( + event({ + sequence: 4, + kind: "delta", + change: "terminal", + records: [record({ state: "failed", terminal: { kind: "failed", endedAt: 4 } })], + }), + state, + ); + expect(duplicateTerminal.events).toEqual([]); + }); + + it("snapshot restore does not resurrect a same-epoch completed child", () => { + let state = emptyContextivitySubagentMapperState(); + const completed = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [ + record({ + runtimeEpoch: 1, + state: "completed", + terminal: { kind: "completed", endedAt: 2, summary: "done" }, + }), + ], + }), + state, + ); + state = completed.state; + const snapshot = apply( + event({ + sequence: 2, + kind: "snapshot", + records: [ + record({ + runtimeEpoch: 1, + state: "completed", + terminal: { kind: "completed", endedAt: 2, summary: "done" }, + }), + ], + }), + state, + ); + expect(snapshot.events).toEqual([]); + }); + + it("higher runtimeEpoch is a fresh start, not a resurrection", () => { + let state = emptyContextivitySubagentMapperState(); + const first = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [ + record({ + runtimeEpoch: 1, + state: "completed", + terminal: { kind: "completed", endedAt: 2 }, + }), + ], + }), + state, + ); + state = first.state; + const respawn = apply( + event({ + sequence: 2, + kind: "delta", + change: "started", + records: [record({ runtimeEpoch: 2, state: "running", displayName: "Scout" })], + }), + state, + ); + expect(respawn.events[0]).toMatchObject({ type: "task.started", payload: { title: "Scout" } }); + }); + + it("does not complete when change is terminal without a terminal state or object", () => { + const { events } = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [record({ state: "running" })], + }), + ); + expect(events.some((entry) => entry.type === "task.completed")).toBe(false); + expect(events.some((entry) => entry.type === "task.started")).toBe(true); + }); + + it("completes a terminal change when the record carries a valid terminal object", () => { + const { events } = apply( + event({ + sequence: 1, + kind: "delta", + change: "terminal", + records: [ + record({ + state: "running", + terminal: { kind: "completed", endedAt: 2, summary: "done via object" }, + }), + ], + }), + ); + expect(events).toMatchObject([ + { type: "task.started" }, + { type: "task.completed", payload: { status: "completed", summary: "done via object" } }, + ]); + }); + + it("snapshot cancels missing non-terminal children", () => { + let state = emptyContextivitySubagentMapperState(); + const two = apply( + event({ + sequence: 1, + kind: "snapshot", + records: [record({ agentId: "keep" }), record({ agentId: "drop" })], + }), + state, + ); + state = two.state; + const next = apply( + event({ + sequence: 2, + kind: "snapshot", + records: [record({ agentId: "keep", state: "running" })], + }), + state, + ); + expect( + next.events.some( + (entry) => + entry.type === "task.updated" && + entry.payload.taskId === "drop" && + entry.payload.status === "cancelled", + ), + ).toBe(true); + expect( + next.events.some( + (entry) => + entry.type === "task.completed" && + entry.payload.taskId === "drop" && + entry.payload.status === "stopped", + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.ts b/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.ts new file mode 100644 index 0000000000000..44314b1589f41 --- /dev/null +++ b/apps/server/src/provider/acp/ContextivityAcpSubagentMapper.ts @@ -0,0 +1,307 @@ +/** + * Maps negotiated ACP sub-agent events onto T3's canonical task.* lifecycle. + * + * Child identity is `taskId` = record.agentId. `parentAgentId` is preserved. + * Do not put the child id in TaskAgentLinkage.agentId (that field is the owner + * of a nested background task). `taskType` is always `subagent` so ingestion + * stamps agentKind=agent. timelineBypass keeps rows off the parent chat. + */ + +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import { + RuntimeTaskId, + type RuntimeTaskStatus, + type TaskCompletedPayload, + type TaskProgressPayload, + type TaskStartedPayload, + type TaskUpdatedPayload, +} from "@t3tools/contracts"; + +import { + isAcpSubagentTerminalState, + type AcpSubagentEventPayload, + type AcpSubagentLifecycleState, + type AcpSubagentRecord, +} from "./ContextivityAcpExtension.ts"; + +export type ContextivityMappedTaskEvent = + | { readonly type: "task.started"; readonly payload: TaskStartedPayload } + | { readonly type: "task.progress"; readonly payload: TaskProgressPayload } + | { readonly type: "task.updated"; readonly payload: TaskUpdatedPayload } + | { readonly type: "task.completed"; readonly payload: TaskCompletedPayload }; + +interface TrackedChild { + runtimeEpoch: number; + startedEmitted: boolean; + terminalEmitted: boolean; + lastStatus?: RuntimeTaskStatus; +} + +export interface ContextivitySubagentMapperState { + lastSequence: number; + children: Map; +} + +export function emptyContextivitySubagentMapperState(): ContextivitySubagentMapperState { + return { lastSequence: 0, children: new Map() }; +} + +export function mapContextivitySubagentEvent( + payload: AcpSubagentEventPayload, + state: ContextivitySubagentMapperState, +): { + readonly events: ReadonlyArray; + readonly state: ContextivitySubagentMapperState; +} { + if (payload.sequence <= state.lastSequence) { + return { events: [], state }; + } + + const next: ContextivitySubagentMapperState = { + lastSequence: payload.sequence, + children: new Map(state.children), + }; + const events: ContextivityMappedTaskEvent[] = []; + + if (payload.kind === "snapshot") { + const seen = new Set(payload.records.map((record) => record.agentId)); + for (const [agentId, child] of next.children) { + if (!seen.has(agentId) && !child.terminalEmitted) { + emitCancelled(events, agentId, child); + } + } + for (const record of payload.records) { + applyRecord(events, next, record); + } + return { events, state: next }; + } + + for (const record of payload.records) { + applyRecord(events, next, record, payload.change); + } + return { events, state: next }; +} + +function applyRecord( + events: ContextivityMappedTaskEvent[], + state: ContextivitySubagentMapperState, + record: AcpSubagentRecord, + change?: AcpSubagentEventPayload["change"], +): void { + const epoch = record.runtimeEpoch ?? 0; + const existing = state.children.get(record.agentId); + if (existing && epoch < existing.runtimeEpoch) { + return; + } + + const isNewEpoch = existing === undefined || epoch > existing.runtimeEpoch; + const child: TrackedChild = isNewEpoch + ? { runtimeEpoch: epoch, startedEmitted: false, terminalEmitted: false } + : existing; + if (isNewEpoch) { + state.children.set(record.agentId, child); + } + + const terminal = recordCompletes(record, change); + if (child.terminalEmitted && !isNewEpoch) { + if (change === "updated") { + emitProgressIfPresent(events, record); + } + return; + } + + if (!child.startedEmitted || isNewEpoch) { + events.push({ type: "task.started", payload: startedPayload(record) }); + child.startedEmitted = true; + child.terminalEmitted = false; + child.lastStatus = "running"; + } + + if (terminal) { + emitTerminal(events, record, child); + return; + } + + emitProgressIfPresent(events, record); + + const status = statusFromLifecycle(record.state); + if (status !== undefined && status !== child.lastStatus) { + events.push({ + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(record.agentId), + status, + ...linkage(record), + ...(record.description || record.taskSubject + ? { description: record.description ?? record.taskSubject } + : {}), + }, + }); + child.lastStatus = status; + } +} + +function emitTerminal( + events: ContextivityMappedTaskEvent[], + record: AcpSubagentRecord, + child: TrackedChild, +): void { + const summary = terminalSummary(record); + const error = record.terminal?.error; + const endedAt = epochMillisToIso(record.completedAt ?? record.terminal?.endedAt); + + if (record.state === "cancelled" || record.terminal?.kind === "cancelled") { + events.push({ + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(record.agentId), + status: "cancelled", + ...linkage(record), + ...(error ? { error } : {}), + ...(endedAt ? { endedAt } : {}), + }, + }); + events.push({ + type: "task.completed", + payload: { + taskId: RuntimeTaskId.make(record.agentId), + status: "stopped", + ...linkage(record), + ...(summary ? { summary } : {}), + }, + }); + child.terminalEmitted = true; + child.lastStatus = "cancelled"; + return; + } + + const failed = record.state === "failed" || record.terminal?.kind === "failed"; + events.push({ + type: "task.completed", + payload: { + taskId: RuntimeTaskId.make(record.agentId), + status: failed ? "failed" : "completed", + ...linkage(record), + ...(summary ? { summary } : {}), + }, + }); + child.terminalEmitted = true; + child.lastStatus = failed ? "failed" : "completed"; +} + +function emitCancelled( + events: ContextivityMappedTaskEvent[], + agentId: string, + child: TrackedChild, +): void { + const taskId = RuntimeTaskId.make(agentId); + const identity = { + taskType: "subagent", + timelineBypass: true, + } as const; + events.push({ + type: "task.updated", + payload: { taskId, status: "cancelled", ...identity }, + }); + events.push({ + type: "task.completed", + payload: { taskId, status: "stopped", ...identity }, + }); + child.terminalEmitted = true; + child.lastStatus = "cancelled"; +} + +function emitProgressIfPresent( + events: ContextivityMappedTaskEvent[], + record: AcpSubagentRecord, +): void { + const summary = + record.latestActivity ?? + record.lastToolName ?? + record.recentActivity?.assistantMessages.at(-1) ?? + record.recentActivity?.toolCalls.at(-1); + if (!summary && !record.lastToolName) return; + const description = + record.displayName ?? record.description ?? record.taskSubject ?? record.agentId; + events.push({ + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(record.agentId), + description, + ...linkage(record), + ...(summary ? { summary } : {}), + ...(record.lastToolName ? { lastToolName: record.lastToolName } : {}), + }, + }); +} + +function startedPayload(record: AcpSubagentRecord): TaskStartedPayload { + const title = record.displayName ?? record.agentId; + const description = record.description ?? record.taskSubject ?? title; + return { + taskId: RuntimeTaskId.make(record.agentId), + description, + ...linkage(record), + title, + }; +} + +function linkage(record: AcpSubagentRecord): { + readonly taskType: "subagent"; + readonly title?: string; + readonly role?: string; + readonly model?: string; + readonly parentAgentId?: string; + readonly agentPath?: string; + readonly timelineBypass: true; +} { + const title = record.displayName; + const role = record.role ?? record.typeName; + const agentPath = + record.lineage && record.lineage.length > 0 ? `/${record.lineage.join("/")}` : undefined; + return { + taskType: "subagent", + timelineBypass: true, + ...(title ? { title } : {}), + ...(role ? { role } : {}), + ...(record.modelId ? { model: record.modelId } : {}), + ...(record.parentAgentId ? { parentAgentId: record.parentAgentId } : {}), + ...(agentPath ? { agentPath } : {}), + }; +} + +function recordCompletes( + record: AcpSubagentRecord, + change?: AcpSubagentEventPayload["change"], +): boolean { + if (isAcpSubagentTerminalState(record.state)) return true; + return change === "terminal" && record.terminal !== undefined; +} + +function statusFromLifecycle(state: AcpSubagentLifecycleState): RuntimeTaskStatus | undefined { + switch (state) { + case "spawned": + return "pending"; + case "running": + case "resetting": + return "running"; + case "waiting_input": + case "waiting_approval": + return "waiting"; + case "idle": + return "idle"; + default: + return undefined; + } +} + +function terminalSummary(record: AcpSubagentRecord): string | undefined { + return record.terminal?.summary ?? record.terminal?.error ?? record.description; +} + +function epochMillisToIso(ms: number | undefined): string | undefined { + if (ms === undefined || !Number.isFinite(ms)) return undefined; + return Option.getOrUndefined(Option.map(DateTime.make(ms), DateTime.formatIso)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c1..bbff99705d2b9 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AcpRegistryDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveAcpRegistryModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeAcpRegistryRuntime({ + settings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* applyAcpRegistryModelSelection({ + runtime, + currentModelId: currentAcpRegistryModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set ACP Registry model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(ACP_REGISTRY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "ACP Registry text generation timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "ACP Registry text generation failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "ACP Registry text generation was cancelled." + : "ACP agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "ACP agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "ACP Registry text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AcpRegistryTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runAcpRegistryJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AcpRegistryTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runAcpRegistryJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AcpRegistryTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpRegistryJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AcpRegistryTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runAcpRegistryJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fee..298aa46cf1f05 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ACPRegistryIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("acpRegistry")]: ACPRegistryIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 158908b5e9428..260d895ff2bf8 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,7 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -88,11 +88,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, { value: ProviderDriverKind.make("piAgent"), label: "Pi Agent", diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d68075..019df665629ae 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AcpRegistrySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -7,7 +8,15 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ACPRegistryIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + icon: ACPRegistryIcon, + badgeLabel: "Early Access", + settingsSchema: AcpRegistrySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fba..b048cc6ce4895 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/contextivity/README.md b/contextivity/README.md new file mode 100644 index 0000000000000..9ae4f15a95fbb --- /dev/null +++ b/contextivity/README.md @@ -0,0 +1,11 @@ +# Contextivity T3 downstream + +Isolated nightly distribution for the `Contextivity/t3code` fork. This directory is not part of the +generic ACP provider implementation. + +Canonical runbook: [docs/operations/contextivity-downstream.md](../docs/operations/contextivity-downstream.md) + +```sh +./bin/t3-ctx --help +node --test --experimental-strip-types src/*.test.ts +``` diff --git a/contextivity/bin/t3-ctx b/contextivity/bin/t3-ctx new file mode 100755 index 0000000000000..7c0e123632af4 --- /dev/null +++ b/contextivity/bin/t3-ctx @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +exec node --experimental-strip-types "$ROOT/src/cli.ts" "$@" diff --git a/contextivity/inventory.example.json b/contextivity/inventory.example.json new file mode 100644 index 0000000000000..6d24af1bcc0f6 --- /dev/null +++ b/contextivity/inventory.example.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "channel": "nightly", + "hosts": [ + { + "name": "workstation", + "via": "local", + "clientGate": true, + "restartCommand": "launchctl kickstart -k gui/$UID/com.example.contextivity-t3", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3000/health" + }, + { + "name": "builder", + "via": "ssh", + "sshAlias": "builder", + "restartCommand": "launchctl kickstart -k gui/$UID/com.example.contextivity-t3", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3000/health" + }, + { + "name": "lab", + "via": "ssh", + "sshAlias": "lab", + "restartCommand": "systemctl --user restart contextivity-t3", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3000/health" + } + ] +} diff --git a/contextivity/package.json b/contextivity/package.json new file mode 100644 index 0000000000000..68e1efbe73335 --- /dev/null +++ b/contextivity/package.json @@ -0,0 +1,16 @@ +{ + "name": "@contextivity/t3-downstream", + "private": true, + "description": "Contextivity downstream T3 nightly distribution. Isolated from the generic ACP patch stack.", + "bin": { + "t3-ctx": "./bin/t3-ctx" + }, + "type": "module", + "scripts": { + "test": "node --test --experimental-strip-types src/*.test.ts", + "cli": "node --experimental-strip-types src/cli.ts" + }, + "engines": { + "node": "^24.13.1" + } +} diff --git a/contextivity/src/args.ts b/contextivity/src/args.ts new file mode 100644 index 0000000000000..30b7a432d7691 --- /dev/null +++ b/contextivity/src/args.ts @@ -0,0 +1,64 @@ +export interface ParsedArgs { + readonly command: string; + readonly positionals: readonly string[]; + readonly flags: Readonly>; +} + +export function parseArgs(argv: readonly string[]): ParsedArgs { + const [command = "help", ...rest] = argv; + const positionals: string[] = []; + const flags: Record = {}; + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (token === undefined) break; + if (token === "--") { + positionals.push(...rest.slice(index + 1)); + break; + } + if (token.startsWith("--")) { + const eq = token.indexOf("="); + if (eq !== -1) { + flags[token.slice(2, eq)] = token.slice(eq + 1); + continue; + } + const name = token.slice(2); + const next = rest[index + 1]; + if (next === undefined || next.startsWith("-")) { + flags[name] = true; + } else { + flags[name] = next; + index += 1; + } + continue; + } + positionals.push(token); + } + return { command, positionals, flags }; +} + +export function flagString(flags: ParsedArgs["flags"], name: string): string | undefined { + const value = flags[name]; + return typeof value === "string" ? value : undefined; +} + +export function flagBool(flags: ParsedArgs["flags"], name: string): boolean { + return flags[name] === true || flags[name] === "true" || flags[name] === "1"; +} + +export function usage(): string { + return `t3-ctx — Contextivity downstream T3 distribution + +Usage: + t3-ctx discover-upstream [--remote ] + t3-ctx sync-tag --tag [--remote ] + t3-ctx report-sync-failure --tag --reason + t3-ctx write-manifest --out [identity and artifact flags] + t3-ctx candidate-test-plan + t3-ctx validate-workflows + t3-ctx check-mac-client --mac-client-version --upstream-version + t3-ctx promote --channel nightly|stable --manifest --mac-client-version + t3-ctx updater status|check|update|stage|activate|rollback + t3-ctx publish-candidate --dir --repo + t3-ctx fleet update --inventory --manifest --mac-client-version [--stage-only] +`; +} diff --git a/contextivity/src/atomic.ts b/contextivity/src/atomic.ts new file mode 100644 index 0000000000000..c600ae26108b7 --- /dev/null +++ b/contextivity/src/atomic.ts @@ -0,0 +1,72 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeCrypto from "node:crypto"; + +export function atomicWriteFile( + filePath: string, + contents: string | Uint8Array, + mode?: number, +): void { + NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); + const tempPath = NodePath.join( + NodePath.dirname(filePath), + `.${NodeCrypto.randomBytes(8).toString("hex")}.tmp`, + ); + try { + NodeFS.writeFileSync(tempPath, contents); + if (mode !== undefined) NodeFS.chmodSync(tempPath, mode); + NodeFS.renameSync(tempPath, filePath); + } catch (error) { + try { + NodeFS.rmSync(tempPath, { force: true }); + } catch { + // ignore cleanup + } + throw error; + } +} + +export function atomicSymlink(linkPath: string, target: string): void { + NodeFS.mkdirSync(NodePath.dirname(linkPath), { recursive: true }); + const tempPath = NodePath.join( + NodePath.dirname(linkPath), + `.${NodeCrypto.randomBytes(8).toString("hex")}.tmp-link`, + ); + try { + NodeFS.symlinkSync(target, tempPath); + NodeFS.renameSync(tempPath, linkPath); + } catch (error) { + try { + NodeFS.rmSync(tempPath, { force: true }); + } catch { + // ignore cleanup + } + throw error; + } +} + +export function readLinkOrNull(linkPath: string): string | null { + try { + return NodeFS.readlinkSync(linkPath); + } catch { + return null; + } +} + +export function readTextOrNull(filePath: string): string | null { + try { + return NodeFS.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +export function uniqueTempDir(prefix: string): string { + const dir = NodePath.join( + NodeOS.tmpdir(), + `${prefix}-${NodeCrypto.randomBytes(8).toString("hex")}`, + ); + NodeFS.mkdirSync(dir, { recursive: true }); + return dir; +} diff --git a/contextivity/src/cli.ts b/contextivity/src/cli.ts new file mode 100644 index 0000000000000..912431fdd9db0 --- /dev/null +++ b/contextivity/src/cli.ts @@ -0,0 +1,696 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { flagBool, flagString, parseArgs, usage } from "./args.ts"; +import { hostCommandFromFlags } from "./host-command.ts"; +import { + CANDIDATE_SERVER_CHECKS, + DOWNSTREAM_GITHUB, + GENERIC_ACP_FOCUSED_TESTS, + PLATFORMS, +} from "./config.ts"; +import { createGitRunner } from "./git-runner.ts"; +import { resolveGitHubAuth, resolveGitHubEndpoints } from "./github-auth.ts"; +import { formatChecksumFile } from "./hash.ts"; +import { clientGateHost, decodeInventory } from "./inventory.ts"; +import { executeTwoPhase, gateFleetWithMacClient, planFleetUpdate } from "./fleet.ts"; +import { + buildCandidateManifest, + candidateReleaseTag, + decodeManifest, + encodeManifest, +} from "./manifest.ts"; +import { detectHostPlatform } from "./platforms.ts"; +import { promoteCandidate, buildChannelPointer } from "./promote.ts"; +import { + assertGhArgvHasNoSecret, + candidateReleaseNotes, + candidateReleaseTitle, + decodeChannelPointer, + ghReleaseCreateArgs, + ghReleaseDownloadArgs, + ghReleaseViewArgs, + releaseAssetFiles, + resolveExactReleaseTag, +} from "./release.ts"; +import { + HOST_HEALTH_TIMEOUT_MS, + HOST_RESTART_TIMEOUT_MS, + redactCommandResult, + runBoundedHostCommand, + spawnCommand, +} from "./spawn.ts"; +import { failClosed, syncExactUpstreamTag, type SyncFailure } from "./sync.ts"; +import { + findNamedUpstreamNightly, + lsRemoteArgs, + requireNewestUpstreamNightly, + type UpstreamNightly, + upstreamRemoteUrl, +} from "./upstream.ts"; +import { + activateAndVerify, + layoutAt, + resolveLayoutRoot, + rollbackCurrent, + stageCandidate, + updaterStatus, +} from "./updater.ts"; +import { reportSyncFailureIssue } from "./issues.ts"; +import { resolveMacClientVersion } from "./mac-client.ts"; +import { + expectedWorkflowPaths, + validatePosixShell, + validateWorkflowYaml, +} from "./workflow-validate.ts"; +import { tarExtractArgs } from "./pack.ts"; + +const repoRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL("../..", import.meta.url))); + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function requireFlag(flags: ReturnType["flags"], name: string): string { + const value = flagString(flags, name); + if (!value) { + throw new Error(`Missing required --${name}`); + } + return value; +} + +function activateVerifyCommands( + flags: ReturnType["flags"], +): Parameters[0]["commands"] { + const restartCommand = hostCommandFromFlags(flags, "restart"); + const healthCommand = hostCommandFromFlags(flags, "health"); + return { + extractArchive: async () => undefined, + preflight: async () => ({ code: 0, stdout: "", stderr: "" }), + ...(restartCommand + ? { restart: () => runBoundedHostCommand(restartCommand, HOST_RESTART_TIMEOUT_MS) } + : {}), + ...(healthCommand + ? { health: () => runBoundedHostCommand(healthCommand, HOST_HEALTH_TIMEOUT_MS) } + : {}), + }; +} + +async function discoverUpstream(remote: string): Promise { + const result = await spawnCommand({ + command: "git", + args: lsRemoteArgs(remote), + cwd: repoRoot, + timeoutMs: 60_000, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git ls-remote failed"); + } + return requireNewestUpstreamNightly(result.stdout); +} + +async function githubToken(): Promise { + const explicit = resolveGitHubAuth({ env: process.env }).token; + if (explicit) return explicit; + try { + const gh = await spawnCommand({ + command: "gh", + args: ["auth", "token"], + timeoutMs: 10_000, + }); + if (gh.code === 0 && gh.stdout.trim().length > 0) return gh.stdout.trim(); + } catch { + return null; + } + return null; +} + +async function main(argv: readonly string[]): Promise { + const parsed = parseArgs(argv); + switch (parsed.command) { + case "help": + case "-h": + case "--help": + process.stdout.write(`${usage()}\n`); + return 0; + case "discover-upstream": { + const endpoints = resolveGitHubEndpoints(process.env); + const remote = flagString(parsed.flags, "remote") ?? upstreamRemoteUrl(endpoints.base); + writeJson(await discoverUpstream(remote)); + return 0; + } + case "sync-tag": { + const tag = requireFlag(parsed.flags, "tag"); + const endpoints = resolveGitHubEndpoints(process.env); + const remote = flagString(parsed.flags, "remote") ?? upstreamRemoteUrl(endpoints.base); + const ls = await spawnCommand({ + command: "git", + args: lsRemoteArgs(remote), + cwd: repoRoot, + timeoutMs: 60_000, + }); + if (ls.code !== 0) { + const failure = failClosed(tag, ls.stderr.trim() || "git ls-remote failed"); + writeJson(failure); + return 2; + } + const nightly = findNamedUpstreamNightly(ls.stdout, tag); + if (!nightly) { + const failure = failClosed( + tag.startsWith("v") ? tag : `v${tag}`, + `Requested nightly tag ${tag} was not found on upstream.`, + ); + writeJson(failure); + return 2; + } + const result = await syncExactUpstreamTag({ + git: createGitRunner(repoRoot), + remote, + nightly, + }); + writeJson(result); + return result.ok ? 0 : 2; + } + case "report-sync-failure": { + const failure: SyncFailure = failClosed( + requireFlag(parsed.flags, "tag"), + requireFlag(parsed.flags, "reason"), + flagString(parsed.flags, "conflicts")?.split(",") ?? [], + ); + const token = await githubToken(); + if (!token) { + writeJson({ created: false, skipped: "no-github-auth", failure }); + return 0; + } + const endpoints = resolveGitHubEndpoints(process.env); + const result = await reportSyncFailureIssue( + { + searchOpen: async (title) => { + const query = encodeURIComponent( + `repo:${DOWNSTREAM_GITHUB.owner}/${DOWNSTREAM_GITHUB.repo} is:issue is:open in:title ${title}`, + ); + const response = await fetch(`${endpoints.api}/search/issues?q=${query}`, { + headers: { Authorization: `Bearer ${token}`, "User-Agent": "contextivity-t3" }, + }); + if (!response.ok) return []; + const body = (await response.json()) as { + items?: Array<{ number: number; title: string; html_url: string; state: string }>; + }; + return (body.items ?? []).map((item) => ({ + number: item.number, + title: item.title, + state: item.state === "closed" ? "closed" : "open", + htmlUrl: item.html_url, + })); + }, + create: async (input) => { + const response = await fetch( + `${endpoints.api}/repos/${DOWNSTREAM_GITHUB.owner}/${DOWNSTREAM_GITHUB.repo}/issues`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "contextivity-t3", + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }, + ); + if (!response.ok) { + throw new Error(`GitHub issue create failed (${response.status}).`); + } + const body = (await response.json()) as { + number: number; + title: string; + html_url: string; + state: string; + }; + return { + number: body.number, + title: body.title, + state: body.state === "closed" ? "closed" : "open", + htmlUrl: body.html_url, + }; + }, + }, + failure, + ); + writeJson({ ...result, failure }); + return 0; + } + case "candidate-test-plan": + writeJson({ + focusedTests: GENERIC_ACP_FOCUSED_TESTS, + serverChecks: CANDIDATE_SERVER_CHECKS, + downstreamTests: "node --test --experimental-strip-types contextivity/src/*.test.ts", + }); + return 0; + case "validate-workflows": { + const errors: string[] = []; + for (const relative of expectedWorkflowPaths()) { + const yaml = NodeFS.readFileSync(NodePath.join(repoRoot, relative), "utf8"); + const result = validateWorkflowYaml(relative, yaml); + if (!result.ok) errors.push(...result.errors); + } + const wrapper = NodeFS.readFileSync( + NodePath.join(repoRoot, "contextivity/bin/t3-ctx"), + "utf8", + ); + errors.push(...validatePosixShell(wrapper, "contextivity/bin/t3-ctx")); + if (errors.length > 0) { + process.stderr.write(`${errors.join("\n")}\n`); + return 1; + } + writeJson({ ok: true, workflows: expectedWorkflowPaths() }); + return 0; + } + case "check-mac-client": { + const macClientVersion = resolveMacClientVersion({ + envVersion: + flagString(parsed.flags, "mac-client-version") ?? + process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION, + }); + const upstreamVersion = requireFlag(parsed.flags, "upstream-version"); + const promotion = promoteCandidate({ + channel: "nightly", + macClientVersion, + manifest: buildCandidateManifest({ + upstreamVersion, + upstreamCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + contextivityRevision: "bbbbbbb", + buildRevision: "check", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: PLATFORMS.map((platform) => ({ + platform, + name: `placeholder-${platform}.tar.gz`, + size: 1, + sha256: "a".repeat(64), + })), + }), + }); + writeJson(promotion); + return promotion.ok ? 0 : 2; + } + case "write-manifest": { + const artifactsJson = requireFlag(parsed.flags, "artifacts"); + const artifacts = JSON.parse(artifactsJson) as Array<{ + platform: "linux-x64" | "linux-arm64" | "darwin-x64" | "darwin-arm64"; + name: string; + size: number; + sha256: string; + }>; + const manifest = buildCandidateManifest({ + upstreamVersion: requireFlag(parsed.flags, "upstream-version"), + upstreamTag: flagString(parsed.flags, "upstream-tag"), + upstreamCommit: requireFlag(parsed.flags, "upstream-commit"), + contextivityRevision: requireFlag(parsed.flags, "contextivity-revision"), + buildRevision: requireFlag(parsed.flags, "build-revision"), + nodeEngine: requireFlag(parsed.flags, "node-engine"), + createdAt: flagString(parsed.flags, "created-at") ?? new Date().toISOString(), + artifacts, + }); + const encoded = encodeManifest(manifest); + const out = flagString(parsed.flags, "out"); + if (out) { + NodeFS.mkdirSync(NodePath.dirname(NodePath.resolve(out)), { recursive: true }); + NodeFS.writeFileSync(out, encoded); + NodeFS.writeFileSync( + NodePath.join(NodePath.dirname(NodePath.resolve(out)), "SHA256SUMS"), + formatChecksumFile(manifest.artifacts), + ); + } + process.stdout.write(encoded); + return 0; + } + case "promote": { + const manifest = decodeManifest( + NodeFS.readFileSync(requireFlag(parsed.flags, "manifest"), "utf8"), + ); + const result = promoteCandidate({ + channel: requireFlag(parsed.flags, "channel") as "nightly" | "stable", + manifest, + macClientVersion: resolveMacClientVersion({ + envVersion: + flagString(parsed.flags, "mac-client-version") ?? + process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION, + }), + requestedInstallId: flagString(parsed.flags, "version"), + }); + if (result.ok) { + writeJson( + buildChannelPointer({ + channel: result.channel, + candidateTag: `contextivity-candidate/${result.installId}`, + installId: result.installId, + upstreamVersion: result.upstreamVersion, + contextivityRevision: result.contextivityRevision, + promotedAt: new Date().toISOString(), + }), + ); + return 0; + } + writeJson(result); + return 2; + } + case "updater": { + const action = parsed.positionals[0] ?? "status"; + const layout = layoutAt(resolveLayoutRoot(process.env)); + if (action === "status") { + writeJson(updaterStatus(layout)); + return 0; + } + if (action === "check" || action === "update") { + const token = await githubToken(); + const repo = `${DOWNSTREAM_GITHUB.owner}/${DOWNSTREAM_GITHUB.repo}`; + const target = resolveExactReleaseTag({ + version: flagString(parsed.flags, "version"), + channel: flagString(parsed.flags, "channel"), + }); + const cacheDir = NodePath.join(layout.root, "cache"); + NodeFS.mkdirSync(cacheDir, { recursive: true }); + const ghEnv = token ? { ...process.env, GH_TOKEN: token } : process.env; + const runGh = async (args: readonly string[]) => { + assertGhArgvHasNoSecret(args, [token]); + const spawned = await spawnCommand({ + command: "gh", + args, + env: ghEnv, + timeoutMs: 180_000, + }); + return redactCommandResult(spawned, [token]); + }; + let installId = target.installId; + let candidateTag = target.tag; + if (target.pointer) { + const pointerDir = NodePath.join(cacheDir, "pointer"); + NodeFS.mkdirSync(pointerDir, { recursive: true }); + const downloaded = await runGh( + ghReleaseDownloadArgs({ + tag: target.tag, + repo, + dir: pointerDir, + pattern: "pointer.json", + }), + ); + if (downloaded.code !== 0) { + throw new Error(downloaded.stderr || `Failed to download ${target.tag}.`); + } + const pointer = decodeChannelPointer( + NodeFS.readFileSync(NodePath.join(pointerDir, "pointer.json"), "utf8"), + ); + installId = pointer.installId; + candidateTag = pointer.candidateTag; + } + if (!installId) { + throw new Error("Could not resolve install id."); + } + if (action === "check") { + const current = updaterStatus(layout).current; + writeJson({ + current, + target: installId, + candidateTag, + upToDate: current === installId, + }); + return 0; + } + const artifactDir = NodePath.join(cacheDir, installId); + NodeFS.mkdirSync(artifactDir, { recursive: true }); + const platform = detectHostPlatform().platform; + for (const pattern of ["manifest.json", `t3-server-${platform}.tar.gz`, "SHA256SUMS"]) { + const downloaded = await runGh( + ghReleaseDownloadArgs({ + tag: candidateTag, + repo, + dir: artifactDir, + pattern, + }), + ); + if (downloaded.code !== 0 && pattern !== "SHA256SUMS") { + throw new Error( + downloaded.stderr || `Failed to download ${pattern} from ${candidateTag}.`, + ); + } + } + const manifest = decodeManifest( + NodeFS.readFileSync(NodePath.join(artifactDir, "manifest.json"), "utf8"), + ); + const archivePath = NodePath.join(artifactDir, `t3-server-${platform}.tar.gz`); + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + github: DOWNSTREAM_GITHUB, + githubAuthenticated: Boolean(token), + oidcAvailable: Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL), + commands: { + extractArchive: async (source, destination) => { + const result = await spawnCommand({ + command: "tar", + args: tarExtractArgs(source, destination), + timeoutMs: 120_000, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "tar extract failed"); + } + }, + preflight: async (versionDir) => + spawnCommand({ + command: "node", + args: [NodePath.join(versionDir, "dist/bin.mjs"), "--help"], + env: { ...process.env, CONTEXTIVITY_T3_DISTRIBUTION: "1" }, + timeoutMs: 30_000, + }), + verifyAttestation: token + ? async (args) => + spawnCommand({ + command: "gh", + args, + env: ghEnv, + timeoutMs: 60_000, + }) + : undefined, + }, + }); + if (flagBool(parsed.flags, "stage-only")) { + writeJson({ + installId: staged.installId, + versionDir: staged.versionDir, + activated: false, + }); + return 0; + } + const activated = await activateAndVerify({ + layout, + installId: staged.installId, + commands: activateVerifyCommands(parsed.flags), + }); + writeJson({ + installId: staged.installId, + current: activated.current, + previous: activated.previous, + }); + return 0; + } + if (action === "rollback") { + writeJson(rollbackCurrent(layout)); + return 0; + } + if (action === "activate") { + const installId = requireFlag(parsed.flags, "version"); + writeJson( + await activateAndVerify({ + layout, + installId, + commands: activateVerifyCommands(parsed.flags), + }), + ); + return 0; + } + if (action === "stage") { + const manifest = decodeManifest( + NodeFS.readFileSync(requireFlag(parsed.flags, "manifest"), "utf8"), + ); + const archivePath = requireFlag(parsed.flags, "archive"); + const token = await githubToken(); + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + github: DOWNSTREAM_GITHUB, + githubAuthenticated: Boolean(token), + oidcAvailable: Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL), + commands: { + extractArchive: async (source, destination) => { + const result = await spawnCommand({ + command: "tar", + args: tarExtractArgs(source, destination), + timeoutMs: 120_000, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "tar extract failed"); + } + }, + preflight: async (versionDir) => + spawnCommand({ + command: "node", + args: [NodePath.join(versionDir, "dist/bin.mjs"), "--help"], + env: { ...process.env, CONTEXTIVITY_T3_DISTRIBUTION: "1" }, + timeoutMs: 30_000, + }), + verifyAttestation: token + ? async (args) => + spawnCommand({ + command: "gh", + args, + env: { ...process.env, GH_TOKEN: token }, + timeoutMs: 60_000, + }) + : undefined, + }, + }); + writeJson({ installId: staged.installId, versionDir: staged.versionDir }); + return 0; + } + throw new Error(`Unknown updater action '${action}'.`); + } + case "fleet": { + const action = parsed.positionals[0] ?? "update"; + const inventory = decodeInventory( + NodeFS.readFileSync(requireFlag(parsed.flags, "inventory"), "utf8"), + ); + const manifest = decodeManifest( + NodeFS.readFileSync(requireFlag(parsed.flags, "manifest"), "utf8"), + ); + if (!clientGateHost(inventory)) { + writeJson({ + ok: false, + reason: + "inventory must identify exactly one clientGate host for official Mac desktop verification.", + failClosed: true, + }); + return 2; + } + let macClientVersion: string; + try { + macClientVersion = resolveMacClientVersion({ + envVersion: + flagString(parsed.flags, "mac-client-version") ?? + process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION, + }); + } catch (error) { + writeJson({ + ok: false, + reason: error instanceof Error ? error.message : String(error), + failClosed: true, + }); + return 2; + } + const gate = gateFleetWithMacClient({ manifest, macClientVersion }); + if (!gate.ok) { + writeJson({ ok: false, reason: gate.reason, failClosed: true }); + return 2; + } + const plan = planFleetUpdate({ + inventory, + manifest, + stageOnly: flagBool(parsed.flags, "stage-only") || action === "stage", + }); + const result = await executeTwoPhase({ + plan, + executor: { + run: async (command) => { + try { + const spawned = await spawnCommand({ + command: command.argv[0] ?? "t3-ctx", + args: command.argv.slice(1), + timeoutMs: 300_000, + }); + return { + host: command.host, + ok: spawned.code === 0, + detail: spawned.stderr.trim() || spawned.stdout.trim(), + }; + } catch (error) { + return { + host: command.host, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + }, + }, + }); + writeJson(result); + return result.ok ? 0 : 2; + } + case "publish-candidate": { + const dir = requireFlag(parsed.flags, "dir"); + const repo = + flagString(parsed.flags, "repo") ?? `${DOWNSTREAM_GITHUB.owner}/${DOWNSTREAM_GITHUB.repo}`; + const manifest = decodeManifest( + NodeFS.readFileSync(NodePath.join(dir, "manifest.json"), "utf8"), + ); + const tag = candidateReleaseTag(manifest); + const token = await githubToken(); + const ghEnv = token ? { ...process.env, GH_TOKEN: token } : process.env; + const viewArgs = ghReleaseViewArgs(tag, repo); + assertGhArgvHasNoSecret(viewArgs, [token]); + const existing = await spawnCommand({ + command: "gh", + args: viewArgs, + env: ghEnv, + timeoutMs: 30_000, + }); + if (existing.code === 0) { + throw new Error( + `Candidate ${tag} already exists; refusing to overwrite an immutable release.`, + ); + } + const files = releaseAssetFiles(dir); + const createArgs = ghReleaseCreateArgs({ + tag, + repo, + title: candidateReleaseTitle(manifest), + notes: candidateReleaseNotes(manifest), + files, + }); + assertGhArgvHasNoSecret(createArgs, [token]); + const created = await spawnCommand({ + command: "gh", + args: createArgs, + env: ghEnv, + timeoutMs: 180_000, + }); + if (created.code !== 0) { + throw new Error(redactCommandResult(created, [token]).stderr || "gh release create failed"); + } + writeJson({ ok: true, tag, files: files.map((file) => file.split("/").at(-1)) }); + return 0; + } + case "host-platform": + writeJson(detectHostPlatform()); + return 0; + default: + process.stderr.write(`Unknown command '${parsed.command}'.\n${usage()}\n`); + return 1; + } +} + +const invoked = + process.argv[1] && NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1]); +if (invoked) { + main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }, + ); +} + +export { main }; diff --git a/contextivity/src/config.ts b/contextivity/src/config.ts new file mode 100644 index 0000000000000..6de1668c55970 --- /dev/null +++ b/contextivity/src/config.ts @@ -0,0 +1,72 @@ +export const SCHEMA_VERSION = 1 as const; + +export const UPSTREAM_GITHUB = { + owner: "pingdotgg", + repo: "t3code", +} as const; + +export const DOWNSTREAM_GITHUB = { + owner: "Contextivity", + repo: "t3code", +} as const; + +export const UPSTREAM_NIGHTLY_TAG_PATTERN = /^v(\d+\.\d+\.\d+-nightly\.(\d{8})\.(\d+))$/; + +export const CONTEXTIVITY_DISTRIBUTION_ENV = "CONTEXTIVITY_T3_DISTRIBUTION"; +export const CONTEXTIVITY_HOME_ENV = "CONTEXTIVITY_T3_HOME"; +export const CONTEXTIVITY_GITHUB_TOKEN_ENV = "CONTEXTIVITY_GITHUB_TOKEN"; +export const CONTEXTIVITY_TRUST_MIRROR_ENV = "CONTEXTIVITY_T3_TRUST_MIRROR"; +export const CONTEXTIVITY_GITHUB_API_ENV = "CONTEXTIVITY_T3_GITHUB_API"; +export const CONTEXTIVITY_GITHUB_BASE_ENV = "CONTEXTIVITY_T3_GITHUB_BASE"; + +export const DEFAULT_GITHUB_API = "https://api.github.com"; +export const DEFAULT_GITHUB_BASE = "https://github.com"; + +export const DEFAULT_LAYOUT_ROOT = "~/.contextivity/t3"; +export const MARKER_FILENAME = "contextivity-distribution.json"; +export const MANIFEST_FILENAME = "manifest.json"; +export const CHECKSUMS_FILENAME = "SHA256SUMS"; +export const CURRENT_LINK = "current"; +export const PREVIOUS_LINK = "previous"; + +export const CHANNELS = ["candidate", "nightly", "stable"] as const; +export type Channel = (typeof CHANNELS)[number]; + +export const POINTER_TAGS = { + nightly: "contextivity-nightly", + stable: "contextivity-stable", +} as const; + +export const PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] as const; +export type ArtifactPlatform = (typeof PLATFORMS)[number]; + +export function isArtifactPlatform(value: string): value is ArtifactPlatform { + return (PLATFORMS as readonly string[]).includes(value); +} + +export const MAINTENANCE_ISSUE_LABEL = "contextivity-maintenance"; + +export const GENERIC_ACP_FOCUSED_TESTS = [ + "apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts", + "apps/server/src/provider/Layers/AcpRegistryProvider.test.ts", + "apps/server/src/provider/acp/AcpAdapterSupport.test.ts", + "apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts", + "apps/server/src/provider/acp/AcpRegistrySupport.test.ts", + "apps/server/src/provider/acp/AcpRuntimeModel.test.ts", + "apps/server/src/provider/acp/AcpSessionRuntime.policy.test.ts", + "apps/server/src/provider/acp/ContextivityAcpExtension.test.ts", + "apps/server/src/provider/acp/ContextivityAcpSubagentMapper.test.ts", + "packages/client-runtime/src/state/subagentRuntime.test.ts", +] as const; + +export const CANDIDATE_SERVER_CHECKS = [ + "vp run --filter t3 typecheck", + "vp run --filter t3 build", +] as const; + +export const FORBIDDEN_UPSTREAM_PACKAGE_PATTERNS = [ + /\bnpx\s+t3@/u, + /\bnpm\s+(?:i|install|exec)\s+t3@/u, + /\bpnpm\s+(?:i|install|dlx)\s+t3@/u, + /\bvpnpm\s+(?:i|install)\s+t3@/u, +] as const; diff --git a/contextivity/src/fleet.test.ts b/contextivity/src/fleet.test.ts new file mode 100644 index 0000000000000..ce6516dc531f2 --- /dev/null +++ b/contextivity/src/fleet.test.ts @@ -0,0 +1,426 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTest from "node:test"; +import { parseArgs, usage } from "./args.ts"; +import { main } from "./cli.ts"; +import { CONTEXTIVITY_HOME_ENV, PLATFORMS } from "./config.ts"; +import { + executeTwoPhase, + gateFleetWithMacClient, + hostArgv, + planFleetUpdate, + updaterActivateArgs, +} from "./fleet.ts"; +import { + decodeHostCommandArg, + encodeHostCommandArg, + HEALTH_COMMAND_B64_FLAG, + hostCommandFromFlags, + RESTART_COMMAND_B64_FLAG, +} from "./host-command.ts"; +import { decodeInventory } from "./inventory.ts"; +import { buildCandidateManifest, encodeManifest, type ManifestArtifact } from "./manifest.ts"; +import { layoutAt, stageCandidate } from "./updater.ts"; + +const inventory = decodeInventory(`{ + "schemaVersion": 1, + "channel": "nightly", + "hosts": [ + { + "name": "workstation", + "via": "local", + "clientGate": true, + "restartCommand": "launchctl kickstart workstation", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3000/health" + }, + { + "name": "builder", + "via": "ssh", + "sshAlias": "builder", + "restartCommand": "launchctl kickstart builder", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3001/health" + }, + { + "name": "lab", + "via": "ssh", + "sshAlias": "lab", + "restartCommand": "systemctl --user restart contextivity-t3", + "healthCommand": "curl -sf --max-time 5 http://127.0.0.1:3002/health" + } + ] +}`); + +function fourArtifacts(): ManifestArtifact[] { + return PLATFORMS.map((platform, index) => ({ + platform, + name: `t3-server-${platform}.tar.gz`, + size: index + 1, + sha256: String.fromCharCode(97 + index).repeat(64), + })); +} + +function sshFlattenedRemoteArgv(argv: readonly string[]): string[] { + NodeAssert.equal(argv[0], "ssh"); + const separator = argv.indexOf("--"); + const remote = argv.slice(separator === -1 ? 2 : separator + 1); + return remote.join(" ").split(/\s+/); +} + +const manifest = buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "abc1234", + buildRevision: "run-1", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: fourArtifacts(), +}); + +NodeTest.describe("fleet two-phase update", () => { + NodeTest.it("resolves one identity and stages every host before activate", () => { + const plan = planFleetUpdate({ inventory, manifest }); + NodeAssert.equal(plan.installId, "0.0.34-nightly.20260822.2-ctx.abc1234"); + NodeAssert.equal(plan.stage.length, 3); + NodeAssert.equal(plan.activate.length, 3); + NodeAssert.deepEqual(plan.stage[0]?.argv, [ + "t3-ctx", + "updater", + "update", + "--version", + plan.installId, + "--stage-only", + ]); + NodeAssert.deepEqual(plan.stage[1]?.argv.slice(0, 3), ["ssh", "builder", "--"]); + }); + + NodeTest.it("passes each host restart and health command through remote activate", () => { + const plan = planFleetUpdate({ inventory, manifest }); + const workstationRestart = encodeHostCommandArg("launchctl kickstart workstation"); + const workstationHealth = encodeHostCommandArg( + "curl -sf --max-time 5 http://127.0.0.1:3000/health", + ); + NodeAssert.deepEqual(plan.activate[0]?.argv, [ + "t3-ctx", + "updater", + "activate", + "--version", + plan.installId, + `--${RESTART_COMMAND_B64_FLAG}`, + workstationRestart, + `--${HEALTH_COMMAND_B64_FLAG}`, + workstationHealth, + ]); + NodeAssert.equal(plan.activate[1]?.argv.includes("launchctl kickstart builder"), false); + NodeAssert.equal(plan.activate[2]?.argv.includes(`--${HEALTH_COMMAND_B64_FLAG}`), true); + const lab = plan.activate[2]; + NodeAssert.ok(lab); + const encodedRestart = lab.argv[lab.argv.indexOf(`--${RESTART_COMMAND_B64_FLAG}`) + 1]; + NodeAssert.equal( + decodeHostCommandArg(encodedRestart ?? ""), + "systemctl --user restart contextivity-t3", + ); + }); + + NodeTest.it("preserves quoted multi-word commands after SSH concatenates argv", () => { + const restartCommand = `printf '%s' "kickstart 'builder'; done"`; + const healthCommand = "curl -sf --max-time 5 http://127.0.0.1:3001/health"; + const host = { + name: "builder", + via: "ssh" as const, + sshAlias: "builder", + restartCommand, + healthCommand, + }; + const rawBroken = [ + "ssh", + "builder", + "--", + "t3-ctx", + "updater", + "activate", + "--version", + "id", + "--restart-command", + restartCommand, + "--health-command", + healthCommand, + ]; + const broken = parseArgs(sshFlattenedRemoteArgv(rawBroken).slice(1)); + NodeAssert.equal(broken.flags["restart-command"], "printf"); + NodeAssert.notEqual(hostCommandFromFlags(broken.flags, "restart"), restartCommand); + + const encodedArgv = hostArgv(host, updaterActivateArgs(host, "id")).argv; + NodeAssert.equal( + encodedArgv.some((token) => token.includes(" ") || /[;|&$'"<>]/.test(token)), + false, + ); + const parsed = parseArgs(sshFlattenedRemoteArgv(encodedArgv).slice(1)); + NodeAssert.equal(hostCommandFromFlags(parsed.flags, "restart"), restartCommand); + NodeAssert.equal(hostCommandFromFlags(parsed.flags, "health"), healthCommand); + }); + + NodeTest.it("activates none when any host fails staging", async () => { + const plan = planFleetUpdate({ inventory, manifest }); + const activated: string[] = []; + const result = await executeTwoPhase({ + plan, + executor: { + run: async (command) => { + if (command.argv.includes("activate")) activated.push(command.host); + if (command.host === "builder" && command.argv.includes("--stage-only")) { + return { host: command.host, ok: false, detail: "stage failed" }; + } + return { host: command.host, ok: true, detail: "ok" }; + }, + }, + }); + NodeAssert.equal(result.ok, false); + if (!result.ok) { + NodeAssert.equal(result.phase, "stage"); + NodeAssert.equal(result.activateNone, true); + NodeAssert.equal(result.failedHost, "builder"); + } + NodeAssert.deepEqual(activated, []); + }); + + NodeTest.it("rolls back every host that switched when activation fails", async () => { + const plan = planFleetUpdate({ inventory, manifest }); + const events: string[] = []; + const result = await executeTwoPhase({ + plan, + executor: { + run: async (command) => { + events.push(`${command.host}:${command.argv.slice(-2).join(" ")}`); + if (command.host === "lab" && command.argv.includes("activate")) { + return { host: "lab", ok: false, detail: "health failed" }; + } + return { host: command.host, ok: true, detail: "ok" }; + }, + }, + }); + NodeAssert.equal(result.ok, false); + if (!result.ok) { + NodeAssert.equal(result.phase, "activate"); + NodeAssert.deepEqual(result.rolledBack, ["builder", "workstation"]); + } + }); + + NodeTest.it("rolls back switched hosts when restart or health on activate fails", async () => { + const plan = planFleetUpdate({ inventory, manifest }); + const result = await executeTwoPhase({ + plan, + executor: { + run: async (command) => { + if ( + command.host === "builder" && + command.argv.includes("activate") && + command.argv.includes(`--${HEALTH_COMMAND_B64_FLAG}`) + ) { + return { host: command.host, ok: false, detail: "health command exited 1" }; + } + return { host: command.host, ok: true, detail: "ok" }; + }, + }, + }); + NodeAssert.equal(result.ok, false); + if (!result.ok) { + NodeAssert.equal(result.phase, "activate"); + NodeAssert.equal(result.failedHost, "builder"); + NodeAssert.deepEqual(result.rolledBack, ["workstation"]); + } + }); + + NodeTest.it( + "treats thrown activate/restart/health errors as host failure and rolls back", + async () => { + const plan = planFleetUpdate({ inventory, manifest }); + const result = await executeTwoPhase({ + plan, + executor: { + run: async (command) => { + if (command.host === "lab" && command.argv.includes(`--${RESTART_COMMAND_B64_FLAG}`)) { + throw new Error("restart timed out"); + } + return { host: command.host, ok: true, detail: "ok" }; + }, + }, + }); + NodeAssert.equal(result.ok, false); + if (!result.ok) { + NodeAssert.equal(result.phase, "activate"); + NodeAssert.equal(result.failedHost, "lab"); + NodeAssert.equal(result.detail, "restart timed out"); + NodeAssert.deepEqual(result.rolledBack, ["builder", "workstation"]); + } + }, + ); + + NodeTest.it("fails closed when the official Mac client version does not match", () => { + const mismatch = gateFleetWithMacClient({ + manifest, + macClientVersion: "0.0.33-nightly.20260821.1", + }); + NodeAssert.equal(mismatch.ok, false); + const match = gateFleetWithMacClient({ + manifest, + macClientVersion: "0.0.34-nightly.20260822.2", + }); + NodeAssert.equal(match.ok, true); + }); + + NodeTest.it("requires exactly one clientGate host in inventory", () => { + NodeAssert.throws( + () => + decodeInventory(`{ + "schemaVersion": 1, + "channel": "nightly", + "hosts": [ + { "name": "workstation", "via": "local" }, + { "name": "lab", "via": "ssh", "sshAlias": "lab" } + ] + }`), + /clientGate/, + ); + NodeAssert.throws( + () => + decodeInventory(`{ + "schemaVersion": 1, + "channel": "nightly", + "hosts": [ + { "name": "workstation", "via": "local", "clientGate": true }, + { "name": "lab", "via": "ssh", "sshAlias": "lab", "clientGate": true } + ] + }`), + /clientGate/, + ); + }); +}); + +NodeTest.describe("direct t3-ctx fleet gate", () => { + NodeTest.it("documents mac-client-version as required for fleet activation", () => { + NodeAssert.match(usage(), /t3-ctx fleet update .* --mac-client-version /); + NodeAssert.equal(usage().includes("[--mac-client-version"), false); + }); + + NodeTest.it("fails closed without a verified official Mac desktop version", async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-fleet-cli-")); + const inventoryPath = NodePath.join(dir, "inventory.json"); + const manifestPath = NodePath.join(dir, "manifest.json"); + NodeFS.writeFileSync( + inventoryPath, + JSON.stringify({ + schemaVersion: 1, + channel: "nightly", + hosts: [ + { name: "workstation", via: "local", clientGate: true }, + { name: "lab", via: "ssh", sshAlias: "lab" }, + ], + }), + ); + NodeFS.writeFileSync(manifestPath, encodeManifest(manifest)); + const previous = process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION; + delete process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION; + try { + const missing = await main([ + "fleet", + "update", + "--inventory", + inventoryPath, + "--manifest", + manifestPath, + ]); + NodeAssert.equal(missing, 2); + const mismatch = await main([ + "fleet", + "update", + "--inventory", + inventoryPath, + "--manifest", + manifestPath, + "--mac-client-version", + "0.0.1", + ]); + NodeAssert.equal(mismatch, 2); + } finally { + if (previous === undefined) { + delete process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION; + } else { + process.env.CONTEXTIVITY_T3_MAC_CLIENT_VERSION = previous; + } + } + }); +}); + +NodeTest.describe("activate CLI command transport", () => { + NodeTest.it("invokes decoded restart and health callbacks after SSH flattening", async () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-fleet-activate-")); + const markers = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-fleet-markers-")); + const restartMarker = NodePath.join(markers, "restarted"); + const healthMarker = NodePath.join(markers, "healthy"); + const archivePath = NodePath.join(root, "payload.tar.gz"); + const body = "archive-bytes"; + NodeFS.writeFileSync(archivePath, body); + const sha = NodeCrypto.createHash("sha256").update(body).digest("hex"); + const stagedManifest = buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "abc1234", + buildRevision: "run-1", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: PLATFORMS.map((platform, index) => ({ + platform, + name: `t3-server-${platform}.tar.gz`, + size: platform === "linux-x64" ? body.length : index + 1, + sha256: platform === "linux-x64" ? sha : String.fromCharCode(98 + index).repeat(64), + })), + }); + const layout = layoutAt(root); + await stageCandidate({ + layout, + manifest: stagedManifest, + archivePath, + commands: { + extractArchive: async (_archive, destination) => { + NodeFS.mkdirSync(NodePath.join(destination, "dist"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(destination, "dist/bin.mjs"), "export {};\n"); + }, + preflight: async () => ({ code: 0, stdout: "t3 help", stderr: "" }), + }, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + + const restartCommand = `printf '%s' "kickstart 'builder'; done" > '${restartMarker}'`; + const healthCommand = `printf '%s' "$HOME" > '${healthMarker}'`; + const host = { + name: "builder", + via: "ssh" as const, + sshAlias: "builder", + restartCommand, + healthCommand, + }; + const remote = sshFlattenedRemoteArgv( + hostArgv(host, updaterActivateArgs(host, "0.0.34-nightly.20260822.2-ctx.abc1234")).argv, + ); + const previousHome = process.env[CONTEXTIVITY_HOME_ENV]; + process.env[CONTEXTIVITY_HOME_ENV] = root; + try { + const code = await main(remote.slice(1)); + NodeAssert.equal(code, 0); + } finally { + if (previousHome === undefined) { + delete process.env[CONTEXTIVITY_HOME_ENV]; + } else { + process.env[CONTEXTIVITY_HOME_ENV] = previousHome; + } + } + NodeAssert.equal(NodeFS.readFileSync(restartMarker, "utf8"), "kickstart 'builder'; done"); + NodeAssert.equal(NodeFS.readFileSync(healthMarker, "utf8"), process.env.HOME); + }); +}); diff --git a/contextivity/src/fleet.ts b/contextivity/src/fleet.ts new file mode 100644 index 0000000000000..f16c344365a7d --- /dev/null +++ b/contextivity/src/fleet.ts @@ -0,0 +1,160 @@ +import type { CandidateManifest } from "./manifest.ts"; +import { promoteCandidate } from "./promote.ts"; +import type { Inventory, InventoryHost } from "./inventory.ts"; +import { formatInstallId } from "./identity.ts"; +import { + encodeHostCommandArg, + HEALTH_COMMAND_B64_FLAG, + RESTART_COMMAND_B64_FLAG, +} from "./host-command.ts"; + +export type FleetPhase = "resolve" | "stage" | "activate" | "rollback"; + +export interface HostCommand { + readonly host: string; + readonly argv: readonly string[]; +} + +export interface HostResult { + readonly host: string; + readonly ok: boolean; + readonly detail: string; +} + +export interface FleetExecutor { + readonly run: (command: HostCommand) => Promise; +} + +export interface FleetPlan { + readonly installId: string; + readonly stage: readonly HostCommand[]; + readonly activate: readonly HostCommand[]; + readonly rollback: readonly HostCommand[]; +} + +export function hostArgv(host: InventoryHost, args: readonly string[]): HostCommand { + if (host.via === "local") { + return { host: host.name, argv: ["t3-ctx", ...args] }; + } + const alias = host.sshAlias; + if (!alias) { + throw new Error(`Host '${host.name}' is missing sshAlias.`); + } + return { host: host.name, argv: ["ssh", alias, "--", "t3-ctx", ...args] }; +} + +export function updaterActivateArgs(host: InventoryHost, installId: string): readonly string[] { + const args = ["updater", "activate", "--version", installId]; + if (host.restartCommand) { + args.push(`--${RESTART_COMMAND_B64_FLAG}`, encodeHostCommandArg(host.restartCommand)); + } + if (host.healthCommand) { + args.push(`--${HEALTH_COMMAND_B64_FLAG}`, encodeHostCommandArg(host.healthCommand)); + } + return args; +} + +export function planFleetUpdate(input: { + readonly inventory: Inventory; + readonly manifest: CandidateManifest; + readonly stageOnly?: boolean; +}): FleetPlan { + const installId = formatInstallId( + input.manifest.upstreamVersion, + input.manifest.contextivityRevision, + ); + const stage = input.inventory.hosts.map((host) => + hostArgv(host, ["updater", "update", "--version", installId, "--stage-only"]), + ); + const activate = input.stageOnly + ? [] + : input.inventory.hosts.map((host) => hostArgv(host, updaterActivateArgs(host, installId))); + const rollback = input.inventory.hosts.map((host) => hostArgv(host, ["updater", "rollback"])); + return { installId, stage, activate, rollback }; +} + +export async function executeTwoPhase(input: { + readonly executor: FleetExecutor; + readonly plan: FleetPlan; +}): Promise< + | { readonly ok: true; readonly installId: string; readonly staged: readonly string[] } + | { + readonly ok: false; + readonly installId: string; + readonly phase: FleetPhase; + readonly failedHost: string; + readonly detail: string; + readonly rolledBack: readonly string[]; + readonly activateNone: boolean; + } +> { + const staged: string[] = []; + for (const command of input.plan.stage) { + const result = await runHost(input.executor, command); + if (!result.ok) { + return { + ok: false, + installId: input.plan.installId, + phase: "stage", + failedHost: result.host, + detail: result.detail, + rolledBack: [], + activateNone: true, + }; + } + staged.push(result.host); + } + + const activated: string[] = []; + for (const command of input.plan.activate) { + const result = await runHost(input.executor, command); + if (!result.ok) { + const rolledBack: string[] = []; + for (const hostName of activated.toReversed()) { + const rollback = input.plan.rollback.find((entry) => entry.host === hostName); + if (!rollback) continue; + const rollbackResult = await runHost(input.executor, rollback); + if (rollbackResult.ok) rolledBack.push(hostName); + } + return { + ok: false, + installId: input.plan.installId, + phase: "activate", + failedHost: result.host, + detail: result.detail, + rolledBack, + activateNone: activated.length === 0, + }; + } + activated.push(result.host); + } + + return { ok: true, installId: input.plan.installId, staged }; +} + +async function runHost(executor: FleetExecutor, command: HostCommand): Promise { + try { + return await executor.run(command); + } catch (error) { + return { + host: command.host, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } +} + +export function gateFleetWithMacClient(input: { + readonly manifest: CandidateManifest; + readonly macClientVersion: string; +}): { readonly ok: true } | { readonly ok: false; readonly reason: string } { + const promotion = promoteCandidate({ + channel: "nightly", + manifest: input.manifest, + macClientVersion: input.macClientVersion, + }); + if (!promotion.ok) { + return { ok: false, reason: promotion.reason }; + } + return { ok: true }; +} diff --git a/contextivity/src/git-runner.ts b/contextivity/src/git-runner.ts new file mode 100644 index 0000000000000..094ed758b0ad3 --- /dev/null +++ b/contextivity/src/git-runner.ts @@ -0,0 +1,74 @@ +import { spawnCommand } from "./spawn.ts"; +import type { GitRunner } from "./sync.ts"; + +async function git( + args: readonly string[], + cwd: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + return spawnCommand({ command: "git", args, cwd, timeoutMs: 120_000 }); +} + +function conflictPaths(output: string): string[] { + const paths = new Set(); + for (const line of output.split(/\r?\n/u)) { + const match = /^(?:CONFLICT(?: \([^)]+\))?:|U\t)(?:Merge conflict in )?(.+)$/u.exec(line); + if (match?.[1]) paths.add(match[1].trim()); + const colon = /^CONFLICT \(content\): Merge conflict in (.+)$/u.exec(line); + if (colon?.[1]) paths.add(colon[1].trim()); + } + return [...paths]; +} + +export function createGitRunner(cwd: string): GitRunner { + return { + fetchExactTag: async (remote, tag) => { + const result = await git( + ["fetch", "--no-tags", remote, `refs/tags/${tag}:refs/tags/${tag}`], + cwd, + ); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `git fetch ${tag} failed`); + } + }, + revParse: async (rev) => { + const result = await git(["rev-parse", rev], cwd); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `git rev-parse ${rev} failed`); + } + return result.stdout.trim(); + }, + mergeCommit: async (rev) => { + const result = await git(["merge", "--no-edit", "--no-ff", rev], cwd); + if (result.code === 0) return { ok: true }; + return { ok: false, conflicts: conflictPaths(`${result.stdout}\n${result.stderr}`) }; + }, + abortMerge: async () => { + await git(["merge", "--abort"], cwd); + }, + isAncestor: async (ancestor, descendant) => { + const result = await git(["merge-base", "--is-ancestor", ancestor, descendant], cwd); + return result.code === 0; + }, + readFileAt: async (rev, filePath) => { + const result = await git(["show", `${rev}:${filePath}`], cwd); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `git show ${rev}:${filePath} failed`); + } + return result.stdout; + }, + currentBranch: async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git rev-parse --abbrev-ref HEAD failed"); + } + return result.stdout.trim(); + }, + hasUncommittedChanges: async () => { + const result = await git(["status", "--porcelain"], cwd); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git status failed"); + } + return result.stdout.trim().length > 0; + }, + }; +} diff --git a/contextivity/src/github-auth.ts b/contextivity/src/github-auth.ts new file mode 100644 index 0000000000000..d9766c38bfbbe --- /dev/null +++ b/contextivity/src/github-auth.ts @@ -0,0 +1,82 @@ +import { + CONTEXTIVITY_GITHUB_API_ENV, + CONTEXTIVITY_GITHUB_BASE_ENV, + CONTEXTIVITY_GITHUB_TOKEN_ENV, + CONTEXTIVITY_TRUST_MIRROR_ENV, + DEFAULT_GITHUB_API, + DEFAULT_GITHUB_BASE, +} from "./config.ts"; + +export interface GitHubAuth { + readonly token: string | null; + readonly source: "explicit-token" | "gh" | "none"; +} + +export interface GitHubEndpoints { + readonly api: string; + readonly base: string; + readonly trustedMirror: boolean; +} + +export function readExplicitToken(env: Record): string | null { + const token = env[CONTEXTIVITY_GITHUB_TOKEN_ENV]?.trim() || env.GH_TOKEN?.trim() || ""; + return token.length > 0 ? token : null; +} + +export function resolveGitHubAuth(input: { + readonly env: Record; + readonly ghToken?: string | null; +}): GitHubAuth { + const explicit = readExplicitToken(input.env); + if (explicit) { + return { token: explicit, source: "explicit-token" }; + } + const ghToken = input.ghToken?.trim() ?? ""; + if (ghToken.length > 0) { + return { token: ghToken, source: "gh" }; + } + return { token: null, source: "none" }; +} + +export function redactSecrets( + text: string, + secrets: readonly (string | null | undefined)[], +): string { + let redacted = text; + for (const secret of secrets) { + if (secret && secret.length > 0) { + redacted = redacted.split(secret).join("[redacted]"); + } + } + return redacted; +} + +export function resolveGitHubEndpoints(env: Record): GitHubEndpoints { + const apiOverride = env[CONTEXTIVITY_GITHUB_API_ENV]?.trim() ?? ""; + const baseOverride = env[CONTEXTIVITY_GITHUB_BASE_ENV]?.trim() ?? ""; + if (apiOverride === "" && baseOverride === "") { + return { + api: DEFAULT_GITHUB_API, + base: DEFAULT_GITHUB_BASE, + trustedMirror: false, + }; + } + if (env[CONTEXTIVITY_TRUST_MIRROR_ENV]?.trim() !== "1") { + throw new Error( + `A GitHub mirror/base URL is set (${CONTEXTIVITY_GITHUB_API_ENV} / ${CONTEXTIVITY_GITHUB_BASE_ENV}) but ${CONTEXTIVITY_TRUST_MIRROR_ENV}=1 is not. Mirrors are untrusted unless that explicit policy is set.`, + ); + } + return { + api: stripTrailingSlash(apiOverride || DEFAULT_GITHUB_API), + base: stripTrailingSlash(baseOverride || DEFAULT_GITHUB_BASE), + trustedMirror: true, + }; +} + +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +export function authorizationHeader(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/contextivity/src/hash.ts b/contextivity/src/hash.ts new file mode 100644 index 0000000000000..ee44390fef7a5 --- /dev/null +++ b/contextivity/src/hash.ts @@ -0,0 +1,48 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeStreamPromises from "node:stream/promises"; + +export function sha256Text(text: string): string { + return NodeCrypto.createHash("sha256").update(text, "utf8").digest("hex"); +} + +export function sha256Buffer(buffer: Uint8Array): string { + return NodeCrypto.createHash("sha256").update(buffer).digest("hex"); +} + +export async function sha256File(filePath: string): Promise { + const hash = NodeCrypto.createHash("sha256"); + const stream = NodeFS.createReadStream(filePath); + stream.pipe(hash); + await NodeStreamPromises.finished(stream); + return hash.digest("hex"); +} + +export function checksumLine(sha256: string, filename: string): string { + return `${sha256} ${filename}`; +} + +export function formatChecksumFile( + entries: ReadonlyArray<{ readonly name: string; readonly sha256: string }>, +): string { + return `${[...entries] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => checksumLine(entry.sha256, entry.name)) + .join("\n")}\n`; +} + +export function parseChecksumFile( + text: string, +): ReadonlyArray<{ readonly name: string; readonly sha256: string }> { + const entries: Array<{ name: string; sha256: string }> = []; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const match = /^([0-9a-f]{64}) {2}(\S+)$/iu.exec(line); + if (!match || match[1] === undefined || match[2] === undefined) { + throw new Error(`Invalid checksum line: ${rawLine}`); + } + entries.push({ sha256: match[1].toLowerCase(), name: match[2] }); + } + return entries; +} diff --git a/contextivity/src/host-command.test.ts b/contextivity/src/host-command.test.ts new file mode 100644 index 0000000000000..f9ec743fc7d18 --- /dev/null +++ b/contextivity/src/host-command.test.ts @@ -0,0 +1,82 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTest from "node:test"; +import { parseArgs } from "./args.ts"; +import { + decodeHostCommandArg, + encodeHostCommandArg, + HEALTH_COMMAND_B64_FLAG, + hostCommandFromFlags, + RESTART_COMMAND_B64_FLAG, +} from "./host-command.ts"; +import { HOST_HEALTH_TIMEOUT_MS, HOST_RESTART_TIMEOUT_MS, runBoundedHostCommand } from "./spawn.ts"; + +const quotedRestart = `printf '%s' "kickstart 'builder'; done"`; +const healthWithFlags = "curl -sf --max-time 5 http://127.0.0.1:3001/health"; + +NodeTest.describe("host command encoding", () => { + NodeTest.it("round-trips spaces, quotes, and shell metacharacters as a single token", () => { + for (const command of [ + quotedRestart, + healthWithFlags, + "systemctl --user restart contextivity-t3", + `echo 'a; b | c && $HOME' > /tmp/x`, + ]) { + const encoded = encodeHostCommandArg(command); + NodeAssert.match(encoded, /^[A-Za-z0-9_-]+$/); + NodeAssert.equal(encoded.includes(" "), false); + NodeAssert.equal(decodeHostCommandArg(encoded), command); + } + }); + + NodeTest.it("rejects truncated or non-canonical encodings", () => { + NodeAssert.throws(() => decodeHostCommandArg("not-valid"), /invalid/); + NodeAssert.throws(() => decodeHostCommandArg("abc+def"), /invalid/); + NodeAssert.throws(() => decodeHostCommandArg(""), /invalid/); + }); +}); + +NodeTest.describe("host command flags", () => { + NodeTest.it("decodes encoded flags and still accepts plain direct-CLI flags", () => { + NodeAssert.equal( + hostCommandFromFlags( + { [RESTART_COMMAND_B64_FLAG]: encodeHostCommandArg(quotedRestart) }, + "restart", + ), + quotedRestart, + ); + NodeAssert.equal( + hostCommandFromFlags({ "health-command": healthWithFlags }, "health"), + healthWithFlags, + ); + }); +}); + +NodeTest.describe("encoded command callback invocation", () => { + NodeTest.it("runs the original quoted command string through sh -c", async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-host-cmd-")); + const marker = NodePath.join(dir, "restarted"); + const restartCommand = `printf '%s' "kickstart 'builder'; done" > '${marker}'`; + const parsed = parseArgs([ + "updater", + "activate", + "--version", + "id", + `--${RESTART_COMMAND_B64_FLAG}`, + encodeHostCommandArg(restartCommand), + `--${HEALTH_COMMAND_B64_FLAG}`, + encodeHostCommandArg("printf '%s' ok"), + ]); + const restart = hostCommandFromFlags(parsed.flags, "restart"); + const health = hostCommandFromFlags(parsed.flags, "health"); + NodeAssert.equal(restart, restartCommand); + const restarted = await runBoundedHostCommand(restart ?? "", HOST_RESTART_TIMEOUT_MS); + const healthy = await runBoundedHostCommand(health ?? "", HOST_HEALTH_TIMEOUT_MS); + NodeAssert.equal(restarted.code, 0); + NodeAssert.equal(healthy.code, 0); + NodeAssert.equal(NodeFS.readFileSync(marker, "utf8"), "kickstart 'builder'; done"); + NodeAssert.equal(healthy.stdout, "ok"); + }); +}); diff --git a/contextivity/src/host-command.ts b/contextivity/src/host-command.ts new file mode 100644 index 0000000000000..a6892dd3ae042 --- /dev/null +++ b/contextivity/src/host-command.ts @@ -0,0 +1,39 @@ +export const RESTART_COMMAND_FLAG = "restart-command"; +export const HEALTH_COMMAND_FLAG = "health-command"; +export const RESTART_COMMAND_B64_FLAG = "restart-command-b64"; +export const HEALTH_COMMAND_B64_FLAG = "health-command-b64"; + +const ENCODED_TOKEN = /^[A-Za-z0-9_-]+$/u; + +export function encodeHostCommandArg(command: string): string { + const trimmed = command.trim(); + if (trimmed.length === 0) { + throw new Error("Host command is empty."); + } + return Buffer.from(trimmed, "utf8").toString("base64url"); +} + +export function decodeHostCommandArg(encoded: string): string { + if (!ENCODED_TOKEN.test(encoded)) { + throw new Error("Host command encoding is invalid."); + } + const decoded = Buffer.from(encoded, "base64url").toString("utf8"); + if (decoded.length === 0 || encodeHostCommandArg(decoded) !== encoded) { + throw new Error("Host command encoding is invalid."); + } + return decoded; +} + +export function hostCommandFromFlags( + flags: Readonly>, + kind: "restart" | "health", +): string | undefined { + const encodedName = kind === "restart" ? RESTART_COMMAND_B64_FLAG : HEALTH_COMMAND_B64_FLAG; + const plainName = kind === "restart" ? RESTART_COMMAND_FLAG : HEALTH_COMMAND_FLAG; + const encoded = flags[encodedName]; + const plain = flags[plainName]; + if (typeof encoded === "string") { + return decodeHostCommandArg(encoded); + } + return typeof plain === "string" ? plain : undefined; +} diff --git a/contextivity/src/identity.test.ts b/contextivity/src/identity.test.ts new file mode 100644 index 0000000000000..db95685cd8161 --- /dev/null +++ b/contextivity/src/identity.test.ts @@ -0,0 +1,68 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; +import { + assertDualIdentity, + assertPackageJsonKeepsUpstreamVersion, + compareNightlyTags, + formatInstallId, + parseInstallId, + parseNightlyTag, + protocolVisibleVersion, +} from "./identity.ts"; + +NodeTest.describe("dual identity", () => { + NodeTest.it("parses official nightly tags and rejects other tags", () => { + NodeAssert.deepEqual(parseNightlyTag("v0.0.34-nightly.20260822.17"), { + tag: "v0.0.34-nightly.20260822.17", + version: "0.0.34-nightly.20260822.17", + date: "20260822", + runNumber: 17, + }); + NodeAssert.equal(parseNightlyTag("v0.0.34"), null); + NodeAssert.equal(parseNightlyTag("nightly-v0.0.34"), null); + NodeAssert.equal(parseNightlyTag("v0.0.34-ctx.abc1234"), null); + }); + + NodeTest.it("orders nightlies by date then run number, not arbitrary main", () => { + const older = parseNightlyTag("v0.0.33-nightly.20260821.90"); + const newer = parseNightlyTag("v0.0.34-nightly.20260822.1"); + const sameDayLater = parseNightlyTag("v0.0.34-nightly.20260822.2"); + NodeAssert.ok(older && newer && sameDayLater); + NodeAssert.ok(compareNightlyTags(older, newer) < 0); + NodeAssert.ok(compareNightlyTags(newer, sameDayLater) < 0); + }); + + NodeTest.it("keeps protocol-visible version equal to the official nightly", () => { + const identity = assertDualIdentity({ + upstreamVersion: "0.0.34-nightly.20260822.17", + protocolVersion: "0.0.34-nightly.20260822.17", + contextivityRevision: "abc1234", + }); + NodeAssert.equal(protocolVisibleVersion(identity), "0.0.34-nightly.20260822.17"); + NodeAssert.equal(identity.contextivityRevision, "abc1234"); + NodeAssert.equal( + formatInstallId(identity.upstreamVersion, identity.contextivityRevision), + "0.0.34-nightly.20260822.17-ctx.abc1234", + ); + NodeAssert.equal( + parseInstallId("0.0.34-nightly.20260822.17-ctx.abc1234")?.upstreamVersion, + identity.upstreamVersion, + ); + }); + + NodeTest.it("fails closed when protocol version diverges or includes ctx suffix", () => { + NodeAssert.throws(() => + assertDualIdentity({ + upstreamVersion: "0.0.34-nightly.20260822.17", + protocolVersion: "0.0.34-nightly.20260822.17-ctx.abc1234", + contextivityRevision: "abc1234", + }), + ); + NodeAssert.throws(() => + assertPackageJsonKeepsUpstreamVersion({ + packageVersion: "0.0.35-contextivity.1", + upstreamVersion: "0.0.34-nightly.20260822.17", + }), + ); + }); +}); diff --git a/contextivity/src/identity.ts b/contextivity/src/identity.ts new file mode 100644 index 0000000000000..cb6cc29840d13 --- /dev/null +++ b/contextivity/src/identity.ts @@ -0,0 +1,119 @@ +import { UPSTREAM_NIGHTLY_TAG_PATTERN } from "./config.ts"; + +export interface DualIdentity { + readonly upstreamVersion: string; + readonly upstreamTag: string; + readonly contextivityRevision: string; +} + +export interface InstallIdentity extends DualIdentity { + readonly installId: string; +} + +const REVISION_PATTERN = /^[0-9a-f]{7,40}$/i; +const INSTALL_ID_PATTERN = /^(\d+\.\d+\.\d+-nightly\.\d{8}\.\d+)-ctx\.([0-9a-f]{7,40})$/i; + +export function stripTagPrefix(tag: string): string { + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +export function nightlyTagFromVersion(version: string): string { + return version.startsWith("v") ? version : `v${version}`; +} + +export function parseNightlyTag(tag: string): { + readonly tag: string; + readonly version: string; + readonly date: string; + readonly runNumber: number; +} | null { + const match = UPSTREAM_NIGHTLY_TAG_PATTERN.exec(tag); + if (!match || match[1] === undefined || match[2] === undefined || match[3] === undefined) { + return null; + } + return { + tag, + version: match[1], + date: match[2], + runNumber: Number(match[3]), + }; +} + +export function compareNightlyTags( + left: { date: string; runNumber: number; version: string }, + right: { date: string; runNumber: number; version: string }, +): number { + if (left.date !== right.date) return left.date < right.date ? -1 : 1; + if (left.runNumber !== right.runNumber) return left.runNumber - right.runNumber; + if (left.version === right.version) return 0; + return left.version < right.version ? -1 : 1; +} + +export function formatInstallId(upstreamVersion: string, contextivityRevision: string): string { + return `${upstreamVersion}-ctx.${contextivityRevision}`; +} + +export function parseInstallId(installId: string): InstallIdentity | null { + const match = INSTALL_ID_PATTERN.exec(installId); + if (!match || match[1] === undefined || match[2] === undefined) { + return null; + } + const upstreamVersion = match[1]; + const contextivityRevision = match[2]; + return { + upstreamVersion, + upstreamTag: nightlyTagFromVersion(upstreamVersion), + contextivityRevision, + installId, + }; +} + +export function assertRevision(revision: string): string { + const trimmed = revision.trim(); + if (!REVISION_PATTERN.test(trimmed)) { + throw new Error(`Invalid contextivityRevision '${revision}'. Use a git SHA (7-40 hex chars).`); + } + return trimmed.toLowerCase(); +} + +export function protocolVisibleVersion(identity: DualIdentity): string { + return identity.upstreamVersion; +} + +export function assertDualIdentity(input: { + readonly upstreamVersion: string; + readonly protocolVersion: string; + readonly contextivityRevision: string; +}): DualIdentity { + const parsed = parseNightlyTag(nightlyTagFromVersion(input.upstreamVersion)); + if (!parsed) { + throw new Error( + `upstreamVersion '${input.upstreamVersion}' is not an official T3 nightly version.`, + ); + } + if (input.protocolVersion !== input.upstreamVersion) { + throw new Error( + `Protocol-visible version '${input.protocolVersion}' must equal official upstream nightly '${input.upstreamVersion}'.`, + ); + } + const contextivityRevision = assertRevision(input.contextivityRevision); + if (input.protocolVersion.includes("-ctx.")) { + throw new Error("Protocol-visible version must not include a Contextivity revision suffix."); + } + return { + upstreamVersion: parsed.version, + upstreamTag: parsed.tag, + contextivityRevision, + }; +} + +export function assertPackageJsonKeepsUpstreamVersion(input: { + readonly packageVersion: string; + readonly upstreamVersion: string; +}): void { + if (input.packageVersion !== input.upstreamVersion) { + throw new Error( + `apps/server package.json version '${input.packageVersion}' must stay the official upstream nightly '${input.upstreamVersion}'.`, + ); + } +} diff --git a/contextivity/src/inventory.ts b/contextivity/src/inventory.ts new file mode 100644 index 0000000000000..865375f76976c --- /dev/null +++ b/contextivity/src/inventory.ts @@ -0,0 +1,109 @@ +import { SCHEMA_VERSION } from "./config.ts"; +import { parseJsonObject } from "./json.ts"; + +export type HostTransport = "local" | "ssh"; + +export interface InventoryHost { + readonly name: string; + readonly via: HostTransport; + readonly sshAlias?: string; + readonly clientGate?: boolean; + readonly healthCommand?: string; + readonly restartCommand?: string; +} + +export interface Inventory { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly channel: "nightly" | "stable" | "candidate"; + readonly hosts: readonly InventoryHost[]; +} + +const FORBIDDEN_HARDCODED = [ + /rfranke/i, + /100\.\d+\.\d+\.\d+/u, + /ai-dev-w1/i, + /macbook/i, + /tail398e94/i, +]; + +export function assertNoHardcodedSecrets(text: string, label: string): void { + if (text.includes("ghp_") || text.includes("github_pat_")) { + throw new Error( + `${label} contains a GitHub token. Credentials must stay out of inventory files.`, + ); + } +} + +export function decodeInventory(text: string): Inventory { + assertNoHardcodedSecrets(text, "inventory"); + const raw = parseJsonObject(text, "inventory"); + if (raw["schemaVersion"] !== SCHEMA_VERSION) { + throw new Error(`Unsupported inventory schemaVersion '${String(raw["schemaVersion"])}'.`); + } + const channel = raw["channel"]; + if (channel !== "nightly" && channel !== "stable" && channel !== "candidate") { + throw new Error("inventory.channel must be candidate, nightly, or stable."); + } + const hostsRaw = raw["hosts"]; + if (!Array.isArray(hostsRaw) || hostsRaw.length === 0) { + throw new Error("inventory.hosts must be a non-empty array."); + } + const names = new Set(); + const hosts = hostsRaw.map((entry, index) => { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`inventory.hosts[${index}] must be an object.`); + } + const host = entry as Record; + const name = String(host["name"] ?? "").trim(); + const via = host["via"]; + if (name.length === 0) throw new Error(`inventory.hosts[${index}].name is required.`); + if (names.has(name)) throw new Error(`Duplicate inventory host '${name}'.`); + names.add(name); + if (via !== "local" && via !== "ssh") { + throw new Error(`inventory.hosts[${index}].via must be local or ssh.`); + } + const sshAlias = typeof host["sshAlias"] === "string" ? host["sshAlias"].trim() : undefined; + if (via === "ssh") { + if (!sshAlias) { + throw new Error(`inventory.hosts[${index}] ssh transport requires sshAlias.`); + } + if (sshAlias.includes("@") || sshAlias.includes("://")) { + throw new Error( + `inventory.hosts[${index}].sshAlias must be an SSH config alias, not user@host or a URL.`, + ); + } + } + const healthCommand = + typeof host["healthCommand"] === "string" ? host["healthCommand"].trim() : ""; + const restartCommand = + typeof host["restartCommand"] === "string" ? host["restartCommand"].trim() : ""; + const decoded: InventoryHost = { + name, + via, + ...(sshAlias ? { sshAlias } : {}), + ...(host["clientGate"] === true ? { clientGate: true } : {}), + ...(healthCommand ? { healthCommand } : {}), + ...(restartCommand ? { restartCommand } : {}), + }; + return decoded; + }); + if (hosts.filter((host) => host.via === "local").length !== 1) { + throw new Error("inventory must contain exactly one host with via=local."); + } + if (hosts.filter((host) => host.clientGate === true).length !== 1) { + throw new Error("inventory must contain exactly one host with clientGate=true."); + } + return { + schemaVersion: SCHEMA_VERSION, + channel, + hosts, + }; +} + +export function exampleInventoryLooksSafe(text: string): boolean { + return !FORBIDDEN_HARDCODED.some((pattern) => pattern.test(text)); +} + +export function clientGateHost(inventory: Inventory): InventoryHost | null { + return inventory.hosts.find((host) => host.clientGate === true) ?? null; +} diff --git a/contextivity/src/issues.ts b/contextivity/src/issues.ts new file mode 100644 index 0000000000000..e96511278cb63 --- /dev/null +++ b/contextivity/src/issues.ts @@ -0,0 +1,40 @@ +import { MAINTENANCE_ISSUE_LABEL } from "./config.ts"; +import { syncFailureIssueBody, syncFailureIssueTitle, type SyncFailure } from "./sync.ts"; + +export interface IssueSummary { + readonly number: number; + readonly title: string; + readonly state: "open" | "closed"; + readonly htmlUrl: string; +} + +export interface IssueClient { + readonly searchOpen: (title: string) => Promise; + readonly create: (input: { + readonly title: string; + readonly body: string; + readonly labels: readonly string[]; + }) => Promise; +} + +export interface DedupedIssueResult { + readonly created: boolean; + readonly issue: IssueSummary; +} + +export async function reportSyncFailureIssue( + client: IssueClient, + failure: SyncFailure, +): Promise { + const title = syncFailureIssueTitle(failure.tag); + const existing = (await client.searchOpen(title)).find((issue) => issue.title === title); + if (existing) { + return { created: false, issue: existing }; + } + const issue = await client.create({ + title, + body: syncFailureIssueBody(failure), + labels: [MAINTENANCE_ISSUE_LABEL], + }); + return { created: true, issue }; +} diff --git a/contextivity/src/json.ts b/contextivity/src/json.ts new file mode 100644 index 0000000000000..c914e54afcf0f --- /dev/null +++ b/contextivity/src/json.ts @@ -0,0 +1,34 @@ +export function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (value !== null && typeof value === "object") { + const record = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) { + const entry = record[key]; + if (entry !== undefined) { + sorted[key] = sortJson(entry); + } + } + return sorted; + } + return value; +} + +export function stableStringify(value: unknown): string { + return `${JSON.stringify(sortJson(value), null, 2)}\n`; +} + +export function parseJsonObject(text: string, label: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (cause) { + throw new Error(`Invalid JSON in ${label}.`, { cause }); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON object.`); + } + return parsed as Record; +} diff --git a/contextivity/src/mac-client.ts b/contextivity/src/mac-client.ts new file mode 100644 index 0000000000000..61f27d2b13bc0 --- /dev/null +++ b/contextivity/src/mac-client.ts @@ -0,0 +1,40 @@ +import { normalizeVersion } from "./promote.ts"; + +export function parseMacPlistVersion(plistText: string): string | null { + const short = /CFBundleShortVersionString<\/key>\s*([^<]+)<\/string>/u.exec( + plistText, + ); + if (short?.[1]) return normalizeVersion(short[1]); + const quoted = /CFBundleShortVersionString\s*=\s*"([^"]+)"/u.exec(plistText); + if (quoted?.[1]) return normalizeVersion(quoted[1]); + const defaults = plistText.trim(); + if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(defaults)) return defaults; + return null; +} + +export function resolveMacClientVersion(input: { + readonly envVersion?: string; + readonly plistText?: string; + readonly commandOutput?: string; +}): string { + const envVersion = input.envVersion?.trim() ?? ""; + if (envVersion.length > 0) return normalizeVersion(envVersion); + if (input.commandOutput) { + const parsed = parseMacPlistVersion(input.commandOutput); + if (parsed) return parsed; + } + if (input.plistText) { + const parsed = parseMacPlistVersion(input.plistText); + if (parsed) return parsed; + } + throw new Error( + "Could not determine the official Mac desktop version. Pass --mac-client-version or set CONTEXTIVITY_T3_MAC_CLIENT_VERSION. Promotion and fleet activation fail closed.", + ); +} + +export const DEFAULT_MAC_READ_VERSION_COMMAND = [ + "defaults", + "read", + "/Applications/T3 Code.app/Contents/Info", + "CFBundleShortVersionString", +] as const; diff --git a/contextivity/src/manifest.test.ts b/contextivity/src/manifest.test.ts new file mode 100644 index 0000000000000..09ec32adcb4d2 --- /dev/null +++ b/contextivity/src/manifest.test.ts @@ -0,0 +1,213 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { PLATFORMS } from "./config.ts"; +import { + assertManifestDeterministic, + buildCandidateManifest, + candidateReleaseTag, + decodeManifest, + encodeManifest, + manifestDigest, + selectArtifact, + type ManifestArtifact, +} from "./manifest.ts"; +import { detectHostPlatform, hostPlatformFromNode, selectHostArtifact } from "./platforms.ts"; +import { formatChecksumFile, parseChecksumFile, sha256Text } from "./hash.ts"; +import { writeCandidateManifestFromDir } from "./write-candidate-manifest.ts"; + +const artifact = { + platform: "linux-x64" as const, + name: "t3-server-linux-x64.tar.gz", + size: 12, + sha256: "a".repeat(64), +}; + +function fourArtifacts( + extra: readonly ManifestArtifact[] = [], + omit: ReadonlySet = new Set(), +): ManifestArtifact[] { + const base: ManifestArtifact[] = PLATFORMS.filter((platform) => !omit.has(platform)).map( + (platform, index) => + platform === "linux-x64" + ? artifact + : { + platform, + name: `t3-server-${platform}.tar.gz`, + size: 10 + index, + sha256: String.fromCharCode(98 + index).repeat(64), + }, + ); + return [...base, ...extra]; +} + +function sample() { + return buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "def4567", + buildRevision: "github-run-99", + nodeEngine: "^22.16 || ^23.11 || >=24.10", + createdAt: "2026-08-22T12:00:00.000Z", + artifacts: fourArtifacts(), + }); +} + +NodeTest.describe("candidate manifest", () => { + NodeTest.it("encodes a deterministic schema including dual identity and artifacts", () => { + const manifest = sample(); + assertManifestDeterministic(manifest); + const encoded = encodeManifest(manifest); + const again = encodeManifest(decodeManifest(encoded)); + NodeAssert.equal(encoded, again); + NodeAssert.equal(manifestDigest(manifest), sha256Text(encoded)); + NodeAssert.equal(manifest.compatibility.protocolVersion, manifest.upstreamVersion); + NodeAssert.equal(manifest.compatibility.upstreamNpmPackage, "forbidden"); + NodeAssert.equal( + candidateReleaseTag(manifest), + "contextivity-candidate/0.0.34-nightly.20260822.2-ctx.def4567", + ); + NodeAssert.deepEqual( + manifest.artifacts.map((entry) => entry.platform), + [...PLATFORMS], + ); + }); + + NodeTest.it("rejects missing, duplicate, or unsupported artifact platforms", () => { + NodeAssert.throws( + () => + buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "def4567", + buildRevision: "github-run-99", + nodeEngine: ">=24", + createdAt: "2026-08-22T12:00:00.000Z", + artifacts: fourArtifacts([], new Set(["darwin-x64"])), + }), + /exactly the four server platforms/, + ); + NodeAssert.throws( + () => + buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "def4567", + buildRevision: "github-run-99", + nodeEngine: ">=24", + createdAt: "2026-08-22T12:00:00.000Z", + artifacts: fourArtifacts([ + { + platform: "linux-x64", + name: "t3-server-linux-x64-dup.tar.gz", + size: 1, + sha256: "e".repeat(64), + }, + ]), + }), + /Duplicate artifact/, + ); + NodeAssert.throws( + () => + buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "def4567", + buildRevision: "github-run-99", + nodeEngine: ">=24", + createdAt: "2026-08-22T12:00:00.000Z", + artifacts: [ + ...fourArtifacts(), + { + platform: "win32-x64" as ManifestArtifact["platform"], + name: "t3-server-win32-x64.tar.gz", + size: 1, + sha256: "f".repeat(64), + }, + ], + }), + /Unknown artifact platform/, + ); + }); + + NodeTest.it( + "constructs a candidate only when the directory has all four platform archives", + async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-manifest-dir-")); + for (const platform of PLATFORMS) { + NodeFS.writeFileSync(NodePath.join(dir, `t3-server-${platform}.tar.gz`), platform); + } + const encoded = await writeCandidateManifestFromDir({ + tag: "v0.0.34-nightly.20260822.2", + sha: "c".repeat(40), + dir, + buildRevision: "run-1", + nodeEngine: ">=24", + contextivityRevision: "def4567", + createdAt: "2026-08-22T12:00:00.000Z", + }); + const written = decodeManifest(encoded); + NodeAssert.equal(written.artifacts.length, 4); + NodeAssert.equal(NodeFS.existsSync(NodePath.join(dir, "SHA256SUMS")), true); + NodeAssert.equal(NodeFS.existsSync(NodePath.join(dir, "manifest.json")), true); + NodeAssert.match( + NodeFS.readFileSync(NodePath.join(dir, "SHA256SUMS"), "utf8"), + /t3-server-linux-x64\.tar\.gz/, + ); + const incomplete = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "ctx-manifest-missing-"), + ); + NodeFS.writeFileSync(NodePath.join(incomplete, "t3-server-linux-x64.tar.gz"), "only-one"); + await NodeAssert.rejects( + () => + writeCandidateManifestFromDir({ + tag: "v0.0.34-nightly.20260822.2", + sha: "c".repeat(40), + dir: incomplete, + buildRevision: "run-1", + nodeEngine: ">=24", + contextivityRevision: "def4567", + }), + /exactly the four server platforms/, + ); + }, + ); + + NodeTest.it("rejects schema drift and protocol/version mismatch", () => { + const encoded = encodeManifest(sample()); + NodeAssert.throws(() => + decodeManifest(encoded.replace('"schemaVersion": 1', '"schemaVersion": 2')), + ); + NodeAssert.throws(() => + decodeManifest( + encoded.replace('"upstreamNpmPackage": "forbidden"', '"upstreamNpmPackage": "allowed"'), + ), + ); + }); + + NodeTest.it("selects the host platform artifact", () => { + const manifest = sample(); + NodeAssert.equal(selectArtifact(manifest, "linux-x64").name, artifact.name); + NodeAssert.equal(hostPlatformFromNode("linux", "arm64").platform, "linux-arm64"); + NodeAssert.equal(hostPlatformFromNode("darwin", "x64").platform, "darwin-x64"); + NodeAssert.equal( + selectHostArtifact(manifest, { platform: "linux", arch: "x64" }).platform, + "linux-x64", + ); + NodeAssert.throws(() => detectHostPlatform({ platform: "win32", arch: "x64" })); + }); + + NodeTest.it("writes checksum files in sorted, sha256sum-compatible form", () => { + const text = formatChecksumFile([ + { name: "b.tar.gz", sha256: "b".repeat(64) }, + { name: "a.tar.gz", sha256: "a".repeat(64) }, + ]); + NodeAssert.equal(text.startsWith(`${"a".repeat(64)} a.tar.gz\n`), true); + NodeAssert.deepEqual( + parseChecksumFile(text).map((entry) => entry.name), + ["a.tar.gz", "b.tar.gz"], + ); + }); +}); diff --git a/contextivity/src/manifest.ts b/contextivity/src/manifest.ts new file mode 100644 index 0000000000000..e72971f9498f9 --- /dev/null +++ b/contextivity/src/manifest.ts @@ -0,0 +1,211 @@ +import { PLATFORMS, SCHEMA_VERSION, isArtifactPlatform, type ArtifactPlatform } from "./config.ts"; +import { sha256Text } from "./hash.ts"; +import { assertDualIdentity, formatInstallId, type DualIdentity } from "./identity.ts"; +import { parseJsonObject, stableStringify } from "./json.ts"; + +export const MANIFEST_KIND = "contextivity-t3-candidate" as const; + +export interface ManifestArtifact { + readonly platform: ArtifactPlatform; + readonly name: string; + readonly size: number; + readonly sha256: string; +} + +export interface ManifestCompatibility { + readonly protocolVersion: string; + readonly requiresExactClientVersion: true; + readonly officialMacDesktop: "must-match-upstreamVersion"; + readonly upstreamNpmPackage: "forbidden"; +} + +export interface CandidateManifest extends DualIdentity { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly kind: typeof MANIFEST_KIND; + readonly upstreamCommit: string; + readonly buildRevision: string; + readonly nodeEngine: string; + readonly createdAt: string; + readonly compatibility: ManifestCompatibility; + readonly artifacts: readonly ManifestArtifact[]; +} + +export function comparePlatforms(left: ArtifactPlatform, right: ArtifactPlatform): number { + return PLATFORMS.indexOf(left) - PLATFORMS.indexOf(right); +} + +export { isArtifactPlatform }; + +export function normalizeArtifacts(artifacts: readonly ManifestArtifact[]): ManifestArtifact[] { + const seen = new Set(); + const normalized = artifacts.map((artifact) => { + if (!isArtifactPlatform(artifact.platform)) { + throw new Error(`Unknown artifact platform '${artifact.platform}'.`); + } + if (seen.has(artifact.platform)) { + throw new Error(`Duplicate artifact for platform '${artifact.platform}'.`); + } + seen.add(artifact.platform); + if (!/^[A-Za-z0-9._-]+$/u.test(artifact.name)) { + throw new Error(`Unsafe artifact name '${artifact.name}'.`); + } + if (!Number.isInteger(artifact.size) || artifact.size < 0) { + throw new Error(`Invalid size for ${artifact.name}.`); + } + if (!/^[0-9a-f]{64}$/u.test(artifact.sha256)) { + throw new Error(`Invalid SHA-256 for ${artifact.name}.`); + } + return { + platform: artifact.platform, + name: artifact.name, + size: artifact.size, + sha256: artifact.sha256.toLowerCase(), + }; + }); + return normalized.sort((left, right) => comparePlatforms(left.platform, right.platform)); +} + +export function buildCandidateManifest(input: { + readonly upstreamVersion: string; + readonly upstreamTag?: string; + readonly upstreamCommit: string; + readonly contextivityRevision: string; + readonly buildRevision: string; + readonly nodeEngine: string; + readonly createdAt: string; + readonly artifacts: readonly ManifestArtifact[]; +}): CandidateManifest { + const identity = assertDualIdentity({ + upstreamVersion: input.upstreamVersion, + protocolVersion: input.upstreamVersion, + contextivityRevision: input.contextivityRevision, + }); + if (input.upstreamTag !== undefined && input.upstreamTag !== identity.upstreamTag) { + throw new Error( + `upstreamTag '${input.upstreamTag}' does not match nightly version '${identity.upstreamVersion}'.`, + ); + } + if (!/^[0-9a-f]{7,40}$/iu.test(input.upstreamCommit)) { + throw new Error(`Invalid upstreamCommit '${input.upstreamCommit}'.`); + } + if (input.buildRevision.trim() === "") { + throw new Error("buildRevision is required."); + } + if (input.nodeEngine.trim() === "") { + throw new Error("nodeEngine is required."); + } + if (Number.isNaN(Date.parse(input.createdAt))) { + throw new Error(`Invalid createdAt '${input.createdAt}'.`); + } + const artifacts = normalizeArtifacts(input.artifacts); + if (artifacts.length !== PLATFORMS.length) { + throw new Error( + `A candidate manifest must include exactly the four server platforms: ${PLATFORMS.join(", ")}.`, + ); + } + return { + schemaVersion: SCHEMA_VERSION, + kind: MANIFEST_KIND, + upstreamVersion: identity.upstreamVersion, + upstreamTag: identity.upstreamTag, + upstreamCommit: input.upstreamCommit.toLowerCase(), + contextivityRevision: identity.contextivityRevision, + buildRevision: input.buildRevision.trim(), + nodeEngine: input.nodeEngine.trim(), + createdAt: new Date(input.createdAt).toISOString(), + compatibility: { + protocolVersion: identity.upstreamVersion, + requiresExactClientVersion: true, + officialMacDesktop: "must-match-upstreamVersion", + upstreamNpmPackage: "forbidden", + }, + artifacts, + }; +} + +export function encodeManifest(manifest: CandidateManifest): string { + return stableStringify(manifest); +} + +export function manifestDigest(manifest: CandidateManifest): string { + return sha256Text(encodeManifest(manifest)); +} + +export function decodeManifest(text: string): CandidateManifest { + const raw = parseJsonObject(text, "candidate manifest"); + if (raw["schemaVersion"] !== SCHEMA_VERSION) { + throw new Error( + `Unsupported manifest schemaVersion '${String(raw["schemaVersion"])}'. Expected ${SCHEMA_VERSION}.`, + ); + } + if (raw["kind"] !== MANIFEST_KIND) { + throw new Error(`Unsupported manifest kind '${String(raw["kind"])}'.`); + } + const artifactsRaw = raw["artifacts"]; + if (!Array.isArray(artifactsRaw)) { + throw new Error("Manifest artifacts must be an array."); + } + const artifacts: ManifestArtifact[] = artifactsRaw.map((entry, index) => { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Manifest artifact ${index} is not an object.`); + } + const artifact = entry as Record; + return { + platform: String(artifact["platform"] ?? ""), + name: String(artifact["name"] ?? ""), + size: Number(artifact["size"]), + sha256: String(artifact["sha256"] ?? ""), + } as ManifestArtifact; + }); + const decoded = buildCandidateManifest({ + upstreamVersion: String(raw["upstreamVersion"] ?? ""), + upstreamTag: typeof raw["upstreamTag"] === "string" ? raw["upstreamTag"] : undefined, + upstreamCommit: String(raw["upstreamCommit"] ?? ""), + contextivityRevision: String(raw["contextivityRevision"] ?? ""), + buildRevision: String(raw["buildRevision"] ?? ""), + nodeEngine: String(raw["nodeEngine"] ?? ""), + createdAt: String(raw["createdAt"] ?? ""), + artifacts, + }); + const compatibility = raw["compatibility"]; + if (compatibility === null || typeof compatibility !== "object" || Array.isArray(compatibility)) { + throw new Error("Manifest compatibility is required."); + } + const compat = compatibility as Record; + if (compat["protocolVersion"] !== decoded.upstreamVersion) { + throw new Error("compatibility.protocolVersion must equal upstreamVersion."); + } + if (compat["upstreamNpmPackage"] !== "forbidden") { + throw new Error("compatibility.upstreamNpmPackage must be 'forbidden'."); + } + if (compat["requiresExactClientVersion"] !== true) { + throw new Error("compatibility.requiresExactClientVersion must be true."); + } + return decoded; +} + +export function assertManifestDeterministic(manifest: CandidateManifest): void { + const encoded = encodeManifest(manifest); + const roundTrip = decodeManifest(encoded); + if (encodeManifest(roundTrip) !== encoded) { + throw new Error("Manifest encoding is not deterministic."); + } +} + +export function candidateReleaseTag(manifest: CandidateManifest): string { + return `contextivity-candidate/${formatInstallId( + manifest.upstreamVersion, + manifest.contextivityRevision, + )}`; +} + +export function selectArtifact( + manifest: CandidateManifest, + platform: ArtifactPlatform, +): ManifestArtifact { + const artifact = manifest.artifacts.find((entry) => entry.platform === platform); + if (!artifact) { + throw new Error(`Manifest ${manifest.upstreamTag} has no artifact for platform '${platform}'.`); + } + return artifact; +} diff --git a/contextivity/src/pack-server.ts b/contextivity/src/pack-server.ts new file mode 100644 index 0000000000000..31c41920efc81 --- /dev/null +++ b/contextivity/src/pack-server.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { flagString, parseArgs } from "./args.ts"; +import { isArtifactPlatform } from "./config.ts"; +import { + collectNativeModuleDirs, + requirePackedNodePty, + stageServerTree, + tarCreateArgs, +} from "./pack.ts"; +import { spawnCommand } from "./spawn.ts"; + +function requireValue(flags: ReturnType["flags"], name: string): string { + const value = flagString(flags, name); + if (!value) throw new Error(`Missing --${name}`); + return value; +} + +export async function packServerArchive(input: { + readonly repoRoot: string; + readonly platform: string; + readonly outDir: string; + readonly upstreamVersion: string; + readonly contextivityRevision: string; +}): Promise { + if (!isArtifactPlatform(input.platform)) { + throw new Error(`Unknown platform '${input.platform}'.`); + } + const stagingDir = NodePath.join(input.outDir, `stage-${input.platform}`); + const archivePath = NodePath.join(input.outDir, `t3-server-${input.platform}.tar.gz`); + NodeFS.mkdirSync(input.outDir, { recursive: true }); + const nativeModuleDirs = collectNativeModuleDirs(input.repoRoot); + requirePackedNodePty(nativeModuleDirs); + stageServerTree({ + stagingDir, + distDir: NodePath.join(input.repoRoot, "apps/server/dist"), + nativeModuleDirs, + upstreamVersion: input.upstreamVersion, + contextivityRevision: input.contextivityRevision, + }); + const tar = await spawnCommand({ + command: "tar", + args: tarCreateArgs(archivePath, stagingDir), + timeoutMs: 120_000, + }); + if (tar.code !== 0) { + throw new Error(tar.stderr.trim() || "tar create failed"); + } + return archivePath; +} + +const repoRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL("../..", import.meta.url))); +const invoked = + process.argv[1] !== undefined && + NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1]); +if (invoked) { + const parsed = parseArgs(process.argv.slice(2)); + packServerArchive({ + repoRoot, + platform: requireValue(parsed.flags, "platform"), + outDir: requireValue(parsed.flags, "out"), + upstreamVersion: requireValue(parsed.flags, "upstream-version"), + contextivityRevision: requireValue(parsed.flags, "contextivity-revision"), + }).then( + (archivePath) => { + process.stdout.write(`${archivePath}\n`); + }, + (error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }, + ); +} diff --git a/contextivity/src/pack.test.ts b/contextivity/src/pack.test.ts new file mode 100644 index 0000000000000..60fd738f8c570 --- /dev/null +++ b/contextivity/src/pack.test.ts @@ -0,0 +1,75 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTest from "node:test"; +import { CONTEXTIVITY_DISTRIBUTION_ENV } from "./config.ts"; +import { + collectNativeModuleDirs, + launchWrapper, + requirePackedNodePty, + stageServerTree, +} from "./pack.ts"; +import { containsUpstreamPackageFallback } from "./update-metadata.ts"; +import { platformFromName } from "./write-candidate-manifest.ts"; + +NodeTest.describe("server archive packing", () => { + NodeTest.it("writes a launcher that marks the distribution and never calls npx t3@", () => { + const wrapper = launchWrapper("dist/bin.mjs"); + NodeAssert.equal(wrapper.includes(`${CONTEXTIVITY_DISTRIBUTION_ENV}=1`), true); + NodeAssert.equal(containsUpstreamPackageFallback(wrapper), false); + NodeAssert.equal(wrapper.startsWith("#!/usr/bin/env sh"), true); + }); + + NodeTest.it("stages dist, marker, and wrapper with protocol version = upstream nightly", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-pack-")); + const distDir = NodePath.join(root, "dist-src"); + NodeFS.mkdirSync(distDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(distDir, "bin.mjs"), "export {};\n"); + const stagingDir = NodePath.join(root, "stage"); + stageServerTree({ + stagingDir, + distDir, + upstreamVersion: "0.0.34-nightly.20260822.2", + contextivityRevision: "abc1234", + }); + const marker = JSON.parse( + NodeFS.readFileSync(NodePath.join(stagingDir, "contextivity-distribution.json"), "utf8"), + ) as { + protocolVersion: string; + installId: string; + }; + NodeAssert.equal(marker.protocolVersion, "0.0.34-nightly.20260822.2"); + NodeAssert.equal(marker.installId, "0.0.34-nightly.20260822.2-ctx.abc1234"); + NodeAssert.equal(platformFromName("t3-server-darwin-arm64.tar.gz"), "darwin-arm64"); + }); + + NodeTest.it("collects native modules and copies through pnpm-style symlinks", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-native-")); + const realPty = NodePath.join(root, ".pnpm", "node-pty"); + NodeFS.mkdirSync(realPty, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(realPty, "binding.node"), "native"); + NodeFS.mkdirSync(NodePath.join(root, "node_modules"), { recursive: true }); + NodeFS.symlinkSync(realPty, NodePath.join(root, "node_modules", "node-pty")); + const dirs = collectNativeModuleDirs(root); + requirePackedNodePty(dirs); + const stagingDir = NodePath.join(root, "stage"); + const distDir = NodePath.join(root, "dist-src"); + NodeFS.mkdirSync(distDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(distDir, "bin.mjs"), "export {};\n"); + stageServerTree({ + stagingDir, + distDir, + nativeModuleDirs: dirs, + upstreamVersion: "0.0.34-nightly.20260822.2", + contextivityRevision: "abc1234", + }); + const packed = NodePath.join(stagingDir, "node_modules", "node-pty", "binding.node"); + NodeAssert.equal(NodeFS.existsSync(packed), true); + NodeAssert.equal( + NodeFS.lstatSync(NodePath.join(stagingDir, "node_modules", "node-pty")).isSymbolicLink(), + false, + ); + NodeAssert.throws(() => requirePackedNodePty([])); + }); +}); diff --git a/contextivity/src/pack.ts b/contextivity/src/pack.ts new file mode 100644 index 0000000000000..e656f63f2654e --- /dev/null +++ b/contextivity/src/pack.ts @@ -0,0 +1,146 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { CONTEXTIVITY_DISTRIBUTION_ENV, MARKER_FILENAME } from "./config.ts"; +import { atomicWriteFile } from "./atomic.ts"; +import { sha256File } from "./hash.ts"; +import { formatInstallId } from "./identity.ts"; +import type { ArtifactPlatform } from "./config.ts"; +import { archiveFileName } from "./platforms.ts"; +import { containsUpstreamPackageFallback } from "./update-metadata.ts"; + +export const SERVER_NATIVE_PACKAGE_PREFIXES = [ + "node-pty", + "ffi-rs", + "@yuuang/", + "@ff-labs/", + "@msgpackr-extract/", + "msgpackr-extract", + "node-gyp-build", + "node-addon-api", + "detect-libc", +] as const; + +export interface PackedArtifact { + readonly platform: ArtifactPlatform; + readonly name: string; + readonly path: string; + readonly size: number; + readonly sha256: string; +} + +export function launchWrapper(entryRelativePath: string): string { + return `#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +export ${CONTEXTIVITY_DISTRIBUTION_ENV}=1 +export CONTEXTIVITY_T3_MARKER="$ROOT/${MARKER_FILENAME}" +exec node "$ROOT/${entryRelativePath}" "$@" +`; +} + +export function writeLaunchWrapper(versionDir: string, entryRelativePath: string): void { + const binDir = NodePath.join(versionDir, "bin"); + NodeFS.mkdirSync(binDir, { recursive: true }); + const wrapperPath = NodePath.join(binDir, "t3"); + atomicWriteFile(wrapperPath, launchWrapper(entryRelativePath), 0o755); + NodeFS.chmodSync(wrapperPath, 0o755); + if (containsUpstreamPackageFallback(launchWrapper(entryRelativePath))) { + throw new Error("Launch wrapper must not mention upstream npm package t3@."); + } +} + +export function stageServerTree(input: { + readonly stagingDir: string; + readonly distDir: string; + readonly nativeModuleDirs?: readonly string[]; + readonly upstreamVersion: string; + readonly contextivityRevision: string; +}): void { + NodeFS.rmSync(input.stagingDir, { recursive: true, force: true }); + NodeFS.mkdirSync(NodePath.join(input.stagingDir, "dist"), { recursive: true }); + if (!NodeFS.existsSync(input.distDir)) { + throw new Error(`Server dist directory is missing: ${input.distDir}`); + } + NodeFS.cpSync(input.distDir, NodePath.join(input.stagingDir, "dist"), { recursive: true }); + for (const nativeDir of input.nativeModuleDirs ?? []) { + if (!NodeFS.existsSync(nativeDir)) continue; + const name = nativeDir.split("/").at(-1); + if (!name) continue; + NodeFS.mkdirSync(NodePath.join(input.stagingDir, "node_modules"), { recursive: true }); + NodeFS.cpSync(nativeDir, NodePath.join(input.stagingDir, "node_modules", name), { + recursive: true, + dereference: true, + }); + } + writeLaunchWrapper(input.stagingDir, "dist/bin.mjs"); + atomicWriteFile( + NodePath.join(input.stagingDir, MARKER_FILENAME), + `${JSON.stringify( + { + upstreamVersion: input.upstreamVersion, + contextivityRevision: input.contextivityRevision, + protocolVersion: input.upstreamVersion, + installId: formatInstallId(input.upstreamVersion, input.contextivityRevision), + }, + null, + 2, + )}\n`, + ); +} + +export async function hashStagedArchive( + archivePath: string, + platform: ArtifactPlatform, + installId: string, +): Promise { + const stats = NodeFS.statSync(archivePath); + return { + platform, + name: archiveFileName(platform, installId), + path: archivePath, + size: stats.size, + sha256: await sha256File(archivePath), + }; +} + +export function tarCreateArgs(archivePath: string, stagingDir: string): readonly string[] { + return ["-C", stagingDir, "-czf", archivePath, "."]; +} + +export function tarExtractArgs(archivePath: string, destination: string): readonly string[] { + return ["-C", destination, "-xzf", archivePath]; +} + +export function nativePrefixesMatchUpstream(upstreamSource: string): boolean { + return SERVER_NATIVE_PACKAGE_PREFIXES.every((prefix) => upstreamSource.includes(`"${prefix}"`)); +} + +export function collectNativeModuleDirs(repoRoot: string): string[] { + const roots = [ + NodePath.join(repoRoot, "apps/server/node_modules"), + NodePath.join(repoRoot, "node_modules"), + ]; + const found: string[] = []; + const seen = new Set(); + for (const root of roots) { + if (!NodeFS.existsSync(root)) continue; + for (const prefix of SERVER_NATIVE_PACKAGE_PREFIXES) { + const relative = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + if (seen.has(relative)) continue; + const dir = NodePath.join(root, relative); + if (!NodeFS.existsSync(dir)) continue; + seen.add(relative); + found.push(dir); + } + } + return found; +} + +export function requirePackedNodePty(nativeModuleDirs: readonly string[]): void { + if (!nativeModuleDirs.some((dir) => dir.split("/").at(-1) === "node-pty")) { + throw new Error( + "node-pty is required in the server archive. Native addons are platform-specific; this is not a platform-neutral artifact.", + ); + } +} diff --git a/contextivity/src/platforms.ts b/contextivity/src/platforms.ts new file mode 100644 index 0000000000000..3143ff78ab707 --- /dev/null +++ b/contextivity/src/platforms.ts @@ -0,0 +1,52 @@ +import { PLATFORMS, type ArtifactPlatform } from "./config.ts"; +import { type CandidateManifest, selectArtifact } from "./manifest.ts"; + +export interface HostPlatform { + readonly os: "linux" | "darwin"; + readonly arch: "x64" | "arm64"; + readonly platform: ArtifactPlatform; +} + +export function hostPlatformFromNode(nodePlatform: string, nodeArch: string): HostPlatform { + const os = nodePlatform === "darwin" ? "darwin" : nodePlatform === "linux" ? "linux" : null; + const arch = nodeArch === "arm64" ? "arm64" : nodeArch === "x64" ? "x64" : null; + if (os === null || arch === null) { + throw new Error( + `Unsupported host '${nodePlatform}/${nodeArch}'. Downstream archives cover ${PLATFORMS.join(", ")} only.`, + ); + } + return { os, arch, platform: `${os}-${arch}` }; +} + +export function detectHostPlatform( + processLike: { readonly platform: string; readonly arch: string } = process, +): HostPlatform { + return hostPlatformFromNode(processLike.platform, processLike.arch); +} + +export function archiveFileName(platform: ArtifactPlatform, installId: string): string { + return `t3-server-${installId}-${platform}.tar.gz`; +} + +export function runnerForPlatform(platform: ArtifactPlatform): { + readonly os: "ubuntu-24.04" | "ubuntu-24.04-arm" | "macos-13" | "macos-14"; + readonly platform: ArtifactPlatform; +} { + switch (platform) { + case "linux-x64": + return { os: "ubuntu-24.04", platform }; + case "linux-arm64": + return { os: "ubuntu-24.04-arm", platform }; + case "darwin-x64": + return { os: "macos-13", platform }; + case "darwin-arm64": + return { os: "macos-14", platform }; + } +} + +export function selectHostArtifact( + manifest: CandidateManifest, + processLike: { readonly platform: string; readonly arch: string } = process, +) { + return selectArtifact(manifest, detectHostPlatform(processLike).platform); +} diff --git a/contextivity/src/policy.test.ts b/contextivity/src/policy.test.ts new file mode 100644 index 0000000000000..fe72b833a9b6e --- /dev/null +++ b/contextivity/src/policy.test.ts @@ -0,0 +1,158 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeTest from "node:test"; +import { + assertNoUpstreamPackageFallback, + containsUpstreamPackageFallback, + internalUpdateCommand, + replaceUpstreamUpdateCommand, +} from "./update-metadata.ts"; +import { + assertNoPrivateSigningKey, + attestationVerifyArgs, + provenancePolicy, + shouldVerifyAttestation, +} from "./provenance.ts"; +import { resolveGitHubAuth, resolveGitHubEndpoints } from "./github-auth.ts"; +import { promoteCandidate } from "./promote.ts"; +import { GENERIC_ACP_FOCUSED_TESTS, PLATFORMS } from "./config.ts"; +import { buildCandidateManifest } from "./manifest.ts"; +import { nativePrefixesMatchUpstream } from "./pack.ts"; +import { decodeInventory, exampleInventoryLooksSafe } from "./inventory.ts"; +import { + expectedWorkflowPaths, + validatePosixShell, + validateWorkflowYaml, +} from "./workflow-validate.ts"; + +const repoRoot = NodePath.join(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../.."); + +const manifest = buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "abc1234", + buildRevision: "run-1", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: PLATFORMS.map((platform) => ({ + platform, + name: `t3-server-${platform}.tar.gz`, + size: 1, + sha256: "a".repeat(64), + })), +}); + +NodeTest.describe("update and provenance policy", () => { + NodeTest.it("rejects upstream npm package fallbacks and rewrites them to t3-ctx", () => { + NodeAssert.equal(containsUpstreamPackageFallback("npx t3@0.0.34-nightly.20260822.2"), true); + NodeAssert.equal(containsUpstreamPackageFallback("npm install t3@0.0.34"), true); + NodeAssert.equal(containsUpstreamPackageFallback("t3-ctx update --version 0.0.34"), false); + NodeAssert.equal( + replaceUpstreamUpdateCommand("npx t3@0.0.34-nightly.20260822.2", "0.0.34-nightly.20260822.2"), + internalUpdateCommand("0.0.34-nightly.20260822.2"), + ); + NodeAssert.throws(() => + assertNoUpstreamPackageFallback("npx t3@latest service update", "docs"), + ); + }); + + NodeTest.it("requires checksums always and GitHub OIDC attestations when authenticated", () => { + const required = provenancePolicy({ githubAuthenticated: true, oidcAvailable: true }); + NodeAssert.equal(required.checksumRequired, true); + NodeAssert.equal(required.privateSigningKey, "forbidden"); + NodeAssert.equal(shouldVerifyAttestation(required), true); + NodeAssert.equal( + shouldVerifyAttestation( + provenancePolicy({ githubAuthenticated: true, oidcAvailable: false }), + ), + true, + ); + NodeAssert.equal( + shouldVerifyAttestation( + provenancePolicy({ githubAuthenticated: false, oidcAvailable: false }), + ), + false, + ); + NodeAssert.throws(() => assertNoPrivateSigningKey({ COSIGN_KEY: "secret" })); + NodeAssert.deepEqual( + attestationVerifyArgs({ + artifactPath: "a.tar.gz", + owner: "Contextivity", + repo: "t3code", + }), + ["attestation", "verify", "a.tar.gz", "--repo", "Contextivity/t3code"], + ); + }); + + NodeTest.it("uses explicit token or gh, and refuses untrusted GitHub mirrors", () => { + NodeAssert.equal( + resolveGitHubAuth({ env: { CONTEXTIVITY_GITHUB_TOKEN: "tok" } }).source, + "explicit-token", + ); + NodeAssert.equal(resolveGitHubAuth({ env: {}, ghToken: "from-gh" }).source, "gh"); + NodeAssert.throws(() => + resolveGitHubEndpoints({ CONTEXTIVITY_T3_GITHUB_BASE: "https://mirror.example" }), + ); + const trusted = resolveGitHubEndpoints({ + CONTEXTIVITY_T3_GITHUB_BASE: "https://mirror.example", + CONTEXTIVITY_T3_TRUST_MIRROR: "1", + }); + NodeAssert.equal(trusted.trustedMirror, true); + }); + + NodeTest.it("fails promotion closed on Mac client mismatch and missing confirmation", () => { + const mismatch = promoteCandidate({ + channel: "nightly", + manifest, + macClientVersion: "0.0.1", + }); + NodeAssert.equal(mismatch.ok, false); + if (!mismatch.ok) { + NodeAssert.equal(mismatch.failClosed, true); + NodeAssert.equal(mismatch.advanceFleet, false); + } + const missing = promoteCandidate({ + channel: "nightly", + manifest, + macClientVersion: " ", + }); + NodeAssert.equal(missing.ok, false); + const ok = promoteCandidate({ + channel: "nightly", + manifest, + macClientVersion: "v0.0.34-nightly.20260822.2", + }); + NodeAssert.equal(ok.ok, true); + }); +}); + +NodeTest.describe("workflow YAML and shell portability", () => { + NodeTest.it("keeps candidate and promote workflows thin and script-driven", () => { + for (const relative of expectedWorkflowPaths()) { + const yaml = NodeFS.readFileSync(NodePath.join(repoRoot, relative), "utf8"); + const result = validateWorkflowYaml(relative, yaml); + NodeAssert.deepEqual(result.errors, [], result.errors.join("\n")); + } + }); + + NodeTest.it("uses a POSIX launcher and lists ACP tests that exist", () => { + const wrapper = NodeFS.readFileSync(NodePath.join(repoRoot, "contextivity/bin/t3-ctx"), "utf8"); + NodeAssert.deepEqual(validatePosixShell(wrapper, "t3-ctx"), []); + for (const file of GENERIC_ACP_FOCUSED_TESTS) { + NodeAssert.equal(NodeFS.existsSync(NodePath.join(repoRoot, file)), true, file); + } + const inventory = NodeFS.readFileSync( + NodePath.join(repoRoot, "contextivity/inventory.example.json"), + "utf8", + ); + NodeAssert.equal(exampleInventoryLooksSafe(inventory), true); + NodeAssert.equal(decodeInventory(inventory).hosts.filter((host) => host.clientGate).length, 1); + const upstream = NodeFS.readFileSync( + NodePath.join(repoRoot, "scripts/lib/cli-external-packages.ts"), + "utf8", + ); + NodeAssert.equal(nativePrefixesMatchUpstream(upstream), true); + }); +}); diff --git a/contextivity/src/promote.ts b/contextivity/src/promote.ts new file mode 100644 index 0000000000000..8f5d12d16b76e --- /dev/null +++ b/contextivity/src/promote.ts @@ -0,0 +1,129 @@ +import type { Channel } from "./config.ts"; +import { formatInstallId } from "./identity.ts"; +import type { CandidateManifest } from "./manifest.ts"; + +export interface PromotionRequest { + readonly channel: Exclude; + readonly manifest: CandidateManifest; + readonly macClientVersion: string; + readonly requestedInstallId?: string; +} + +export interface PromotionSuccess { + readonly ok: true; + readonly channel: Exclude; + readonly installId: string; + readonly upstreamVersion: string; + readonly contextivityRevision: string; +} + +export interface PromotionFailure { + readonly ok: false; + readonly reason: string; + readonly failClosed: true; + readonly advanceFleet: false; +} + +export type PromotionResult = PromotionSuccess | PromotionFailure; + +export function normalizeVersion(version: string): string { + return version.trim().replace(/^v/u, ""); +} + +export function assertExactMacClientMatch(input: { + readonly macClientVersion: string; + readonly upstreamVersion: string; +}): void { + const client = normalizeVersion(input.macClientVersion); + const upstream = normalizeVersion(input.upstreamVersion); + if (client.length === 0) { + throw new Error( + "Official Mac desktop version is required for promotion. Promotion fails closed on mismatch or missing confirmation.", + ); + } + if (client !== upstream) { + throw new Error( + `Official Mac desktop version '${client}' does not match manifest upstreamVersion '${upstream}'. Promotion fails closed; the desktop app was not modified.`, + ); + } +} + +export function promoteCandidate(request: PromotionRequest): PromotionResult { + try { + if (request.channel !== "nightly" && request.channel !== "stable") { + return { + ok: false, + reason: `Cannot promote to channel '${String(request.channel)}'.`, + failClosed: true, + advanceFleet: false, + }; + } + assertExactMacClientMatch({ + macClientVersion: request.macClientVersion, + upstreamVersion: request.manifest.upstreamVersion, + }); + const installId = formatInstallId( + request.manifest.upstreamVersion, + request.manifest.contextivityRevision, + ); + if (request.requestedInstallId && request.requestedInstallId !== installId) { + return { + ok: false, + reason: `Requested identity '${request.requestedInstallId}' does not match candidate '${installId}'.`, + failClosed: true, + advanceFleet: false, + }; + } + if (request.manifest.compatibility.upstreamNpmPackage !== "forbidden") { + return { + ok: false, + reason: "Candidate compatibility allows upstream npm package fallback.", + failClosed: true, + advanceFleet: false, + }; + } + return { + ok: true, + channel: request.channel, + installId, + upstreamVersion: request.manifest.upstreamVersion, + contextivityRevision: request.manifest.contextivityRevision, + }; + } catch (cause) { + return { + ok: false, + reason: cause instanceof Error ? cause.message : String(cause), + failClosed: true, + advanceFleet: false, + }; + } +} + +export interface ChannelPointer { + readonly schemaVersion: 1; + readonly channel: Exclude; + readonly candidateTag: string; + readonly installId: string; + readonly upstreamVersion: string; + readonly contextivityRevision: string; + readonly promotedAt: string; +} + +export function buildChannelPointer(input: { + readonly channel: Exclude; + readonly candidateTag: string; + readonly installId: string; + readonly upstreamVersion: string; + readonly contextivityRevision: string; + readonly promotedAt: string; +}): ChannelPointer { + return { + schemaVersion: 1, + channel: input.channel, + candidateTag: input.candidateTag, + installId: input.installId, + upstreamVersion: input.upstreamVersion, + contextivityRevision: input.contextivityRevision, + promotedAt: new Date(input.promotedAt).toISOString(), + }; +} diff --git a/contextivity/src/provenance.ts b/contextivity/src/provenance.ts new file mode 100644 index 0000000000000..f2e4a67c4e474 --- /dev/null +++ b/contextivity/src/provenance.ts @@ -0,0 +1,64 @@ +export type ProvenanceMode = "required" | "optional" | "unavailable"; + +export interface ProvenancePolicy { + readonly checksumRequired: true; + readonly privateSigningKey: "forbidden"; + readonly githubOidcAttestation: ProvenanceMode; +} + +export function provenancePolicy(input: { + readonly githubAuthenticated: boolean; + readonly oidcAvailable: boolean; +}): ProvenancePolicy { + const githubOidcAttestation: ProvenanceMode = + input.githubAuthenticated && input.oidcAvailable + ? "required" + : input.githubAuthenticated + ? "optional" + : "unavailable"; + return { + checksumRequired: true, + privateSigningKey: "forbidden", + githubOidcAttestation, + }; +} + +export function assertNoPrivateSigningKey(env: Record): void { + const forbidden = [ + "CONTEXTIVITY_SIGNING_KEY", + "CONTEXTIVITY_T3_SIGNING_KEY", + "COSIGN_KEY", + "COSIGN_PRIVATE_KEY", + ]; + for (const name of forbidden) { + if ((env[name] ?? "").trim() !== "") { + throw new Error( + `${name} is set. Downstream provenance uses GitHub OIDC attestations only; do not invent or store a private signing key.`, + ); + } + } +} + +export function requireChecksumMatch(input: { + readonly expectedSha256: string; + readonly actualSha256: string; + readonly name: string; +}): void { + if (input.expectedSha256.toLowerCase() !== input.actualSha256.toLowerCase()) { + throw new Error( + `SHA-256 mismatch for ${input.name}: expected ${input.expectedSha256}, got ${input.actualSha256}.`, + ); + } +} + +export function shouldVerifyAttestation(policy: ProvenancePolicy): boolean { + return policy.githubOidcAttestation === "required" || policy.githubOidcAttestation === "optional"; +} + +export function attestationVerifyArgs(input: { + readonly artifactPath: string; + readonly owner: string; + readonly repo: string; +}): readonly string[] { + return ["attestation", "verify", input.artifactPath, "--repo", `${input.owner}/${input.repo}`]; +} diff --git a/contextivity/src/release.test.ts b/contextivity/src/release.test.ts new file mode 100644 index 0000000000000..877e2d79fac57 --- /dev/null +++ b/contextivity/src/release.test.ts @@ -0,0 +1,85 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTest from "node:test"; +import { PLATFORMS } from "./config.ts"; +import { buildCandidateManifest, candidateReleaseTag } from "./manifest.ts"; +import { + assertGhArgvHasNoSecret, + candidateReleaseNotes, + ghReleaseCreateArgs, + ghReleaseDownloadArgs, + resolveExactReleaseTag, + syncedWipRef, +} from "./release.ts"; + +const manifest = buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "abc1234", + buildRevision: "run-1", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: PLATFORMS.map((platform) => ({ + platform, + name: `t3-server-${platform}.tar.gz`, + size: 1, + sha256: "a".repeat(64), + })), +}); + +NodeTest.describe("candidate GitHub release identity", () => { + NodeTest.it("uses an immutable candidate tag and never puts tokens on gh argv", () => { + const tag = candidateReleaseTag(manifest); + NodeAssert.equal(tag, "contextivity-candidate/0.0.34-nightly.20260822.2-ctx.abc1234"); + const notes = candidateReleaseNotes(manifest); + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-rel-")); + const asset = NodePath.join(dir, "manifest.json"); + NodeFS.writeFileSync(asset, "{}"); + const create = ghReleaseCreateArgs({ + tag, + repo: "Contextivity/t3code", + title: "Contextivity candidate", + notes, + files: [asset], + }); + NodeAssert.equal(create[0], "release"); + NodeAssert.equal(create[1], "create"); + NodeAssert.equal(create.includes("--latest=false"), true); + assertGhArgvHasNoSecret(create, ["ghs_secret_token"]); + NodeAssert.equal( + ghReleaseDownloadArgs({ + tag, + repo: "Contextivity/t3code", + dir, + pattern: "manifest.json", + }).includes("ghs_secret_token"), + false, + ); + NodeAssert.equal(syncedWipRef("12345"), "refs/heads/contextivity-sync/12345"); + }); + + NodeTest.it("pins exact versions and pointer channels without npm package names", () => { + NodeAssert.deepEqual( + resolveExactReleaseTag({ version: "0.0.34-nightly.20260822.2-ctx.abc1234" }), + { + tag: "contextivity-candidate/0.0.34-nightly.20260822.2-ctx.abc1234", + installId: "0.0.34-nightly.20260822.2-ctx.abc1234", + pointer: false, + }, + ); + NodeAssert.equal(resolveExactReleaseTag({ channel: "nightly" }).tag, "contextivity-nightly"); + NodeAssert.equal(resolveExactReleaseTag({ channel: "stable" }).pointer, true); + NodeAssert.throws(() => resolveExactReleaseTag({})); + NodeAssert.throws(() => + ghReleaseCreateArgs({ + tag: "contextivity-candidate/x", + repo: "Contextivity/t3code", + title: "ok", + notes: "npx t3@0.0.34", + files: ["manifest.json"], + }), + ); + }); +}); diff --git a/contextivity/src/release.ts b/contextivity/src/release.ts new file mode 100644 index 0000000000000..f4afc86068faf --- /dev/null +++ b/contextivity/src/release.ts @@ -0,0 +1,158 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { DOWNSTREAM_GITHUB, POINTER_TAGS } from "./config.ts"; +import { parseInstallId } from "./identity.ts"; +import { type CandidateManifest } from "./manifest.ts"; +import { type ChannelPointer } from "./promote.ts"; +import { containsUpstreamPackageFallback } from "./update-metadata.ts"; + +export function syncedWipRef(runId: string): string { + if (!/^[0-9]+$/u.test(runId)) { + throw new Error("Invalid GitHub run id for the synced merge ref."); + } + return `refs/heads/contextivity-sync/${runId}`; +} + +export function ghRepoSlug( + owner: string = DOWNSTREAM_GITHUB.owner, + repo: string = DOWNSTREAM_GITHUB.repo, +): string { + return `${owner}/${repo}`; +} + +export function ghReleaseDownloadArgs(input: { + readonly tag: string; + readonly repo: string; + readonly dir: string; + readonly pattern: string; +}): readonly string[] { + return [ + "release", + "download", + input.tag, + "--repo", + input.repo, + "--dir", + input.dir, + "--pattern", + input.pattern, + ]; +} + +export function ghReleaseViewArgs(tag: string, repo: string): readonly string[] { + return ["release", "view", tag, "--repo", repo]; +} + +export function ghReleaseCreateArgs(input: { + readonly tag: string; + readonly repo: string; + readonly title: string; + readonly notes: string; + readonly files: readonly string[]; +}): readonly string[] { + if ( + containsUpstreamPackageFallback(input.notes) || + containsUpstreamPackageFallback(input.title) + ) { + throw new Error("Release notes must not mention the public npm t3 package."); + } + for (const file of input.files) { + if (file.startsWith("-") || file.includes("--token") || file.includes("ghp_")) { + throw new Error("Unsafe release asset path."); + } + } + return [ + "release", + "create", + input.tag, + ...input.files, + "--repo", + input.repo, + "--title", + input.title, + "--notes", + input.notes, + "--prerelease", + "--latest=false", + ]; +} + +export function candidateReleaseNotes(manifest: CandidateManifest): string { + return [ + `Immutable Contextivity candidate for official T3 ${manifest.upstreamVersion}.`, + `Patch revision ${manifest.contextivityRevision}.`, + "Install with t3-ctx updater update. Do not use the public npm t3 package.", + "This is not a fleet pointer. Promote only after the official Mac desktop version matches.", + ].join("\n"); +} + +export function candidateReleaseTitle(manifest: CandidateManifest): string { + return `Contextivity candidate ${manifest.upstreamVersion}-ctx.${manifest.contextivityRevision}`; +} + +export function assertGhArgvHasNoSecret( + argv: readonly string[], + secrets: readonly (string | null | undefined)[], +): void { + for (const secret of secrets) { + if (!secret) continue; + if (argv.some((arg) => arg.includes(secret))) { + throw new Error("GitHub token must not appear on the gh command line."); + } + } +} + +export function releaseAssetFiles(dir: string): string[] { + return NodeFS.readdirSync(dir) + .filter((name) => !name.startsWith(".") && !name.startsWith("stage-")) + .sort() + .map((name) => NodePath.join(dir, name)); +} + +export function resolveExactReleaseTag(input: { + readonly version?: string; + readonly channel?: string; +}): { readonly tag: string; readonly installId?: string; readonly pointer: boolean } { + const version = input.version?.trim() ?? ""; + if (version.length > 0) { + const parsed = parseInstallId(version); + if (!parsed) { + throw new Error(`Invalid install id '${version}'.`); + } + return { + tag: `contextivity-candidate/${parsed.installId}`, + installId: parsed.installId, + pointer: false, + }; + } + if (input.channel === "nightly" || input.channel === "stable") { + return { tag: POINTER_TAGS[input.channel], pointer: true }; + } + if (input.channel === "candidate") { + throw new Error("Channel 'candidate' requires --version ."); + } + throw new Error("Pass --version or --channel nightly|stable."); +} + +export function decodeChannelPointer(text: string): ChannelPointer { + const raw = JSON.parse(text) as Record; + const channel = raw["channel"]; + if (channel !== "nightly" && channel !== "stable") { + throw new Error("Channel pointer must be nightly or stable."); + } + const installId = String(raw["installId"] ?? ""); + const parsed = parseInstallId(installId); + if (!parsed) { + throw new Error("Channel pointer installId is invalid."); + } + const candidateTag = String(raw["candidateTag"] ?? `contextivity-candidate/${parsed.installId}`); + return { + schemaVersion: 1, + channel, + candidateTag, + installId: parsed.installId, + upstreamVersion: String(raw["upstreamVersion"] ?? parsed.upstreamVersion), + contextivityRevision: String(raw["contextivityRevision"] ?? parsed.contextivityRevision), + promotedAt: String(raw["promotedAt"] ?? new Date().toISOString()), + }; +} diff --git a/contextivity/src/spawn.ts b/contextivity/src/spawn.ts new file mode 100644 index 0000000000000..d2fe028dfc980 --- /dev/null +++ b/contextivity/src/spawn.ts @@ -0,0 +1,97 @@ +import * as NodeChildProcess from "node:child_process"; +import type { CommandResult } from "./updater.ts"; +import { redactSecrets } from "./github-auth.ts"; + +export interface SpawnRequest { + readonly command: string; + readonly args?: readonly string[]; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly timeoutMs?: number; + readonly input?: string; +} + +export const HOST_RESTART_TIMEOUT_MS = 60_000; +export const HOST_HEALTH_TIMEOUT_MS = 30_000; + +export async function runBoundedHostCommand( + command: string, + timeoutMs: number, +): Promise { + const trimmed = command.trim(); + if (trimmed.length === 0) { + return { code: 1, stdout: "", stderr: "Host command is empty." }; + } + try { + return await spawnCommand({ + command: "sh", + args: ["-c", trimmed], + timeoutMs, + }); + } catch (error) { + return { + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +export function spawnCommand(request: SpawnRequest): Promise { + const { command, args = [], cwd, env, timeoutMs, input } = request; + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(command, [...args], { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = + timeoutMs === undefined + ? null + : setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (error) => { + if (timer) clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + if (timer) clearTimeout(timer); + if (timedOut) { + reject(new Error(`Command timed out: ${command}`)); + return; + } + resolve({ + code: code ?? 1, + stdout, + stderr, + }); + }); + if (input !== undefined) { + child.stdin.end(input); + } else { + child.stdin.end(); + } + }); +} + +export function redactCommandResult( + result: CommandResult, + secrets: readonly (string | null | undefined)[], +): CommandResult { + return { + code: result.code, + stdout: redactSecrets(result.stdout, secrets), + stderr: redactSecrets(result.stderr, secrets), + }; +} diff --git a/contextivity/src/sync.test.ts b/contextivity/src/sync.test.ts new file mode 100644 index 0000000000000..890d1dd3c147d --- /dev/null +++ b/contextivity/src/sync.test.ts @@ -0,0 +1,149 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; +import { failClosed, syncExactUpstreamTag, type GitRunner } from "./sync.ts"; +import type { UpstreamNightly } from "./upstream.ts"; +import { reportSyncFailureIssue, type IssueClient, type IssueSummary } from "./issues.ts"; + +const nightly: UpstreamNightly = { + tag: "v0.0.34-nightly.20260822.2", + version: "0.0.34-nightly.20260822.2", + date: "20260822", + runNumber: 2, + commit: "c".repeat(40), +}; + +function gitStub(overrides: Partial = {}): GitRunner { + return { + fetchExactTag: async () => undefined, + revParse: async (rev) => (rev === "HEAD" ? "d".repeat(40) : nightly.commit), + mergeCommit: async () => ({ ok: true }), + abortMerge: async () => undefined, + isAncestor: async () => false, + readFileAt: async () => JSON.stringify({ version: nightly.version }), + currentBranch: async () => "main", + hasUncommittedChanges: async () => false, + ...overrides, + }; +} + +NodeTest.describe("exact-tag sync fail-closed", () => { + NodeTest.it("merges the exact upstream tag when the worktree is clean", async () => { + const result = await syncExactUpstreamTag({ + git: gitStub(), + remote: "origin", + nightly, + }); + NodeAssert.equal(result.ok, true); + if (result.ok) { + NodeAssert.equal(result.tag, nightly.tag); + NodeAssert.equal(result.alreadyContained, false); + } + }); + + NodeTest.it("skips merge when the tag is already an ancestor", async () => { + const result = await syncExactUpstreamTag({ + git: gitStub({ isAncestor: async () => true }), + remote: "origin", + nightly, + }); + NodeAssert.equal(result.ok, true); + if (result.ok) NodeAssert.equal(result.alreadyContained, true); + }); + + NodeTest.it("fails closed on merge conflicts and does not publish", async () => { + let aborted = false; + const result = await syncExactUpstreamTag({ + git: gitStub({ + mergeCommit: async () => ({ ok: false, conflicts: ["apps/server/src/server.ts"] }), + abortMerge: async () => { + aborted = true; + }, + }), + remote: "origin", + nightly, + }); + NodeAssert.equal(result.ok, false); + NodeAssert.equal(aborted, true); + if (!result.ok) { + NodeAssert.equal(result.failClosed, true); + NodeAssert.equal(result.publish, false); + NodeAssert.equal(result.advanceFleet, false); + NodeAssert.deepEqual(result.conflicts, ["apps/server/src/server.ts"]); + } + }); + + NodeTest.it("fails closed on dirty worktree, version mismatch, and fetch errors", async () => { + const dirty = await syncExactUpstreamTag({ + git: gitStub({ hasUncommittedChanges: async () => true }), + remote: "origin", + nightly, + }); + NodeAssert.equal(dirty.ok, false); + + const mismatch = await syncExactUpstreamTag({ + git: gitStub({ + readFileAt: async () => JSON.stringify({ version: "0.0.1" }), + }), + remote: "origin", + nightly, + }); + NodeAssert.equal(mismatch.ok, false); + + const fetchFail = await syncExactUpstreamTag({ + git: gitStub({ + fetchExactTag: async () => { + throw new Error("network"); + }, + }), + remote: "origin", + nightly, + }); + NodeAssert.equal(fetchFail.ok, false); + }); + + NodeTest.it("deduplicates maintenance issues for the same failed tag", async () => { + const existing: IssueSummary = { + number: 9, + title: "Contextivity T3 nightly sync failed: v0.0.34-nightly.20260822.2", + state: "open", + htmlUrl: "https://github.com/Contextivity/t3code/issues/9", + }; + const created: IssueSummary[] = []; + const client: IssueClient = { + searchOpen: async () => [existing], + create: async (input) => { + const issue = { + number: 10, + title: input.title, + state: "open" as const, + htmlUrl: "https://example.test/10", + }; + created.push(issue); + return issue; + }, + }; + const first = await reportSyncFailureIssue( + client, + failClosed(nightly.tag, "conflict", ["a.ts"]), + ); + NodeAssert.equal(first.created, false); + NodeAssert.equal(first.issue.number, 9); + NodeAssert.equal(created.length, 0); + + const emptyClient: IssueClient = { + searchOpen: async () => [], + create: async (input) => ({ + number: 11, + title: input.title, + state: "open", + htmlUrl: "https://example.test/11", + }), + }; + const second = await reportSyncFailureIssue( + emptyClient, + failClosed(nightly.tag, "conflict", ["a.ts"]), + ); + NodeAssert.equal(second.created, true); + NodeAssert.equal(second.issue.number, 11); + }); +}); diff --git a/contextivity/src/sync.ts b/contextivity/src/sync.ts new file mode 100644 index 0000000000000..db20c313707ac --- /dev/null +++ b/contextivity/src/sync.ts @@ -0,0 +1,162 @@ +import { parseNightlyTag } from "./identity.ts"; +import type { UpstreamNightly } from "./upstream.ts"; + +export interface GitRunner { + readonly fetchExactTag: (remote: string, tag: string) => Promise; + readonly revParse: (rev: string) => Promise; + readonly mergeCommit: (rev: string) => Promise<{ ok: true } | { ok: false; conflicts: string[] }>; + readonly abortMerge: () => Promise; + readonly isAncestor: (ancestor: string, descendant: string) => Promise; + readonly readFileAt: (rev: string, filePath: string) => Promise; + readonly currentBranch: () => Promise; + readonly hasUncommittedChanges: () => Promise; +} + +export interface SyncSuccess { + readonly ok: true; + readonly tag: string; + readonly upstreamCommit: string; + readonly branch: string; + readonly alreadyContained: boolean; +} + +export interface SyncFailure { + readonly ok: false; + readonly tag: string; + readonly reason: string; + readonly conflicts: readonly string[]; + readonly failClosed: true; + readonly publish: false; + readonly advanceFleet: false; +} + +export type SyncResult = SyncSuccess | SyncFailure; + +export function failClosed( + tag: string, + reason: string, + conflicts: readonly string[] = [], +): SyncFailure { + return { + ok: false, + tag, + reason, + conflicts, + failClosed: true, + publish: false, + advanceFleet: false, + }; +} + +export async function syncExactUpstreamTag(input: { + readonly git: GitRunner; + readonly remote: string; + readonly nightly: UpstreamNightly; +}): Promise { + const { git, remote, nightly } = input; + if (!parseNightlyTag(nightly.tag)) { + return failClosed(nightly.tag, "Not an official T3 nightly tag."); + } + if (await git.hasUncommittedChanges()) { + return failClosed(nightly.tag, "Worktree is dirty; refusing to merge an upstream nightly."); + } + + try { + await git.fetchExactTag(remote, nightly.tag); + } catch (cause) { + return failClosed( + nightly.tag, + `Failed to fetch exact tag ${nightly.tag}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + const resolvedCommit = (await git.revParse(`${nightly.tag}^{commit}`)).toLowerCase(); + if (resolvedCommit !== nightly.commit.toLowerCase()) { + return failClosed( + nightly.tag, + `Fetched tag ${nightly.tag} is ${resolvedCommit}, expected ${nightly.commit}.`, + ); + } + + let packageJson: string; + try { + packageJson = await git.readFileAt(nightly.tag, "apps/server/package.json"); + } catch (cause) { + return failClosed( + nightly.tag, + `Could not read apps/server/package.json at ${nightly.tag}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + let packageVersion: string; + try { + const parsed = JSON.parse(packageJson) as { version?: unknown }; + packageVersion = typeof parsed.version === "string" ? parsed.version : ""; + } catch { + return failClosed(nightly.tag, "apps/server/package.json at the tag is not valid JSON."); + } + if (packageVersion !== nightly.version) { + return failClosed( + nightly.tag, + `Tag ${nightly.tag} package version is '${packageVersion}', expected '${nightly.version}'.`, + ); + } + + const head = await git.revParse("HEAD"); + const alreadyContained = await git.isAncestor(resolvedCommit, head); + const branch = await git.currentBranch(); + if (alreadyContained) { + return { + ok: true, + tag: nightly.tag, + upstreamCommit: resolvedCommit, + branch, + alreadyContained: true, + }; + } + + const merge = await git.mergeCommit(nightly.tag); + if (!merge.ok) { + try { + await git.abortMerge(); + } catch { + // still fail closed; the worktree may need manual recovery + } + return failClosed( + nightly.tag, + `Merge of ${nightly.tag} conflicted. Prior fleet version stays active.`, + merge.conflicts, + ); + } + + return { + ok: true, + tag: nightly.tag, + upstreamCommit: resolvedCommit, + branch, + alreadyContained: false, + }; +} + +export function syncFailureIssueTitle(tag: string): string { + return `Contextivity T3 nightly sync failed: ${tag}`; +} + +export function syncFailureIssueBody(failure: SyncFailure): string { + const conflictLines = + failure.conflicts.length === 0 + ? "- (no conflict paths reported)" + : failure.conflicts.map((path) => `- \`${path}\``).join("\n"); + return [ + "The Contextivity downstream failed to replay an official `pingdotgg/t3code` nightly tag.", + "", + `- Tag: \`${failure.tag}\``, + `- Reason: ${failure.reason}`, + "- Fail-closed: the previous fleet pointer was not advanced and no candidate was published.", + "", + "Conflicts:", + conflictLines, + "", + "Recover by resolving the patch stack against that exact tag, then re-run the candidate workflow with the same tag.", + ].join("\n"); +} diff --git a/contextivity/src/update-metadata.ts b/contextivity/src/update-metadata.ts new file mode 100644 index 0000000000000..982c2593f7509 --- /dev/null +++ b/contextivity/src/update-metadata.ts @@ -0,0 +1,32 @@ +import { FORBIDDEN_UPSTREAM_PACKAGE_PATTERNS } from "./config.ts"; + +export function internalUpdateCommand(upstreamVersion: string): string { + return `t3-ctx update --version ${upstreamVersion}`; +} + +export function fleetUpdateCommand(channel: "nightly" | "stable"): string { + return `t3-ctx fleet update --channel ${channel}`; +} + +export function containsUpstreamPackageFallback(text: string): boolean { + return FORBIDDEN_UPSTREAM_PACKAGE_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function assertNoUpstreamPackageFallback(text: string, label: string): void { + if (containsUpstreamPackageFallback(text)) { + throw new Error( + `${label} offers upstream package t3@. Downstream updates must use t3-ctx / t3-ctx fleet.`, + ); + } +} + +export function replaceUpstreamUpdateCommand(command: string, upstreamVersion: string): string { + if (!containsUpstreamPackageFallback(command)) { + return command; + } + return internalUpdateCommand(upstreamVersion); +} + +export function downstreamUpdateGuidance(serverLabel: string): string { + return `This ${serverLabel} is a Contextivity distribution. Use \`t3-ctx update\` on the host, not \`npx t3@\`.`; +} diff --git a/contextivity/src/updater.test.ts b/contextivity/src/updater.test.ts new file mode 100644 index 0000000000000..3a0b6b73e0f46 --- /dev/null +++ b/contextivity/src/updater.test.ts @@ -0,0 +1,302 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTest from "node:test"; +import * as NodeCrypto from "node:crypto"; +import { PLATFORMS } from "./config.ts"; +import { buildCandidateManifest, type ManifestArtifact } from "./manifest.ts"; +import { HOST_HEALTH_TIMEOUT_MS, runBoundedHostCommand } from "./spawn.ts"; +import { + activateAndVerify, + activateStaged, + layoutAt, + rollbackCurrent, + stageCandidate, + updaterStatus, + type UpdaterCommands, +} from "./updater.ts"; + +function tempRoot(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "ctx-updater-")); +} + +function sha256(contents: string): string { + return NodeCrypto.createHash("sha256").update(contents).digest("hex"); +} + +function artifactsFor(sha: string, size: number): ManifestArtifact[] { + return PLATFORMS.map((platform, index) => ({ + platform, + name: `t3-server-${platform}.tar.gz`, + size: platform === "linux-x64" ? size : index + 1, + sha256: platform === "linux-x64" ? sha : String.fromCharCode(98 + index).repeat(64), + })); +} + +function manifestFor(sha: string, size: number) { + return buildCandidateManifest({ + upstreamVersion: "0.0.34-nightly.20260822.2", + upstreamCommit: "c".repeat(40), + contextivityRevision: "abc1234", + buildRevision: "run-1", + nodeEngine: ">=24", + createdAt: "2026-08-22T00:00:00.000Z", + artifacts: artifactsFor(sha, size), + }); +} + +NodeTest.describe("updater stage/activate/rollback", () => { + NodeTest.it( + "stages after checksum verification, activates atomically, and rolls back", + async () => { + const root = tempRoot(); + const layout = layoutAt(root); + const archivePath = NodePath.join(root, "payload.tar.gz"); + const body = "archive-bytes"; + NodeFS.writeFileSync(archivePath, body); + const manifest = manifestFor(sha256(body), body.length); + const commands: UpdaterCommands = { + extractArchive: async (_archive, destination) => { + NodeFS.mkdirSync(NodePath.join(destination, "dist"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(destination, "dist/bin.mjs"), "export {};\n"); + }, + preflight: async () => ({ code: 0, stdout: "t3 help", stderr: "" }), + }; + + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + NodeAssert.equal(staged.installId, "0.0.34-nightly.20260822.2-ctx.abc1234"); + + const first = activateStaged({ layout, installId: staged.installId }); + NodeAssert.equal(first.current, staged.installId); + NodeAssert.equal(updaterStatus(layout).current, staged.installId); + + const secondId = "0.0.35-nightly.20260823.1-ctx.ddd1111"; + const secondManifest = buildCandidateManifest({ + upstreamVersion: "0.0.35-nightly.20260823.1", + upstreamCommit: "d".repeat(40), + contextivityRevision: "ddd1111", + buildRevision: "run-2", + nodeEngine: ">=24", + createdAt: "2026-08-23T00:00:00.000Z", + artifacts: manifest.artifacts, + }); + NodeFS.writeFileSync(archivePath, body); + await stageCandidate({ + layout, + manifest: secondManifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + activateStaged({ layout, installId: secondId }); + const rolled = rollbackCurrent(layout); + NodeAssert.equal(rolled.current, staged.installId); + NodeAssert.equal(rolled.rolledBackFrom, secondId); + }, + ); + + NodeTest.it("requires provenance verification when GitHub is authenticated", async () => { + const root = tempRoot(); + const layout = layoutAt(root); + const archivePath = NodePath.join(root, "payload.tar.gz"); + const body = "archive-bytes"; + NodeFS.writeFileSync(archivePath, body); + const manifest = manifestFor(sha256(body), body.length); + await NodeAssert.rejects( + () => + stageCandidate({ + layout, + manifest, + archivePath, + commands: { + extractArchive: async () => undefined, + preflight: async () => ({ code: 0, stdout: "", stderr: "" }), + }, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: true, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }), + /attestation verifier/, + ); + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + commands: { + extractArchive: async (_archive, destination) => { + NodeFS.mkdirSync(NodePath.join(destination, "dist"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(destination, "dist/bin.mjs"), "export {};\n"); + }, + preflight: async () => ({ code: 0, stdout: "t3 help", stderr: "" }), + verifyAttestation: async () => ({ code: 0, stdout: "ok", stderr: "" }), + }, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: true, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + NodeAssert.equal(staged.installId, "0.0.34-nightly.20260822.2-ctx.abc1234"); + }); + + NodeTest.it("refuses checksum mismatches and failed preflight without activating", async () => { + const root = tempRoot(); + const layout = layoutAt(root); + const archivePath = NodePath.join(root, "payload.tar.gz"); + NodeFS.writeFileSync(archivePath, "good"); + const manifest = manifestFor(sha256("other"), 4); + await NodeAssert.rejects( + () => + stageCandidate({ + layout, + manifest, + archivePath, + commands: { + extractArchive: async () => undefined, + preflight: async () => ({ code: 0, stdout: "", stderr: "" }), + }, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }), + /SHA-256 mismatch/, + ); + NodeAssert.equal(updaterStatus(layout).current, null); + }); + + NodeTest.it("rolls back hosts that switched when health verification fails", async () => { + const root = tempRoot(); + const layout = layoutAt(root); + const archivePath = NodePath.join(root, "payload.tar.gz"); + const body = "archive-bytes"; + NodeFS.writeFileSync(archivePath, body); + const manifest = manifestFor(sha256(body), body.length); + const commands: UpdaterCommands = { + extractArchive: async (_archive, destination) => { + NodeFS.mkdirSync(NodePath.join(destination, "dist"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(destination, "dist/bin.mjs"), "export {};\n"); + }, + preflight: async () => ({ code: 0, stdout: "", stderr: "" }), + health: async () => ({ code: 1, stdout: "", stderr: "unhealthy" }), + }; + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + activateStaged({ layout, installId: staged.installId }); + + const nextBody = "next"; + NodeFS.writeFileSync(archivePath, nextBody); + const nextManifest = buildCandidateManifest({ + upstreamVersion: "0.0.35-nightly.20260823.1", + upstreamCommit: "e".repeat(40), + contextivityRevision: "eee2222", + buildRevision: "run-3", + nodeEngine: ">=24", + createdAt: "2026-08-23T00:00:00.000Z", + artifacts: artifactsFor(sha256(nextBody), nextBody.length), + }); + await stageCandidate({ + layout, + manifest: nextManifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + await NodeAssert.rejects( + () => + activateAndVerify({ + layout, + installId: "0.0.35-nightly.20260823.1-ctx.eee2222", + commands, + }), + /Health check/, + ); + NodeAssert.equal(updaterStatus(layout).current, staged.installId); + }); + + NodeTest.it("rolls back when a bounded host health command fails", async () => { + const root = tempRoot(); + const layout = layoutAt(root); + const archivePath = NodePath.join(root, "payload.tar.gz"); + const body = "archive-bytes"; + NodeFS.writeFileSync(archivePath, body); + const manifest = manifestFor(sha256(body), body.length); + const commands: UpdaterCommands = { + extractArchive: async (_archive, destination) => { + NodeFS.mkdirSync(NodePath.join(destination, "dist"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(destination, "dist/bin.mjs"), "export {};\n"); + }, + preflight: async () => ({ code: 0, stdout: "", stderr: "" }), + }; + const staged = await stageCandidate({ + layout, + manifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + activateStaged({ layout, installId: staged.installId }); + + const nextBody = "next-health"; + NodeFS.writeFileSync(archivePath, nextBody); + const nextManifest = buildCandidateManifest({ + upstreamVersion: "0.0.35-nightly.20260823.1", + upstreamCommit: "e".repeat(40), + contextivityRevision: "eee2222", + buildRevision: "run-3", + nodeEngine: ">=24", + createdAt: "2026-08-23T00:00:00.000Z", + artifacts: artifactsFor(sha256(nextBody), nextBody.length), + }); + await stageCandidate({ + layout, + manifest: nextManifest, + archivePath, + commands, + github: { owner: "Contextivity", repo: "t3code" }, + githubAuthenticated: false, + oidcAvailable: false, + processLike: { platform: "linux", arch: "x64" }, + }); + await NodeAssert.rejects( + () => + activateAndVerify({ + layout, + installId: "0.0.35-nightly.20260823.1-ctx.eee2222", + commands: { + ...commands, + health: () => runBoundedHostCommand("exit 1", HOST_HEALTH_TIMEOUT_MS), + }, + }), + /Health check/, + ); + NodeAssert.equal(updaterStatus(layout).current, staged.installId); + }); +}); diff --git a/contextivity/src/updater.ts b/contextivity/src/updater.ts new file mode 100644 index 0000000000000..f6c23422ea864 --- /dev/null +++ b/contextivity/src/updater.ts @@ -0,0 +1,297 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { + CHECKSUMS_FILENAME, + CONTEXTIVITY_HOME_ENV, + CURRENT_LINK, + DEFAULT_LAYOUT_ROOT, + MANIFEST_FILENAME, + MARKER_FILENAME, + PREVIOUS_LINK, +} from "./config.ts"; +import { atomicSymlink, atomicWriteFile, readLinkOrNull, readTextOrNull } from "./atomic.ts"; +import { sha256File } from "./hash.ts"; +import { formatInstallId, parseInstallId } from "./identity.ts"; +import { decodeManifest, type CandidateManifest, selectArtifact } from "./manifest.ts"; +import { detectHostPlatform } from "./platforms.ts"; +import { + attestationVerifyArgs, + provenancePolicy, + requireChecksumMatch, + shouldVerifyAttestation, +} from "./provenance.ts"; +import { containsUpstreamPackageFallback } from "./update-metadata.ts"; + +export interface UpdaterLayout { + readonly root: string; + readonly versionsDir: string; + readonly currentLink: string; + readonly previousLink: string; +} + +export interface StagedVersion { + readonly installId: string; + readonly versionDir: string; + readonly manifest: CandidateManifest; +} + +export interface CommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface UpdaterCommands { + readonly extractArchive: (archivePath: string, destination: string) => Promise; + readonly preflight: (versionDir: string) => Promise; + readonly restart?: () => Promise; + readonly health?: () => Promise; + readonly verifyAttestation?: (args: readonly string[]) => Promise; +} + +export function expandHome(pathValue: string, home: string = NodeOS.homedir()): string { + if (pathValue === "~") return home; + if (pathValue.startsWith("~/")) return NodePath.join(home, pathValue.slice(2)); + return pathValue; +} + +export function resolveLayoutRoot( + env: Record = process.env, + home: string = NodeOS.homedir(), +): string { + const override = env[CONTEXTIVITY_HOME_ENV]?.trim(); + return NodePath.resolve( + expandHome(override && override.length > 0 ? override : DEFAULT_LAYOUT_ROOT, home), + ); +} + +export function layoutAt(root: string): UpdaterLayout { + return { + root, + versionsDir: NodePath.join(root, "versions"), + currentLink: NodePath.join(root, CURRENT_LINK), + previousLink: NodePath.join(root, PREVIOUS_LINK), + }; +} + +export function versionDirFor(layout: UpdaterLayout, installId: string): string { + const parsed = parseInstallId(installId); + if (!parsed) { + throw new Error(`Invalid install id '${installId}'.`); + } + return NodePath.join(layout.versionsDir, parsed.installId); +} + +export function currentInstallId(layout: UpdaterLayout): string | null { + const target = readLinkOrNull(layout.currentLink); + if (!target) return null; + return parseInstallId(target.split("/").at(-1) ?? target)?.installId ?? null; +} + +export function previousInstallId(layout: UpdaterLayout): string | null { + const target = readLinkOrNull(layout.previousLink); + if (!target) return null; + return parseInstallId(target.split("/").at(-1) ?? target)?.installId ?? null; +} + +export function writeDistributionMarker(versionDir: string, manifest: CandidateManifest): void { + atomicWriteFile( + NodePath.join(versionDir, MARKER_FILENAME), + `${JSON.stringify( + { + upstreamVersion: manifest.upstreamVersion, + contextivityRevision: manifest.contextivityRevision, + protocolVersion: manifest.upstreamVersion, + installId: formatInstallId(manifest.upstreamVersion, manifest.contextivityRevision), + }, + null, + 2, + )}\n`, + ); +} + +export async function verifyStagedArtifact(input: { + readonly archivePath: string; + readonly expectedSha256: string; + readonly expectedSize: number; + readonly name: string; +}): Promise { + const stats = NodeFS.statSync(input.archivePath); + if (stats.size !== input.expectedSize) { + throw new Error( + `Size mismatch for ${input.name}: expected ${input.expectedSize}, got ${stats.size}.`, + ); + } + const actual = await sha256File(input.archivePath); + requireChecksumMatch({ + expectedSha256: input.expectedSha256, + actualSha256: actual, + name: input.name, + }); +} + +export async function stageCandidate(input: { + readonly layout: UpdaterLayout; + readonly manifest: CandidateManifest; + readonly archivePath: string; + readonly commands: UpdaterCommands; + readonly github: { readonly owner: string; readonly repo: string }; + readonly githubAuthenticated: boolean; + readonly oidcAvailable: boolean; + readonly processLike?: { readonly platform: string; readonly arch: string }; +}): Promise { + const platform = detectHostPlatform(input.processLike).platform; + const artifact = selectArtifact(input.manifest, platform); + // Manifest artifact hashes are authoritative. SHA256SUMS is published alongside + // for humans and GitHub; staging never trusts that sidecar over the manifest. + await verifyStagedArtifact({ + archivePath: input.archivePath, + expectedSha256: artifact.sha256, + expectedSize: artifact.size, + name: artifact.name, + }); + + const policy = provenancePolicy({ + githubAuthenticated: input.githubAuthenticated, + oidcAvailable: input.oidcAvailable, + }); + if (shouldVerifyAttestation(policy)) { + if (!input.commands.verifyAttestation) { + throw new Error( + "GitHub OIDC provenance is required but no attestation verifier is configured.", + ); + } + const result = await input.commands.verifyAttestation( + attestationVerifyArgs({ + artifactPath: input.archivePath, + owner: input.github.owner, + repo: input.github.repo, + }), + ); + if (result.code !== 0) { + throw new Error(`Provenance verification failed for ${artifact.name}.`); + } + } + + const installId = formatInstallId( + input.manifest.upstreamVersion, + input.manifest.contextivityRevision, + ); + const versionDir = versionDirFor(input.layout, installId); + NodeFS.mkdirSync(input.layout.versionsDir, { recursive: true }); + NodeFS.rmSync(versionDir, { recursive: true, force: true }); + NodeFS.mkdirSync(versionDir, { recursive: true }); + await input.commands.extractArchive(input.archivePath, versionDir); + writeDistributionMarker(versionDir, input.manifest); + atomicWriteFile( + NodePath.join(versionDir, MANIFEST_FILENAME), + `${JSON.stringify(input.manifest, null, 2)}\n`, + ); + atomicWriteFile( + NodePath.join(versionDir, CHECKSUMS_FILENAME), + `${artifact.sha256} ${artifact.name}\n`, + ); + + const preflight = await input.commands.preflight(versionDir); + if (preflight.code !== 0) { + NodeFS.rmSync(versionDir, { recursive: true, force: true }); + throw new Error( + `Staged preflight failed for ${installId}: ${preflight.stderr || preflight.stdout}`.trim(), + ); + } + if ( + containsUpstreamPackageFallback(preflight.stdout) || + containsUpstreamPackageFallback(preflight.stderr) + ) { + NodeFS.rmSync(versionDir, { recursive: true, force: true }); + throw new Error("Staged server advertised an upstream npm package update path."); + } + + return { installId, versionDir, manifest: input.manifest }; +} + +export function activateStaged(input: { + readonly layout: UpdaterLayout; + readonly installId: string; +}): { readonly current: string; readonly previous: string | null } { + const versionDir = versionDirFor(input.layout, input.installId); + if (!NodeFS.existsSync(versionDir)) { + throw new Error(`Cannot activate ${input.installId}: staged directory is missing.`); + } + const previous = currentInstallId(input.layout); + if (previous && previous !== input.installId) { + atomicSymlink(input.layout.previousLink, NodePath.join("versions", previous)); + } + atomicSymlink(input.layout.currentLink, NodePath.join("versions", input.installId)); + NodeFS.chmodSync(input.layout.root, 0o755); + return { current: input.installId, previous }; +} + +export function rollbackCurrent(layout: UpdaterLayout): { + readonly current: string; + readonly rolledBackFrom: string; +} { + const current = currentInstallId(layout); + const previous = previousInstallId(layout); + if (!current) { + throw new Error("Nothing to roll back: current is unset."); + } + if (!previous) { + throw new Error("Nothing to roll back: previous is unset."); + } + atomicSymlink(layout.currentLink, NodePath.join("versions", previous)); + atomicSymlink(layout.previousLink, NodePath.join("versions", current)); + return { current: previous, rolledBackFrom: current }; +} + +export async function activateAndVerify(input: { + readonly layout: UpdaterLayout; + readonly installId: string; + readonly commands: UpdaterCommands; +}): Promise<{ readonly current: string; readonly previous: string | null }> { + const switched = activateStaged(input); + try { + if (input.commands.restart) { + const restart = await input.commands.restart(); + if (restart.code !== 0) { + throw new Error( + `Restart after activate failed: ${restart.stderr || restart.stdout}`.trim(), + ); + } + } + if (input.commands.health) { + const health = await input.commands.health(); + if (health.code !== 0) { + throw new Error( + `Health check after activate failed: ${health.stderr || health.stdout}`.trim(), + ); + } + } + return switched; + } catch (error) { + if (switched.previous) { + rollbackCurrent(input.layout); + } + throw error; + } +} + +export function updaterStatus(layout: UpdaterLayout): { + readonly root: string; + readonly current: string | null; + readonly previous: string | null; +} { + return { + root: layout.root, + current: currentInstallId(layout), + previous: previousInstallId(layout), + }; +} + +export function readInstalledManifest(layout: UpdaterLayout): CandidateManifest | null { + const current = currentInstallId(layout); + if (!current) return null; + const text = readTextOrNull(NodePath.join(versionDirFor(layout, current), MANIFEST_FILENAME)); + return text ? decodeManifest(text) : null; +} diff --git a/contextivity/src/upstream.test.ts b/contextivity/src/upstream.test.ts new file mode 100644 index 0000000000000..74c7941cb86b0 --- /dev/null +++ b/contextivity/src/upstream.test.ts @@ -0,0 +1,87 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; +import { + discoverUpstreamNightlies, + findNamedUpstreamNightly, + lsRemoteArgs, + requireNamedUpstreamNightly, + requireNewestUpstreamNightly, + selectNewestNightly, + upstreamRemoteUrl, + validateTagIdentity, +} from "./upstream.ts"; + +const lsRemote = ` +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.0.33-nightly.20260820.4 +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v0.0.33-nightly.20260820.4^{} +cccccccccccccccccccccccccccccccccccccccc refs/tags/v0.0.34-nightly.20260822.2 +dddddddddddddddddddddddddddddddddddddddd refs/tags/v0.0.34 +eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee refs/tags/v0.0.34-nightly.20260822.1 +`; + +NodeTest.describe("upstream nightly selection", () => { + NodeTest.it( + "selects the newest official nightly tag, ignoring stable tags and peeled refs", + () => { + const nightlies = discoverUpstreamNightlies(lsRemote); + NodeAssert.equal(nightlies.length, 3); + const newest = selectNewestNightly(nightlies); + NodeAssert.equal(newest?.tag, "v0.0.34-nightly.20260822.2"); + NodeAssert.equal(newest?.commit, "c".repeat(40)); + NodeAssert.equal(requireNewestUpstreamNightly(lsRemote).tag, "v0.0.34-nightly.20260822.2"); + }, + ); + + NodeTest.it("does not track arbitrary newer main commits without a nightly tag", () => { + NodeAssert.equal( + discoverUpstreamNightlies("ffff\trefs/heads/main\n1111\trefs/tags/v0.0.34\n").length, + 0, + ); + NodeAssert.throws(() => requireNewestUpstreamNightly("")); + }); + + NodeTest.it("validates tag/version/commit identity", () => { + const expected = requireNewestUpstreamNightly(lsRemote); + validateTagIdentity({ + expected, + resolvedCommit: expected.commit, + packageVersion: expected.version, + }); + NodeAssert.throws(() => + validateTagIdentity({ + expected, + resolvedCommit: "0".repeat(40), + packageVersion: expected.version, + }), + ); + NodeAssert.throws(() => + validateTagIdentity({ + expected, + resolvedCommit: expected.commit, + packageVersion: "0.0.1", + }), + ); + }); + + NodeTest.it("looks up an exact requested nightly tag, including older than newest", () => { + const older = findNamedUpstreamNightly(lsRemote, "v0.0.33-nightly.20260820.4"); + NodeAssert.equal(older?.tag, "v0.0.33-nightly.20260820.4"); + NodeAssert.equal(older?.commit, "b".repeat(40)); + NodeAssert.equal( + requireNamedUpstreamNightly(lsRemote, "0.0.34-nightly.20260822.1").commit, + "e".repeat(40), + ); + NodeAssert.equal(findNamedUpstreamNightly(lsRemote, "v0.0.34"), null); + NodeAssert.throws(() => requireNamedUpstreamNightly(lsRemote, "v0.0.99-nightly.20260822.1")); + }); + + NodeTest.it("points ls-remote at the official upstream repository", () => { + NodeAssert.equal(upstreamRemoteUrl(), "https://github.com/pingdotgg/t3code.git"); + NodeAssert.deepEqual(lsRemoteArgs(upstreamRemoteUrl()), [ + "ls-remote", + "--tags", + "https://github.com/pingdotgg/t3code.git", + "refs/tags/v*-nightly.*", + ]); + }); +}); diff --git a/contextivity/src/upstream.ts b/contextivity/src/upstream.ts new file mode 100644 index 0000000000000..c4db35eb963c5 --- /dev/null +++ b/contextivity/src/upstream.ts @@ -0,0 +1,141 @@ +import { DEFAULT_GITHUB_BASE, UPSTREAM_GITHUB } from "./config.ts"; +import { compareNightlyTags, parseNightlyTag } from "./identity.ts"; + +export interface UpstreamNightly { + readonly tag: string; + readonly version: string; + readonly date: string; + readonly runNumber: number; + readonly commit: string; +} + +export interface LsRemoteEntry { + readonly sha: string; + readonly ref: string; +} + +export function parseLsRemoteLine(line: string): LsRemoteEntry | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + const [sha, ref] = trimmed.split(/\s+/u); + if (!sha || !ref || !/^[0-9a-f]{7,40}$/iu.test(sha)) return null; + if (ref.endsWith("^{}")) return null; + return { sha, ref }; +} + +export function nightlyFromRemoteRef(entry: LsRemoteEntry): UpstreamNightly | null { + const prefix = "refs/tags/"; + if (!entry.ref.startsWith(prefix)) return null; + const parsed = parseNightlyTag(entry.ref.slice(prefix.length)); + if (!parsed) return null; + return { + tag: parsed.tag, + version: parsed.version, + date: parsed.date, + runNumber: parsed.runNumber, + commit: entry.sha.toLowerCase(), + }; +} + +export function selectNewestNightly( + candidates: readonly UpstreamNightly[], +): UpstreamNightly | null { + if (candidates.length === 0) return null; + return [...candidates].sort((left, right) => compareNightlyTags(left, right)).at(-1) ?? null; +} + +export function discoverUpstreamNightlies(lsRemoteOutput: string): UpstreamNightly[] { + const peeled = new Map(); + const tags = new Map(); + for (const rawLine of lsRemoteOutput.split(/\r?\n/u)) { + const trimmed = rawLine.trim(); + if (trimmed.length === 0) continue; + const [sha, ref] = trimmed.split(/\s+/u); + if (!sha || !ref || !/^[0-9a-f]{7,40}$/iu.test(sha)) continue; + if (ref.endsWith("^{}")) { + peeled.set(ref.slice(0, -3), sha.toLowerCase()); + continue; + } + const entry = parseLsRemoteLine(trimmed); + if (!entry) continue; + const nightly = nightlyFromRemoteRef(entry); + if (nightly) tags.set(nightly.tag, entry); + } + const nightlies: UpstreamNightly[] = []; + for (const entry of tags.values()) { + const nightly = nightlyFromRemoteRef({ + sha: peeled.get(entry.ref) ?? entry.sha, + ref: entry.ref, + }); + if (nightly) nightlies.push(nightly); + } + return nightlies; +} + +export function requireNewestUpstreamNightly(lsRemoteOutput: string): UpstreamNightly { + const newest = selectNewestNightly(discoverUpstreamNightlies(lsRemoteOutput)); + if (!newest) { + throw new Error( + `No official nightly tags matching vX.Y.Z-nightly.YYYYMMDD.N were found on ${UPSTREAM_GITHUB.owner}/${UPSTREAM_GITHUB.repo}.`, + ); + } + return newest; +} + +export function upstreamRemoteUrl(base: string = DEFAULT_GITHUB_BASE): string { + return `${base.replace(/\/$/u, "")}/${UPSTREAM_GITHUB.owner}/${UPSTREAM_GITHUB.repo}.git`; +} + +export function lsRemoteArgs(remoteUrl: string): readonly string[] { + return ["ls-remote", "--tags", remoteUrl, "refs/tags/v*-nightly.*"]; +} + +export function validateTagIdentity(input: { + readonly expected: UpstreamNightly; + readonly resolvedCommit: string; + readonly packageVersion: string; + readonly isAncestorOfHead?: boolean; +}): void { + if (input.resolvedCommit.toLowerCase() !== input.expected.commit.toLowerCase()) { + throw new Error( + `Tag ${input.expected.tag} resolved to ${input.resolvedCommit}, expected ${input.expected.commit}.`, + ); + } + if (input.packageVersion !== input.expected.version) { + throw new Error( + `Tag ${input.expected.tag} commit has package version '${input.packageVersion}', expected '${input.expected.version}'.`, + ); + } + if (input.isAncestorOfHead === false) { + throw new Error( + `Tag ${input.expected.tag} (${input.expected.commit}) is not an ancestor of the fetched tag commit. Refusing to guess ancestry.`, + ); + } +} + +export function findNamedUpstreamNightly( + lsRemoteOutput: string, + requested: string, +): UpstreamNightly | null { + const tag = requested.startsWith("v") ? requested : `v${requested}`; + const parsed = parseNightlyTag(tag); + if (!parsed) return null; + return discoverUpstreamNightlies(lsRemoteOutput).find((item) => item.tag === parsed.tag) ?? null; +} + +export function requireNamedUpstreamNightly( + lsRemoteOutput: string, + requested: string, +): UpstreamNightly { + const tag = requested.startsWith("v") ? requested : `v${requested}`; + if (!parseNightlyTag(tag)) { + throw new Error( + `Requested tag '${requested}' is not an official T3 nightly tag (vX.Y.Z-nightly.YYYYMMDD.N).`, + ); + } + const match = findNamedUpstreamNightly(lsRemoteOutput, requested); + if (!match) { + throw new Error(`Requested nightly tag ${tag} was not found on upstream.`); + } + return match; +} diff --git a/contextivity/src/workflow-validate.ts b/contextivity/src/workflow-validate.ts new file mode 100644 index 0000000000000..ecabbe0d8c559 --- /dev/null +++ b/contextivity/src/workflow-validate.ts @@ -0,0 +1,94 @@ +import { containsUpstreamPackageFallback } from "./update-metadata.ts"; + +export interface WorkflowValidation { + readonly path: string; + readonly ok: boolean; + readonly errors: readonly string[]; +} + +const BASHISMS = [ + { id: "double-bracket", pattern: /\[\[/u }, + { id: "source-builtin", pattern: /\bsource\s+/u }, + { id: "function-keyword", pattern: /\bfunction\s+\w+/u }, + { id: "declare-array", pattern: /\bdeclare\s+-a\b/u }, +]; + +export function validatePosixShell(script: string, label: string): string[] { + const errors: string[] = []; + if (!script.startsWith("#!/usr/bin/env sh") && !script.startsWith("#!/bin/sh")) { + errors.push(`${label} must use a POSIX sh shebang.`); + } + for (const bashism of BASHISMS) { + if (bashism.pattern.test(script)) { + errors.push(`${label} contains bashism ${bashism.id}.`); + } + } + return errors; +} + +export function validateWorkflowYaml(path: string, yaml: string): WorkflowValidation { + const errors: string[] = []; + const lines = yaml.split(/\r?\n/u); + if (lines.length > 280) { + errors.push( + `${path} has ${lines.length} lines. Keep workflow YAML thin and put logic in contextivity/src.`, + ); + } + if ( + !yaml.includes("node contextivity/src/cli.ts") && + !yaml.includes("node --experimental-strip-types contextivity/src/cli.ts") + ) { + errors.push(`${path} must invoke contextivity/src/cli.ts rather than inlining the pipeline.`); + } + if ( + containsUpstreamPackageFallback(yaml) || + /npm publish/u.test(yaml) || + /vp pm publish/u.test(yaml) + ) { + errors.push(`${path} must not publish or install upstream package t3@.`); + } + if (path.includes("candidate") && !yaml.includes("attest-build-provenance")) { + errors.push(`${path} must request GitHub OIDC artifact attestations.`); + } + if (path.includes("candidate") && !yaml.includes("id-token: write")) { + errors.push(`${path} needs id-token: write for Sigstore/GitHub OIDC attestations.`); + } + if (path.includes("candidate") && !yaml.includes("attestations: write")) { + errors.push(`${path} needs attestations: write.`); + } + if ( + path.includes("promote") && + !yaml.includes("mac-client-version") && + !yaml.includes("macClientVersion") + ) { + errors.push(`${path} must require an exact official Mac client version.`); + } + if (path.includes("candidate")) { + if ( + !yaml.includes("candidate-test-plan") && + !yaml.includes("ContextivityAcpExtension.test.ts") + ) { + errors.push(`${path} must run the generic ACP plus sub-agent focused tests.`); + } + if (!yaml.includes("report-sync-failure")) { + errors.push(`${path} must report fail-closed sync failures.`); + } + if (!yaml.includes("sync-tag")) { + errors.push(`${path} must sync the exact upstream nightly tag.`); + } + if (!yaml.includes("publish-candidate") && !yaml.includes("gh release create")) { + errors.push(`${path} must publish an immutable GitHub candidate release.`); + } + if (!yaml.includes("contextivity-sync/") && !yaml.includes("git push")) { + errors.push(`${path} must persist the synced merge commit for later jobs.`); + } + } + return { path, ok: errors.length === 0, errors }; +} + +export function expectedWorkflowPaths(): readonly string[] { + return [ + ".github/workflows/contextivity-candidate.yml", + ".github/workflows/contextivity-promote.yml", + ]; +} diff --git a/contextivity/src/write-candidate-manifest.ts b/contextivity/src/write-candidate-manifest.ts new file mode 100644 index 0000000000000..5335223674f24 --- /dev/null +++ b/contextivity/src/write-candidate-manifest.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { flagString, parseArgs } from "./args.ts"; +import { isArtifactPlatform, type ArtifactPlatform } from "./config.ts"; +import { formatChecksumFile, sha256File } from "./hash.ts"; +import { parseNightlyTag } from "./identity.ts"; +import { buildCandidateManifest, encodeManifest, type ManifestArtifact } from "./manifest.ts"; + +function requireValue(flags: ReturnType["flags"], name: string): string { + const value = flagString(flags, name); + if (!value) throw new Error(`Missing --${name}`); + return value; +} + +export function platformFromName(name: string): ArtifactPlatform { + for (const platform of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] as const) { + if (name.includes(platform) && isArtifactPlatform(platform)) return platform; + } + throw new Error(`Cannot infer platform from artifact name '${name}'.`); +} + +export async function writeCandidateManifestFromDir(input: { + readonly tag: string; + readonly sha: string; + readonly dir: string; + readonly buildRevision: string; + readonly nodeEngine: string; + readonly contextivityRevision: string; + readonly createdAt?: string; +}): Promise { + const parsed = parseNightlyTag(input.tag.startsWith("v") ? input.tag : `v${input.tag}`); + if (!parsed) { + throw new Error(`Not an official nightly tag: ${input.tag}`); + } + const artifacts: ManifestArtifact[] = []; + for (const name of NodeFS.readdirSync(input.dir).sort()) { + if (!name.endsWith(".tar.gz")) continue; + const filePath = NodePath.join(input.dir, name); + artifacts.push({ + platform: platformFromName(name), + name, + size: NodeFS.statSync(filePath).size, + sha256: await sha256File(filePath), + }); + } + const manifest = buildCandidateManifest({ + upstreamVersion: parsed.version, + upstreamTag: parsed.tag, + upstreamCommit: input.sha, + contextivityRevision: input.contextivityRevision, + buildRevision: input.buildRevision, + nodeEngine: input.nodeEngine, + createdAt: input.createdAt ?? new Date().toISOString(), + artifacts, + }); + const encoded = encodeManifest(manifest); + NodeFS.writeFileSync(NodePath.join(input.dir, "manifest.json"), encoded); + NodeFS.writeFileSync( + NodePath.join(input.dir, "SHA256SUMS"), + formatChecksumFile(manifest.artifacts), + ); + return encoded; +} + +const invoked = + process.argv[1] !== undefined && + NodeURL.fileURLToPath(import.meta.url) === NodePath.resolve(process.argv[1]); +if (invoked) { + const parsed = parseArgs(process.argv.slice(2)); + writeCandidateManifestFromDir({ + tag: requireValue(parsed.flags, "tag"), + sha: requireValue(parsed.flags, "sha"), + dir: requireValue(parsed.flags, "dir"), + buildRevision: requireValue(parsed.flags, "build-revision"), + nodeEngine: requireValue(parsed.flags, "node-engine"), + contextivityRevision: requireValue(parsed.flags, "contextivity-revision"), + }).then( + (encoded) => { + process.stdout.write(encoded); + }, + (error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }, + ); +} diff --git a/docs/README.md b/docs/README.md index 622d810643871..4f2b6a6fb9390 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [ACP Registry](./user/providers-acp-registry.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) @@ -41,6 +41,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A ### Runbooks - [Release](./operations/release.md) +- [Contextivity downstream](./operations/contextivity-downstream.md) - [Observability](./operations/observability.md) - [Relay observability](./operations/relay-observability.md) - [Mobile app store screenshots](./operations/mobile-app-store-screenshots.md) diff --git a/docs/internals/acp-subagent-events.md b/docs/internals/acp-subagent-events.md new file mode 100644 index 0000000000000..c69e46af165a6 --- /dev/null +++ b/docs/internals/acp-subagent-events.md @@ -0,0 +1,92 @@ +# ACP sub-agent events + +T3 Code's ACP Registry client can ingest a namespaced ACP extension that carries +typed sub-agent lifecycle. Any ACP agent may implement it. Contextivity is the +first producer (`contextivity-agent --mode acp`). + +Do not infer child identity or status from tool names or thought text. Standard +ACP `session/update` traffic stays unchanged for agents that omit this +extension. + +## Capability negotiation + +On `initialize`, T3 always advertises: + +```json +{ + "clientCapabilities": { + "_meta": { + "contextivity": { + "subagentEvents": { "version": 1 } + } + } + } +} +``` + +T3 ingests events only when the agent advertises the matching version on +`agentCapabilities._meta`. A missing `_meta`, a different version, or events +sent without advertisement are ignored. The primary turn continues. + +## Notification + +JSON-RPC method: `_contextivity/subagent_event`. + +| Field | Meaning | +| ----------- | ----------------------------------------------------------------------------------- | +| `version` | Always `1` | +| `sessionId` | ACP session id for this connection | +| `sequence` | Per-session integer starting at `1`; duplicates and out-of-order deltas are ignored | +| `kind` | `snapshot` (reconcile roster) or `delta` (one change) | +| `change` | Required on deltas only: `started`, `updated`, or `terminal` | +| `records` | Array of bounded child records (normally one record for a delta) | + +This is the committed producer v1 wire. Draft aliases (`kind: started|progress|status|completed`, +`subagent` / `subagents`) are rejected. + +Emit a `snapshot` after `session/new`, `session/load`, and reconnect. Parents +before children. `change: "terminal"` once per `agentId`+`runtimeEpoch`. A later +summary patch is `updated`. A new `runtimeEpoch` is a fresh start, not a +resurrection. Identities longer than the bound are dropped, not truncated. + +## Record fields + +Authoritative, bounded, sanitized. Usage is omitted in v1; T3 does not require +or invent per-child token counts. + +| Field | Notes | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `agentId` | Stable child id. T3 uses this as `taskId`, never as `TaskAgentLinkage.agentId` | +| `parentAgentId` | Direct parent when nested | +| `displayName`, `typeName`, `role` | Title / role. `role` may mirror `typeName` | +| `lineage` | Root-first id path including self | +| `spawnDepth` | `0` = direct child of the orchestrator | +| `taskId`, `taskSubject`, `description` | Subject/preview only; never the full prompt | +| `state` | `spawned`, `running`, `waiting_input`, `waiting_approval`, `idle`, `resetting`, `completed`, `failed`, `cancelled` | +| `modelId` | Compact model id when known | +| `latestActivity`, `lastToolName`, `recentActivity` | Bounded recent assistant/tool strings | +| `terminal` | `kind`, `endedAt`, optional bounded `summary` / `error` / `reportStatus` | +| `runtimeEpoch`, `startedAt`, `completedAt`, `updatedAt` | Timing / identity. `updatedAt` is required | + +Hidden reasoning, secrets, env/auth, cwd, session files, process ids, and +unbounded transcripts must not be included. A malformed envelope, including any +unparseable record, is ignored. The primary turn must not fail. + +## T3 mapping + +Ingestion lives on ACP Registry only. Cursor and Grok ignore the extension +notification. + +| Producer | Canonical event | +| ---------------------------- | -------------------------------------------------------- | +| First observation of a child | `task.started` | +| Activity | `task.progress` | +| Non-terminal `state` | `task.updated` (`pending`, `running`, `waiting`, `idle`) | +| `completed` / `failed` | `task.completed` with that status | +| `cancelled` | `task.updated` cancelled, then `task.completed` stopped | + +Every mapped event sets `taskType: "subagent"` and `timelineBypass: true` so +`ProviderRuntimeIngestion` stamps `agentKind=agent` and the existing +`subagentRuntime` fold / Agents panel can render it. Child identity is `taskId`. +Snapshots cancel known non-terminal children that disappeared. Events are +processed even when no turn is active (session load / snapshot). diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f7..122430448029b 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi - [Orchestration](#orchestration) - [Provider runtime](#provider-runtime) - [Checkpointing](#checkpointing) +- [Contextivity downstream](#contextivity-downstream) ## Concepts @@ -94,7 +95,11 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and ACP Registry. ACP Registry is a generic ACP executable adapter (for example Contextivity) and is not synthesized from the legacy providers blob. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. + +#### ACP sub-agent events + +A namespaced ACP extension (`_contextivity/subagent_event`) that any ACP agent may advertise via `_meta.contextivity.subagentEvents.version = 1`. T3's ACP Registry client advertises the same capability and, when the agent matches, maps bounded child records onto canonical `task.*` events for the Agents panel. See [acp-subagent-events.md][25] and [AcpRegistryAdapter.ts][26]. #### Session @@ -140,6 +145,18 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. +### Contextivity downstream + +Fork-only distribution on `Contextivity/t3code`. It tracks official `pingdotgg/t3code` nightly tags and publishes self-contained server archives. See [contextivity-downstream.md][27]. + +#### Dual identity + +`upstreamVersion` is the official T3 nightly and is the protocol-visible `serverVersion`. `contextivityRevision` is the Contextivity patch SHA used for install paths and rollback. Official clients must keep seeing the upstream nightly. + +#### Candidate / nightly / stable pointers + +A candidate is an immutable attested build. The `nightly` fleet pointer moves only after a manual promote that confirms the official Mac desktop version. `stable` is the known-good rollback pointer. Hosts install with `t3-ctx`, never `npx t3@`. + ## Practical Shortcuts - If you see `requested`, think "intent recorded". @@ -179,3 +196,6 @@ The file patch and changed-file summary for one turn. It is usually computed in [22]: ../../apps/server/src/checkpointing/Utils.ts [23]: ../../apps/server/src/checkpointing/Diffs.ts [24]: ./overview.md +[25]: ./acp-subagent-events.md +[26]: ../../apps/server/src/provider/Layers/AcpRegistryAdapter.ts +[27]: ../operations/contextivity-downstream.md diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d08..83d19f9f627b1 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ OpenCode, and configurable ACP Registry │ └────────────────────────────────────────────────┘ ``` @@ -106,11 +106,12 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a -scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves -an instance to its adapter, so `ProviderService` routes session and turn operations without knowing -which agent is behind them. See [providers.md](./providers.md). +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, OpenCode, and ACP Registry. A driver declares its kind and config schema +and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and +`ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session +and turn operations without knowing which agent is behind them. ACP Registry is instance-only (no +legacy `providers` blob) and speaks standards ACP only. See [providers.md](./providers.md). ## Checkpointing diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03ded..cfbdef92d78d3 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: -| Driver kind | Driver source | -| ------------- | --------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| Driver kind | Driver source | +| ------------- | --------------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `acpRegistry` | [`Drivers/AcpRegistryDriver.ts`][acpregistry] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -39,6 +40,26 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +### ACP Registry + +`acpRegistry` is a standards-only ACP driver for user-configured executables (Contextivity: +`contextivity-agent --mode acp`). It reuses [`AcpSessionRuntime.ts`][acpruntime] and does not speak +Grok's x.ai extensions. Settings live on the instance (`binaryPath`, `launchArgs`, `authMethodId`, +`attachMcpWhenSupported`); there is no legacy `providers.acpRegistry` blob, so hydration does not +synthesize a dummy instance. + +Health is ACP initialize plus session create. `--version` is optional and is not the sole failure +reason. MCP is attached only when the switch is on and the initialized agent advertises HTTP MCP +(`mcpPolicy: "auto"`; `"never"` when the switch is off). Grok and Cursor keep `mcpPolicy: "always"`. + +ACP tool `_meta` is preserved on canonical tool data so namespaced subagent metadata stays +observable as tool activity. When the agent also advertises +`_meta.contextivity.subagentEvents.version = 1`, ACP Registry maps +`_contextivity/subagent_event` onto canonical `task.*` events for the Agents panel +(see [acp-subagent-events.md](./acp-subagent-events.md)). Agents without that capability +keep tool-activity-only behavior. ACP `available_commands_update` has no command-palette +surface. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method @@ -81,6 +102,8 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[acpregistry]: ../../apps/server/src/provider/Drivers/AcpRegistryDriver.ts +[acpruntime]: ../../apps/server/src/provider/acp/AcpSessionRuntime.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/internals/workspace-layout.md b/docs/internals/workspace-layout.md index e933e45289155..7e30ec2140907 100644 --- a/docs/internals/workspace-layout.md +++ b/docs/internals/workspace-layout.md @@ -53,6 +53,8 @@ the task commands. - `oxlint-plugin-t3code/`: repo-specific lint rules. - `experiments/`: throwaway prototypes. Not part of the shipped build. - `docs/`: this documentation tree. +- `contextivity/`: Contextivity fork-only nightly distribution (candidate builds, updater, fleet). + Not part of the generic ACP provider. See [contextivity-downstream.md](../operations/contextivity-downstream.md). ## Import conventions diff --git a/docs/operations/contextivity-downstream.md b/docs/operations/contextivity-downstream.md new file mode 100644 index 0000000000000..14df5fb45e7bd --- /dev/null +++ b/docs/operations/contextivity-downstream.md @@ -0,0 +1,207 @@ +# Contextivity downstream T3 distribution + +> Fork-only operations for `Contextivity/t3code`. Official T3 releases stay on `pingdotgg/t3code`. + +This is a long-term maintained downstream, not an upstream PR. The generic ACP/sub-agent patch +stack lives in the T3 server/provider tree. Everything that builds, publishes, or installs the +forked server lives under `contextivity/` plus two GitHub workflows. Keep it that way so an +upstreamable ACP review does not have to read fleet machinery. + +## What this is + +The Mac desktop app stays the **official** T3 nightly. The background server on the fleet is the +**forked** server: exact official nightly source plus the Contextivity patch stack. + +- Protocol-visible `serverVersion` is the official upstream nightly (from `apps/server/package.json`). +- `contextivityRevision` is a separate git SHA recorded on disk and in the candidate manifest. +- Official clients that match that nightly stay compatible. They must not be told they are running a + different T3 version. +- The internal updater is `t3-ctx` / `t3-ctx fleet`. Never `npx t3@` and never npm + `t3@`. + +## Initial fork setup + +1. The organization fork is `https://github.com/Contextivity/t3code`. +2. Default branch permanently contains the small Contextivity patch stack (generic ACP Registry, + sub-agent events, this distribution tree, and the npm self-update suppression). +3. Add git remotes: + + ```sh + git remote add origin https://github.com/pingdotgg/t3code.git + git remote add contextivity https://github.com/Contextivity/t3code.git + ``` + +4. Automation tracks official `pingdotgg/t3code` **nightly release tags** + (`vX.Y.Z-nightly.YYYYMMDD.N`), not arbitrary newer `main` commits. + +## GitHub permissions, secrets, and OIDC + +Required on `Contextivity/t3code`: + +- Workflow permissions: `contents: write`, `issues: write`, `id-token: write`, `attestations: write`. +- No private signing key. Candidate provenance is GitHub artifact attestations via OIDC + (`actions/attest-build-provenance`). +- Optional: `CONTEXTIVITY_GITHUB_TOKEN` for hosts that cannot use `gh`. Do not persist or log it. +- Mirror/base URL overrides (`CONTEXTIVITY_T3_GITHUB_API`, `CONTEXTIVITY_T3_GITHUB_BASE`) are + refused unless `CONTEXTIVITY_T3_TRUST_MIRROR=1` is set. That is an explicit trust policy, not a + default. + +Host updater auth is an explicit token or an already-authenticated `gh` CLI. Credentials never go +into manifests, inventory files, or logs. + +## Branch and release model + +1. For each new official nightly tag, the candidate workflow fetches that **exact** tag and merges + it into the downstream branch, preserving Contextivity commits. +2. If the merge conflicts or focused tests fail, the workflow fails closed: no candidate publish, no + fleet pointer movement, prior version stays active. It opens a deduplicated issue titled + `Contextivity T3 nightly sync failed: ` with label `contextivity-maintenance`. +3. Successful runs produce an immutable **candidate** (archives + manifest + SHA-256 + attestations). + They do **not** move `nightly` or `stable`. +4. `nightly` advances only from the manual promote workflow after artifacts/tests passed and the + installed official Mac desktop version is confirmed equal to `upstreamVersion`. +5. `stable` is a known-good pointer for rollback, promoted the same way. + +Candidate tag: `contextivity-candidate/-ctx.` +Pointers: GitHub releases `contextivity-nightly` and `contextivity-stable` (pointer JSON only). + +## Candidate flow + +Scheduled every three hours (offset from upstream) or `workflow_dispatch` with an exact tag. + +`.github/workflows/contextivity-candidate.yml` is a thin wrapper. Logic lives in +`contextivity/src/`: + +1. `discover-upstream` — newest official nightly tag/commit. +2. `sync-tag` — exact-tag merge, version/commit validation, fail-closed on conflict. +3. Downstream unit tests plus generic ACP/sub-agent tests, server typecheck, server build. +4. Per-platform archives: Linux x64/arm64, macOS x64/arm64. Native addons (`node-pty`, + `msgpackr-extract`, and the rest of the CLI external closure) are platform-specific, so the + artifact is not platform-neutral. +5. `write-candidate-manifest` — schema version, upstream version/tag/commit, Contextivity revision, + build revision, Node engine, artifact name/size/SHA-256, created time, compatibility. Construction + itself requires exactly the four server platforms (Linux x64/arm64 and macOS x64/arm64) and + rejects missing, duplicate, or unsupported artifacts. +6. GitHub OIDC attestations. Checksums are sorted SHA256SUMS files. SHA256SUMS is a published + sidecar; the updater verifies archives against the candidate **manifest** hashes. +7. Immutable GitHub release `contextivity-candidate/-ctx.`. Re-runs must + not overwrite that tag. The merge commit is pushed to `contextivity-sync/` so later jobs + can check it out, then the default branch fast-forwards only after the candidate exists. + +The candidate does not publish npm package `t3`. Hosts install with `t3-ctx updater update`. + +## Manual promotion + +`.github/workflows/contextivity-promote.yml`: + +- Inputs: `channel` (`nightly` or `stable`), exact candidate identity, official Mac client version. +- Fails closed if the Mac version ≠ manifest `upstreamVersion`. +- Does not overwrite or disable the official desktop app or its updater. +- Writes the channel pointer only. + +Local equivalent: + +```sh +t3-ctx promote --channel nightly --manifest manifest.json --mac-client-version "$VERSION" +``` + +## Three-host inventory + +Copy `contextivity/inventory.example.json`. Use SSH **config aliases** and one `via: "local"` host. +Do not put hostnames, usernames, home paths, or tokens in the file. + +Typical mapping (configure aliases yourself): workstation (Mac, official desktop + forked server), +builder (second Mac), lab (Linux). + +```sh +t3-ctx fleet update --inventory ~/.config/contextivity/t3-inventory.json \ + --manifest manifest.json --mac-client-version "$VERSION" +``` + +The coordinator resolves **one** manifest, stages every host, then activates. Activation runs each +host's configured `restartCommand` and `healthCommand` (bounded) after the symlink switch. Fleet +sends those strings as base64url argv tokens (`--restart-command-b64`, `--health-command-b64`) so +SSH concatenation cannot split on spaces or metacharacters; the remote `t3-ctx` decodes the original +string and runs it with `sh -c`. If staging fails, none activate. If activate, restart, or health +fails, every host that switched is rolled back. Direct `t3-ctx fleet` fails closed without a +verified official Mac desktop version (`--mac-client-version` or +`CONTEXTIVITY_T3_MAC_CLIENT_VERSION`) that exactly matches `manifest.upstreamVersion`. Inventory +must name exactly one `clientGate` host — the Mac that holds the official desktop app. + +## Host updater + +Layout: + +``` +~/.contextivity/t3/ + versions/-ctx./ + current -> versions/... + previous -> versions/... +``` + +Override root with `CONTEXTIVITY_T3_HOME`. + +```sh +t3-ctx updater status +t3-ctx updater check --channel nightly +t3-ctx updater update --version 0.0.34-nightly.20260822.2-ctx.abc1234 --stage-only +t3-ctx updater update --channel nightly +t3-ctx updater stage --manifest manifest.json --archive t3-server-linux-x64.tar.gz +t3-ctx updater activate --version 0.0.34-nightly.20260822.2-ctx.abc1234 +t3-ctx updater rollback +``` + +Each host authenticates to GitHub with `CONTEXTIVITY_GITHUB_TOKEN` or an already-logged-in `gh`. +`update` downloads the exact candidate (or nightly/stable pointer, then that candidate), verifies +SHA-256 against the **manifest** (not SHA256SUMS), and verifies GitHub attestations when +authenticated. `t3-ctx updater activate --restart-command … --health-command …` runs those host +commands after cutover and rolls the local current symlink back if either fails. Preflight is bounded and must not +advertise an upstream npm install. Cutover is an atomic symlink rename. `--stage-only` stops before +cutover so the fleet coordinator can activate every host together. + +The staged tree sets `CONTEXTIVITY_T3_DISTRIBUTION=1`, which makes the server omit +`serverSelfUpdate` and refuse npm `t3@` installs. Same-version official clients keep +working. Unknown downstream metadata is local/optional. + +## Conflict recovery + +1. Read the `contextivity-maintenance` issue for the exact tag. +2. Reproduce: `t3-ctx sync-tag --tag vX.Y.Z-nightly.YYYYMMDD.N`. +3. Resolve the patch stack against that tag only. Do not fast-forward past a newer untagged main. +4. Re-run the candidate workflow with the same tag. +5. Promote only after the Mac desktop version matches. + +## Exact-version diagnostics + +```sh +t3-ctx updater status +git -C ~/.contextivity/t3/current rev-parse --short=7 HEAD # if a checkout exists +python3 -c 'import json,pathlib; print(json.loads(pathlib.Path.home().joinpath(".contextivity/t3/current/contextivity-distribution.json").read_text()))' +``` + +`protocolVersion` / `upstreamVersion` must equal the official client. `installId` includes +`-ctx.` for rollback. + +## Official Mac client sync + +Promotion and fleet gates compare the installed/declared official Mac app version with +`manifest.upstreamVersion`. Set `CONTEXTIVITY_T3_MAC_CLIENT_VERSION` or pass +`--mac-client-version`. On macOS, `defaults read "/Applications/T3 Code.app/Contents/Info" +CFBundleShortVersionString` is the usual reader. A mismatch fails closed. This pipeline never +writes into `/Applications/T3 Code.app`. + +## Contextivity agent versions are independent + +Stable/nightly **Contextivity agent** releases (the ACP executable, for example +`contextivity-agent --mode acp`) are versioned in the Contextivity agent repo. They are not this +T3 downstream and must not share these pointers, archives, or updater layout. T3's ACP Registry +settings point at whatever agent binary the host already has. + +## Commands + +```sh +node --experimental-strip-types contextivity/src/cli.ts --help +node --test --experimental-strip-types contextivity/src/*.test.ts +``` + +`contextivity/bin/t3-ctx` is a POSIX wrapper around that CLI. diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3f..b080103fa7429 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -65,6 +65,10 @@ to use, then authenticate it. Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to use them. +ACP Registry is a generic adapter for Agent Client Protocol CLIs such as Contextivity. It is not +installed by default. Add it from **Settings** → **Add provider**. See +[ACP Registry](./providers-acp-registry.md). + Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/docs/user/providers-acp-registry.md b/docs/user/providers-acp-registry.md new file mode 100644 index 0000000000000..a30b9d00988ec --- /dev/null +++ b/docs/user/providers-acp-registry.md @@ -0,0 +1,79 @@ +# ACP Registry + +ACP Registry is a generic provider for Agent Client Protocol (ACP) agents. Use it when you have an +ACP-speaking CLI that is not one of the built-in providers (Codex, Claude, Cursor, Grok, OpenCode). +The first supported example is Contextivity. + +ACP Registry is off until you add an instance. T3 Code does not create a default one. + +For first-time T3 Code setup, see [Install T3 Code](./install.md). + +## Add An Instance + +In **Settings** → **Add provider**, choose **ACP Registry**. Give it a display name, then set: + +```text +Binary path: contextivity-agent +Launch arguments: --mode acp +Auth method ID: empty unless the agent requires a specific authenticate method +Attach T3 MCP when supported: on +``` + +The binary must be on the T3 Code server's `PATH`, or use an absolute path. Launch arguments are a +normal CLI string; T3 Code tokenizes them before spawning the agent. + +For Contextivity, the launch command is: + +```bash +contextivity-agent --mode acp +``` + +Set **Binary path** to `contextivity-agent` (or the full path) and **Launch arguments** to +`--mode acp`. + +## Auth + +Leave **Auth method ID** empty in the usual case. T3 Code then uses the first method the agent +advertises during ACP initialize. If the agent advertises none, T3 Code skips authenticate. + +Set the field only when the agent advertises several methods and you need a specific one. + +Run any login the agent needs on the machine that runs the T3 Code server, not on the device you +browse from. + +## Models And Config + +ACP Registry discovers models from the live ACP session. You can switch models on the current +thread; a new thread is not required. + +Config options the agent advertises (modes, thinking, and similar session settings) are applied on +the live session when T3 Code has a matching UI control. + +## MCP + +T3 Code can offer its HTTP MCP server to an ACP agent. ACP Registry only sends that descriptor when +**Attach T3 MCP when supported** is on _and_ the initialized agent advertises compatible HTTP MCP. + +Contextivity currently does not advertise ACP MCP and rejects a non-empty MCP server list. Leave the +switch on: T3 Code still omits MCP for that agent. Turn the switch off if you never want T3 MCP sent +to this instance. + +## What You See In Chat + +Standard ACP session traffic shows up in the thread: assistant text, reasoning, plans, tool calls, +permissions, image attachments, and cancel. Namespaced metadata on tool calls is kept on the tool +payload so the activity stays visible as an ACP tool. + +## Limitations + +- The native **Agents** panel shows ACP sub-agents when the agent advertises the shared + sub-agent events capability (version 1). Agents that do not advertise it still appear only as + ACP tool activity in the thread. +- ACP `available_commands` updates have no T3 command palette or slash-command surface. Commands + the agent advertises stay on the agent side. +- ACP Registry does not implement Grok's private x.ai extensions. Use the Grok provider for Grok. + +## More Than One Agent + +Add another ACP Registry instance for a second executable or a different argument set. Each instance +keeps its own binary path, launch arguments, auth method, and MCP switch. diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ff0aea7c8a518..8bc8eb81da64f 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -612,6 +612,38 @@ describe("model and effort attribution", () => { }); describe("background task exclusion", () => { + it("ACP Registry child records with taskType subagent join the Agents panel", () => { + const agents = fold([ + activity("task.started", { + taskId: "child-1", + taskType: "subagent", + title: "Coordinator", + role: "coordinator", + timelineBypass: true, + }), + activity("task.started", { + taskId: "child-2", + taskType: "subagent", + title: "Worker", + role: "worker", + parentAgentId: "child-1", + timelineBypass: true, + }), + activity("task.updated", { + taskId: "child-2", + taskType: "subagent", + status: "waiting", + timelineBypass: true, + }), + ]); + expect(agents.map((agent) => agent.id)).toEqual(["child-1", "child-2"]); + expect(agents.find((agent) => agent.id === "child-2")?.parentAgentId).toBe("child-1"); + expect(agents.find((agent) => agent.id === "child-2")?.status).toBe("waiting"); + const panel = deriveAgentPanelModel({ agents }); + expect(panel.hasAgents).toBe(true); + expect(panel.directAgents.map((agent) => agent.id)).toEqual(["child-1", "child-2"]); + }); + it("shells and monitors never join the roster (from any lifecycle row)", () => { const agents = fold([ activity("task.started", { taskId: "shell-1", taskType: "shell", title: "Run 12s stall" }), diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd62..082753c3994ae 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -222,4 +222,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ProviderDriverKind.make("acpRegistry")]: "ACP Registry", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14b..10bcd3c7e0afb 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -187,24 +187,31 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect("acpRegistry" in decoded.providers).toBe(false); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("acpRegistry"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); it("resolves instance enabled state with explicit false winning", () => { const grok = ProviderDriverKind.make("grok"); + const acpRegistry = ProviderDriverKind.make("acpRegistry"); const codex = ProviderDriverKind.make("codex"); // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); + expect(resolveProviderInstanceEnabled({ driver: acpRegistry, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); + expect(resolveProviderInstanceEnabled({ driver: acpRegistry, enabled: true, config: {} })).toBe( + true, + ); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( false, ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d2496..1d0e375c21e9a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -467,6 +467,68 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + // Opt-in only. ACP Registry is never synthesized from the legacy + // `providers` blob, so an empty install does not probe a dummy binary. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Binary path", + description: "Path to the ACP agent executable.", + providerSettingsForm: { + placeholder: "contextivity-agent", + clearWhenEmpty: "omit", + }, + }), + ), + launchArgs: Schema.String.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "CLI arguments passed to the agent. For Contextivity, use --mode acp.", + providerSettingsForm: { + placeholder: "--mode acp", + clearWhenEmpty: "omit", + }, + }), + ), + authMethodId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Auth method ID", + description: + "ACP authenticate method id. Leave blank to use the first advertised method, or skip authentication if the agent advertises none.", + providerSettingsForm: { + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + attachMcpWhenSupported: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Attach T3 MCP when supported", + description: + "Offer T3's HTTP MCP server only when the agent advertises compatible HTTP MCP. Turn this off to never send MCP servers.", + providerSettingsForm: { control: "switch" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs", "authMethodId", "attachMcpWhenSupported"], + }, +); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -697,6 +759,11 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * the single source of truth. Unknown (fork) drivers default to enabled. */ export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { + // ACP Registry is instance-only and is not synthesized from the legacy + // `providers` blob, so it cannot inherit an enabled default from there. + if (driver === "acpRegistry") { + return false; + } const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined