From e58298abc3fe7a234ba077e805e5c0935edf3116 Mon Sep 17 00:00:00 2001 From: Eric Hayes Date: Tue, 15 Sep 2026 17:27:03 -0400 Subject: [PATCH 1/5] feat(agents): add Claude Cloud demo harness --- .gitignore | 3 + ...e8337815d014b73097e578c5b5bd43e8b6ce4.json | 16 + ...61f434e090e77ce7c0d273cd2bdd242e1a30f.json | 34 ++ ...12b82a2157bf3207a71af91c796b6145f17d9.json | 12 + ...2020d3ccf13300604c8b0e0af6610decc7fa5.json | 14 + ...e9a9331238e347a15ef0fe95514c7308e8435.json | 20 ++ ...4581ebb1bc17fadf3cebeafbffff412804997.json | 16 + Cargo.lock | 30 ++ Cargo.toml | 1 + .../component/AgentSplitHeader.tsx | 1 + .../compose-agent-session-options.test.ts | 1 + .../compose-agent-session-options.ts | 8 +- .../context/AgentSessionContext.tsx | 8 +- .../claude-connection/claude-connection.tsx | 39 +++ .../components/connection-card.test.tsx | 72 ++++ .../components/connection-card.tsx | 165 +++++++++ .../claude-connection/core/connection.ts | 22 ++ .../primitives/connection.test.ts | 83 +++++ .../primitives/connection.ts | 101 ++++++ .../web/src/features/settings/Agents.test.tsx | 11 + apps/web/src/features/settings/Agents.tsx | 18 +- .../src/features/settings/Harness.test.tsx | 22 +- apps/web/src/features/settings/Harness.tsx | 3 + .../src/lib/queries/agent-session/session.ts | 4 +- .../src/lib/queries/agents/models.test.tsx | 4 +- apps/web/src/lib/queries/agents/models.ts | 5 +- .../src/lib/queries/claude-auth/connection.ts | 42 +++ apps/web/src/lib/queries/claude-auth/keys.ts | 4 + .../service-agent-harness/claude-auth.ts | 66 ++++ .../generated/schemas/modelHarnessDto.ts | 1 + .../service-agent-harness/openapi.json | 213 +++++++++++- crates/agent_harness/src/domain/model.rs | 3 + crates/agent_harness/src/domain/model_load.rs | 25 ++ .../src/domain/model_load/test.rs | 23 ++ .../agent_harness/src/domain/service/queue.rs | 6 + .../agent_harness/src/domain/service/test.rs | 47 +++ .../agent_harness/src/inbound/kafka/test.rs | 1 + .../agent_harness/src/inbound/model_load.rs | 3 + crates/agent_harness/src/outbound/routing.rs | 14 +- crates/agent_session/src/domain/model.rs | 19 ++ crates/agent_session/src/domain/model/test.rs | 25 ++ .../agent_session/src/inbound/axum_router.rs | 3 +- crates/claude_cloud_agents/Cargo.toml | 32 ++ crates/claude_cloud_agents/examples/smoke.rs | 110 ++++++ crates/claude_cloud_agents/src/domain/auth.rs | 231 +++++++++++++ .../src/domain/auth/test.rs | 187 ++++++++++ .../src/domain/credentials.rs | 102 ++++++ .../src/domain/credentials/test.rs | 82 +++++ crates/claude_cloud_agents/src/domain/mod.rs | 9 + .../claude_cloud_agents/src/domain/model.rs | 145 ++++++++ .../claude_cloud_agents/src/domain/models.rs | 139 ++++++++ .../src/domain/models/test.rs | 131 +++++++ .../claude_cloud_agents/src/domain/ports.rs | 33 ++ .../claude_cloud_agents/src/domain/service.rs | 323 ++++++++++++++++++ .../src/domain/service/test.rs | 309 +++++++++++++++++ .../src/domain/translate.rs | 185 ++++++++++ .../src/domain/translate/test.rs | 97 ++++++ crates/claude_cloud_agents/src/inbound/acp.rs | 255 ++++++++++++++ .../src/inbound/acp/test.rs | 231 +++++++++++++ .../claude_cloud_agents/src/inbound/auth.rs | 199 +++++++++++ crates/claude_cloud_agents/src/inbound/mod.rs | 4 + crates/claude_cloud_agents/src/lib.rs | 6 + .../src/outbound/credentials.rs | 205 +++++++++++ .../src/outbound/credentials/test.rs | 104 ++++++ .../claude_cloud_agents/src/outbound/http.rs | 292 ++++++++++++++++ .../src/outbound/http/test.rs | 12 + .../claude_cloud_agents/src/outbound/mod.rs | 6 + .../claude_cloud_agents/src/outbound/oauth.rs | 162 +++++++++ .../src/outbound/postgres.rs | 73 ++++ .../src/outbound/postgres/test.rs | 102 ++++++ ...20260910144348_add_claude_oauth_grants.sql | 10 + docs/AGENT_GUIDE/README.md | 1 + docs/AGENT_GUIDE/navigation.md | 11 + docs/CLAUDE_CLOUD_DEMO.md | 155 +++++++++ packages/sdk/specs/agent-harness.json | 213 +++++++++++- .../ts-feature-components-presentational.yml | 1 + rules/ast-grep/ts-feature-core-pure.yml | 1 + rules/ast-grep/ts-feature-data-no-ui.yml | 1 + .../ts-feature-layers-use-context.yml | 2 + .../tsx-feature-components-presentational.yml | 1 + rules/ast-grep/tsx-feature-core-pure.yml | 1 + rules/ast-grep/tsx-feature-data-no-ui.yml | 1 + .../tsx-feature-layers-use-context.yml | 2 + services/agent_harness_service/Cargo.toml | 1 + services/agent_harness_service/src/api.rs | 9 + .../agent_harness_service/src/api/swagger.rs | 9 + .../agent_harness_service/src/claude_cloud.rs | 299 ++++++++++++++++ services/agent_harness_service/src/config.rs | 6 + services/agent_harness_service/src/main.rs | 71 +++- tooling/claude-cloud/browser-smoke.mjs | 180 ++++++++++ tooling/claude-cloud/connect.mjs | 215 ++++++++++++ 91 files changed, 6137 insertions(+), 27 deletions(-) create mode 100644 .sqlx/query-09d165c95c65c1c557077270a3ae8337815d014b73097e578c5b5bd43e8b6ce4.json create mode 100644 .sqlx/query-60c4b70fca2877d4725e9d8ffdf61f434e090e77ce7c0d273cd2bdd242e1a30f.json create mode 100644 .sqlx/query-788d94f60798cda89ec1d1b859f12b82a2157bf3207a71af91c796b6145f17d9.json create mode 100644 .sqlx/query-82f5f0bbdc50b48174baa0d5b1e2020d3ccf13300604c8b0e0af6610decc7fa5.json create mode 100644 .sqlx/query-b99f44b3083e803ed923c27da5de9a9331238e347a15ef0fe95514c7308e8435.json create mode 100644 .sqlx/query-fdf3802d910ed6101aa3235af774581ebb1bc17fadf3cebeafbffff412804997.json create mode 100644 apps/web/src/features/claude-connection/claude-connection.tsx create mode 100644 apps/web/src/features/claude-connection/components/connection-card.test.tsx create mode 100644 apps/web/src/features/claude-connection/components/connection-card.tsx create mode 100644 apps/web/src/features/claude-connection/core/connection.ts create mode 100644 apps/web/src/features/claude-connection/primitives/connection.test.ts create mode 100644 apps/web/src/features/claude-connection/primitives/connection.ts create mode 100644 apps/web/src/lib/queries/claude-auth/connection.ts create mode 100644 apps/web/src/lib/queries/claude-auth/keys.ts create mode 100644 apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts create mode 100644 crates/agent_session/src/domain/model/test.rs create mode 100644 crates/claude_cloud_agents/Cargo.toml create mode 100644 crates/claude_cloud_agents/examples/smoke.rs create mode 100644 crates/claude_cloud_agents/src/domain/auth.rs create mode 100644 crates/claude_cloud_agents/src/domain/auth/test.rs create mode 100644 crates/claude_cloud_agents/src/domain/credentials.rs create mode 100644 crates/claude_cloud_agents/src/domain/credentials/test.rs create mode 100644 crates/claude_cloud_agents/src/domain/mod.rs create mode 100644 crates/claude_cloud_agents/src/domain/model.rs create mode 100644 crates/claude_cloud_agents/src/domain/models.rs create mode 100644 crates/claude_cloud_agents/src/domain/models/test.rs create mode 100644 crates/claude_cloud_agents/src/domain/ports.rs create mode 100644 crates/claude_cloud_agents/src/domain/service.rs create mode 100644 crates/claude_cloud_agents/src/domain/service/test.rs create mode 100644 crates/claude_cloud_agents/src/domain/translate.rs create mode 100644 crates/claude_cloud_agents/src/domain/translate/test.rs create mode 100644 crates/claude_cloud_agents/src/inbound/acp.rs create mode 100644 crates/claude_cloud_agents/src/inbound/acp/test.rs create mode 100644 crates/claude_cloud_agents/src/inbound/auth.rs create mode 100644 crates/claude_cloud_agents/src/inbound/mod.rs create mode 100644 crates/claude_cloud_agents/src/lib.rs create mode 100644 crates/claude_cloud_agents/src/outbound/credentials.rs create mode 100644 crates/claude_cloud_agents/src/outbound/credentials/test.rs create mode 100644 crates/claude_cloud_agents/src/outbound/http.rs create mode 100644 crates/claude_cloud_agents/src/outbound/http/test.rs create mode 100644 crates/claude_cloud_agents/src/outbound/mod.rs create mode 100644 crates/claude_cloud_agents/src/outbound/oauth.rs create mode 100644 crates/claude_cloud_agents/src/outbound/postgres.rs create mode 100644 crates/claude_cloud_agents/src/outbound/postgres/test.rs create mode 100644 crates/macro_db_client/migrations/20260910144348_add_claude_oauth_grants.sql create mode 100644 docs/CLAUDE_CLOUD_DEMO.md create mode 100644 services/agent_harness_service/src/claude_cloud.rs create mode 100644 tooling/claude-cloud/browser-smoke.mjs create mode 100644 tooling/claude-cloud/connect.mjs diff --git a/.gitignore b/.gitignore index 9c81865a765..b7e20900865 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ infra/local/generated/ # Per-developer macrod config contains this machine's harness bearer token. /macrod.toml /macrod.webhook-state.json + +# Owner-bound subscription OAuth credentials for the internal Claude demo. +/.claude-cloud/ diff --git a/.sqlx/query-09d165c95c65c1c557077270a3ae8337815d014b73097e578c5b5bd43e8b6ce4.json b/.sqlx/query-09d165c95c65c1c557077270a3ae8337815d014b73097e578c5b5bd43e8b6ce4.json new file mode 100644 index 00000000000..e8ca3df16d7 --- /dev/null +++ b/.sqlx/query-09d165c95c65c1c557077270a3ae8337815d014b73097e578c5b5bd43e8b6ce4.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH u AS (INSERT INTO macro_user (id, username, email, stripe_customer_id) VALUES ($1,$2,$2,$2) RETURNING id) INSERT INTO \"User\" (\"id\", \"email\", \"macro_user_id\") SELECT $3,$2,u.id FROM u", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "09d165c95c65c1c557077270a3ae8337815d014b73097e578c5b5bd43e8b6ce4" +} diff --git a/.sqlx/query-60c4b70fca2877d4725e9d8ffdf61f434e090e77ce7c0d273cd2bdd242e1a30f.json b/.sqlx/query-60c4b70fca2877d4725e9d8ffdf61f434e090e77ce7c0d273cd2bdd242e1a30f.json new file mode 100644 index 00000000000..74b9ac37154 --- /dev/null +++ b/.sqlx/query-60c4b70fca2877d4725e9d8ffdf61f434e090e77ce7c0d273cd2bdd242e1a30f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT grant_ciphertext, kms_key_id, encryption_version FROM claude_oauth_grants WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "grant_ciphertext", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "kms_key_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "encryption_version", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "60c4b70fca2877d4725e9d8ffdf61f434e090e77ce7c0d273cd2bdd242e1a30f" +} diff --git a/.sqlx/query-788d94f60798cda89ec1d1b859f12b82a2157bf3207a71af91c796b6145f17d9.json b/.sqlx/query-788d94f60798cda89ec1d1b859f12b82a2157bf3207a71af91c796b6145f17d9.json new file mode 100644 index 00000000000..cc01125cb2f --- /dev/null +++ b/.sqlx/query-788d94f60798cda89ec1d1b859f12b82a2157bf3207a71af91c796b6145f17d9.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO claude_oauth_grants (user_id, grant_ciphertext, kms_key_id, encryption_version) SELECT 'bob', grant_ciphertext, kms_key_id, encryption_version FROM claude_oauth_grants WHERE user_id = 'alice'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "788d94f60798cda89ec1d1b859f12b82a2157bf3207a71af91c796b6145f17d9" +} diff --git a/.sqlx/query-82f5f0bbdc50b48174baa0d5b1e2020d3ccf13300604c8b0e0af6610decc7fa5.json b/.sqlx/query-82f5f0bbdc50b48174baa0d5b1e2020d3ccf13300604c8b0e0af6610decc7fa5.json new file mode 100644 index 00000000000..66cf24728df --- /dev/null +++ b/.sqlx/query-82f5f0bbdc50b48174baa0d5b1e2020d3ccf13300604c8b0e0af6610decc7fa5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM claude_oauth_grants WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "82f5f0bbdc50b48174baa0d5b1e2020d3ccf13300604c8b0e0af6610decc7fa5" +} diff --git a/.sqlx/query-b99f44b3083e803ed923c27da5de9a9331238e347a15ef0fe95514c7308e8435.json b/.sqlx/query-b99f44b3083e803ed923c27da5de9a9331238e347a15ef0fe95514c7308e8435.json new file mode 100644 index 00000000000..6b7201a84d6 --- /dev/null +++ b/.sqlx/query-b99f44b3083e803ed923c27da5de9a9331238e347a15ef0fe95514c7308e8435.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT grant_ciphertext FROM claude_oauth_grants WHERE user_id = 'alice'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "grant_ciphertext", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "b99f44b3083e803ed923c27da5de9a9331238e347a15ef0fe95514c7308e8435" +} diff --git a/.sqlx/query-fdf3802d910ed6101aa3235af774581ebb1bc17fadf3cebeafbffff412804997.json b/.sqlx/query-fdf3802d910ed6101aa3235af774581ebb1bc17fadf3cebeafbffff412804997.json new file mode 100644 index 00000000000..14da440a981 --- /dev/null +++ b/.sqlx/query-fdf3802d910ed6101aa3235af774581ebb1bc17fadf3cebeafbffff412804997.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO claude_oauth_grants (user_id, grant_ciphertext, kms_key_id, encryption_version) VALUES ($1, $2, $3, 1) ON CONFLICT (user_id) DO UPDATE SET grant_ciphertext = EXCLUDED.grant_ciphertext, kms_key_id = EXCLUDED.kms_key_id, encryption_version = 1, updated_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Bytea", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fdf3802d910ed6101aa3235af774581ebb1bc17fadf3cebeafbffff412804997" +} diff --git a/Cargo.lock b/Cargo.lock index f98728af592..09ddda80224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,7 @@ dependencies = [ "bot_id", "bots", "channels", + "claude_cloud_agents", "codex_cloud_agents", "codex_connection", "connection_gateway_client", @@ -3614,6 +3615,35 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "claude_cloud_agents" +version = "0.1.0" +dependencies = [ + "agent_runtime_protocol", + "async-trait", + "axum", + "base64 0.22.1", + "clap", + "cursor_api_key", + "futures", + "macro_authorization", + "macro_db_migrator", + "rand 0.9.4", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "sse-core", + "subtle", + "tempfile", + "thiserror 2.0.18", + "tokio", + "utoipa", + "uuid", + "zeroize", +] + [[package]] name = "clipboard-win" version = "5.4.1" diff --git a/Cargo.toml b/Cargo.toml index a613644b7a1..34b1134511b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/crm", "crates/cursor_api_key", "crates/cursor_cloud_agents", + "crates/claude_cloud_agents", "crates/embedding", "crates/email_api_client", "crates/entity_mutation", diff --git a/apps/web/src/features/block-agent/component/AgentSplitHeader.tsx b/apps/web/src/features/block-agent/component/AgentSplitHeader.tsx index c26a4b1610f..5863dc7fce8 100644 --- a/apps/web/src/features/block-agent/component/AgentSplitHeader.tsx +++ b/apps/web/src/features/block-agent/component/AgentSplitHeader.tsx @@ -94,6 +94,7 @@ export function AgentSplitHeader(props: { { label: () => { const provider = props.session?.external?.provider; + if (provider === 'claude-cloud') return 'Open in Claude'; if (!provider) return 'Open externally'; return `Open in ${provider.charAt(0).toUpperCase()}${provider.slice(1)}`; }, diff --git a/apps/web/src/features/block-agent/component/compose-agent-session-options.test.ts b/apps/web/src/features/block-agent/component/compose-agent-session-options.test.ts index 1ae9674c2e3..20b3f704ecb 100644 --- a/apps/web/src/features/block-agent/component/compose-agent-session-options.test.ts +++ b/apps/web/src/features/block-agent/component/compose-agent-session-options.test.ts @@ -89,6 +89,7 @@ describe('isManagedHarness', () => { expect(isManagedHarness('in-memory')).toBe(true); expect(isManagedHarness('macro-inmem')).toBe(true); expect(isManagedHarness('cursor')).toBe(true); + expect(isManagedHarness('claude-cloud')).toBe(true); }); it('refuses external daemons', () => { diff --git a/apps/web/src/features/block-agent/component/compose-agent-session-options.ts b/apps/web/src/features/block-agent/component/compose-agent-session-options.ts index a0d72906686..528611d7306 100644 --- a/apps/web/src/features/block-agent/component/compose-agent-session-options.ts +++ b/apps/web/src/features/block-agent/component/compose-agent-session-options.ts @@ -42,7 +42,8 @@ export function isManagedHarness(harness: string): boolean { harness === 'in-memory' || harness === 'macro-inmem' || harness === 'cursor' || - harness === 'codex-cloud' + harness === 'codex-cloud' || + harness === 'claude-cloud' ); } @@ -78,6 +79,9 @@ export function agentRuntimeDescription( } if (persona.harness === 'codex-cloud') return 'Runs in your selected Codex cloud environment'; + if (persona.harness === 'claude-cloud') { + return 'Runs in Claude’s cloud using your subscription. Demo: text prompts, no Macro connectors'; + } if (persona.harness === 'macrod') { return `Do work locally using ${persona.name}${ownerName ? ` owned by ${ownerName}` : ''}`; } @@ -98,6 +102,8 @@ export function harnessDisplayName(harness: string): string { return 'Cursor'; case 'codex-cloud': return 'Codex'; + case 'claude-cloud': + return 'Claude Cloud'; default: return harness; } diff --git a/apps/web/src/features/block-agent/context/AgentSessionContext.tsx b/apps/web/src/features/block-agent/context/AgentSessionContext.tsx index ef63bbc2459..938ec595c34 100644 --- a/apps/web/src/features/block-agent/context/AgentSessionContext.tsx +++ b/apps/web/src/features/block-agent/context/AgentSessionContext.tsx @@ -225,7 +225,7 @@ export function AgentSessionProvider( } /** - * Compensating read for a Cursor session whose provider url arrived after + * Compensating read for a cloud session whose provider URL arrived after * the feed's snapshot. Lives in its own Suspense so the rest of the block * stays mounted while this query's first fetch is in flight. */ @@ -234,13 +234,15 @@ function CloudExternalUrlPoll(props: { session: Accessor; applySnapshot: (session: AgentSessionResponse) => void; }) { - // Only a loaded Cursor session whose provider url is still missing polls; + // Only a loaded cloud session whose provider URL is still missing polls; // everything else passes `undefined`, which disables the query. const query = useAgentSessionExternalUrlQuery(() => { const id = props.sessionId(); const session = props.session(); if (!id || !session || session.external?.url) return undefined; - return isCursorBotId(session.botId) || isCodexBotId(session.botId) + return isCursorBotId(session.botId) || + isCodexBotId(session.botId) || + session.harness === 'claude-cloud' ? id : undefined; }); diff --git a/apps/web/src/features/claude-connection/claude-connection.tsx b/apps/web/src/features/claude-connection/claude-connection.tsx new file mode 100644 index 00000000000..2ce00af50b7 --- /dev/null +++ b/apps/web/src/features/claude-connection/claude-connection.tsx @@ -0,0 +1,39 @@ +import { useUserId } from '@core/context/user'; +import { useClaudeConnectionSource } from '@queries/claude-auth/connection'; +import { Suspense } from 'solid-js'; +import { ConnectionCard } from './components/connection-card'; +import { createClaudeConnection } from './primitives/connection'; + +function ConnectedCard() { + const connection = createClaudeConnection( + useClaudeConnectionSource(useUserId()) + ); + return ( + void connection.begin()} + onComplete={() => void connection.complete()} + onDisconnect={() => void connection.disconnect()} + onRefresh={() => void connection.refresh()} + /> + ); +} + +/** Production composition, isolated from the parent Settings suspense boundary. */ +export function ClaudeConnection() { + return ( + Loading Claude connection…

+ } + > + +
+ ); +} diff --git a/apps/web/src/features/claude-connection/components/connection-card.test.tsx b/apps/web/src/features/claude-connection/components/connection-card.test.tsx new file mode 100644 index 00000000000..0c591e62b5e --- /dev/null +++ b/apps/web/src/features/claude-connection/components/connection-card.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, expect, it, vi } from 'vitest'; +import { ConnectionCard, type ConnectionCardProps } from './connection-card'; + +vi.mock('@ui', () => ({ + Button: ( + props: import('solid-js').JSX.ButtonHTMLAttributes + ) => + + } + > + {(login) => ( +
{ + event.preventDefault(); + props.onComplete(); + }} + > + + 1. Open Claude sign-in ↗ + + + props.onCode(event.currentTarget.value)} + placeholder="code#state" + autocomplete="off" + spellcheck={false} + disabled={props.busy} + /> +

+ Approve access on Claude, then copy the entire code shown + there. This attempt expires in{' '} + {Math.round(login.expiresIn / 60)} minutes. +

+
+ + +
+
+ )} + + + + +

+ Ready. In Settings → Agents, choose Claude Cloud (demo) as the + agent’s harness. Disconnecting forgets Macro’s grant; it does not + revoke consent at Claude or stop an already-running cloud turn. +

+
+ + + + +
+ +
+
+ + + ); +} diff --git a/apps/web/src/features/claude-connection/core/connection.ts b/apps/web/src/features/claude-connection/core/connection.ts new file mode 100644 index 00000000000..8c16105562b --- /dev/null +++ b/apps/web/src/features/claude-connection/core/connection.ts @@ -0,0 +1,22 @@ +/** Safe connection metadata. Provider tokens are never sent to the browser. */ +export type ClaudeConnectionStatus = { + enabled: boolean; + connected: boolean; + ephemeral: boolean; +}; + +export type ClaudeLogin = { + attemptId: string; + authorizationUrl: string; + expiresIn: number; +}; + +/** Narrow source contract; the controller does not know the query or transport library. */ +export type ClaudeConnectionSource = { + status: () => ClaudeConnectionStatus | undefined; + failed: () => boolean; + begin: () => Promise; + complete: (attemptId: string, code: string) => Promise; + disconnect: () => Promise; + refresh: () => Promise; +}; diff --git a/apps/web/src/features/claude-connection/primitives/connection.test.ts b/apps/web/src/features/claude-connection/primitives/connection.test.ts new file mode 100644 index 00000000000..1ace51df83e --- /dev/null +++ b/apps/web/src/features/claude-connection/primitives/connection.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { createRoot, createSignal } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; +import type { + ClaudeConnectionSource, + ClaudeConnectionStatus, +} from '../core/connection'; +import { createClaudeConnection } from './connection'; + +function setup() { + return createRoot((dispose) => { + const [status, setStatus] = createSignal({ + enabled: true, + connected: false, + ephemeral: true, + }); + const source: ClaudeConnectionSource = { + status, + failed: () => false, + begin: vi.fn(async () => ({ + attemptId: 'attempt', + authorizationUrl: 'https://claude.com/consent', + expiresIn: 600, + })), + complete: vi.fn(async () => { + setStatus({ ...status(), connected: true }); + }), + disconnect: vi.fn(async () => { + setStatus({ ...status(), connected: false }); + }), + refresh: vi.fn(async () => {}), + }; + return { state: createClaudeConnection(source), source, dispose }; + }); +} +describe('Claude connection controller', () => { + it('connects and immediately clears the one-time code, then disconnects', async () => { + const { state, source, dispose } = setup(); + await state.begin(); + expect(state.login()?.attemptId).toBe('attempt'); + state.setCode('one-time#state'); + const done = state.complete(); + expect(state.code()).toBe(''); + expect(state.busy()).toBe(true); + await done; + expect(source.complete).toHaveBeenCalledWith('attempt', 'one-time#state'); + expect(state.status()?.connected).toBe(true); + expect(state.login()).toBeUndefined(); + await state.disconnect(); + expect(state.status()?.connected).toBe(false); + dispose(); + }); + it('shows an exchange error and requires a fresh attempt instead of replaying', async () => { + const { state, source, dispose } = setup(); + vi.mocked(source.complete).mockRejectedValueOnce(new Error('Expired code')); + await state.begin(); + state.setCode('expired#state'); + await state.complete(); + expect(state.error()).toBe('Expired code'); + expect(state.code()).toBe(''); + expect(state.login()).toBeUndefined(); + expect(state.busy()).toBe(false); + await state.complete(); + expect(source.complete).toHaveBeenCalledTimes(1); + dispose(); + }); + it('expires an unfinished attempt and cancels explicitly', async () => { + vi.useFakeTimers(); + const { state, source, dispose } = setup(); + await state.begin(); + state.setCode('private#state'); + vi.advanceTimersByTime(600_000); + expect(state.login()).toBeUndefined(); + expect(state.code()).toBe(''); + expect(state.error()).toContain('expired'); + await state.begin(); + await state.disconnect(); + expect(source.disconnect).toHaveBeenCalledTimes(1); + expect(state.login()).toBeUndefined(); + dispose(); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/src/features/claude-connection/primitives/connection.ts b/apps/web/src/features/claude-connection/primitives/connection.ts new file mode 100644 index 00000000000..c8df5efae25 --- /dev/null +++ b/apps/web/src/features/claude-connection/primitives/connection.ts @@ -0,0 +1,101 @@ +import { createSignal, onCleanup } from 'solid-js'; +import type { ClaudeConnectionSource, ClaudeLogin } from '../core/connection'; + +/** Owns consent UI state; the source owns transport and cache invalidation. */ +export function createClaudeConnection(source: ClaudeConnectionSource) { + const [login, setLogin] = createSignal(); + const [code, setCode] = createSignal(''); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(''); + let disposed = false; + let expiry: ReturnType | undefined; + const clear = () => { + clearTimeout(expiry); + setLogin(undefined); + setCode(''); + }; + onCleanup(() => { + disposed = true; + clearTimeout(expiry); + setCode(''); + }); + const report = (error: unknown) => + setError( + error instanceof Error + ? error.message + : 'Claude connection failed. Please try again.' + ); + + async function begin() { + if (busy()) return; + clear(); + setBusy(true); + setError(''); + try { + const next = await source.begin(); + if (disposed) return; + setLogin(next); + expiry = setTimeout(() => { + clear(); + setError('Sign-in expired. Start Connect Claude again.'); + }, next.expiresIn * 1000); + } catch (error) { + if (!disposed) report(error); + } finally { + if (!disposed) setBusy(false); + } + } + async function complete() { + const current = login(); + const pasted = code().trim(); + if (busy() || !current || !pasted) return; + setCode(''); + setBusy(true); + setError(''); + try { + await source.complete(current.attemptId, pasted); + if (!disposed) clear(); + } catch (error) { + if (!disposed) { + clear(); + report(error); + } + } finally { + if (!disposed) setBusy(false); + } + } + async function disconnect() { + if (busy()) return; + clear(); + setBusy(true); + setError(''); + try { + await source.disconnect(); + } catch (error) { + if (!disposed) report(error); + } finally { + if (!disposed) setBusy(false); + } + } + async function refresh() { + setError(''); + try { + await source.refresh(); + } catch (error) { + if (!disposed) report(error); + } + } + return { + status: source.status, + failed: source.failed, + login, + code, + setCode, + busy, + error, + begin, + complete, + disconnect, + refresh, + }; +} diff --git a/apps/web/src/features/settings/Agents.test.tsx b/apps/web/src/features/settings/Agents.test.tsx index aba8609caaa..e360062589b 100644 --- a/apps/web/src/features/settings/Agents.test.tsx +++ b/apps/web/src/features/settings/Agents.test.tsx @@ -22,6 +22,17 @@ import { createStore } from 'solid-js/store'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Agents } from './Agents'; +vi.mock('@queries/claude-auth/connection', () => ({ + useClaudeConnectionSource: () => ({ + status: () => ({ enabled: true, connected: false, ephemeral: true }), + failed: () => false, + begin: vi.fn(), + complete: vi.fn(), + disconnect: vi.fn(), + refresh: vi.fn(), + }), +})); + const [searchParams, updateSearchParams] = createStore<{ createAgent?: string; }>({}); diff --git a/apps/web/src/features/settings/Agents.tsx b/apps/web/src/features/settings/Agents.tsx index 18b33b301d1..e70bfa18071 100644 --- a/apps/web/src/features/settings/Agents.tsx +++ b/apps/web/src/features/settings/Agents.tsx @@ -121,6 +121,14 @@ export function Agents() { return buildAgentModelTargets(cursorConnected(), harnesses).map( (target) => { if (target.harness === 'in-memory') return IN_MEMORY_HARNESS; + if (target.harness === 'claude-cloud') { + return { + id: 'claude-cloud', + name: 'Claude Cloud (demo)', + kind: 'builtin', + target, + }; + } if (target.harness === 'cursor') { return { id: 'cursor', @@ -379,6 +387,7 @@ function summarizeAgent( function harnessName(id: string): string { if (id === 'in-memory') return 'In-memory'; if (id === 'cursor') return 'Cursor'; + if (id === 'claude-cloud') return 'Claude Cloud (demo)'; // Any other id is a registered macrod harness uuid; if it is not in the // connected list any more, the harness has been removed. return 'Disconnected harness'; @@ -857,7 +866,14 @@ function AgentDialog(props: { handleHarnessChange(event.currentTarget.value) } > - + + harness.id !== 'claude-cloud' || + modelDataForHarness(harness.id)?.status === + 'available' + )} + > {(harness) => ( )} diff --git a/apps/web/src/features/settings/Harness.test.tsx b/apps/web/src/features/settings/Harness.test.tsx index f03246a3961..61070591885 100644 --- a/apps/web/src/features/settings/Harness.test.tsx +++ b/apps/web/src/features/settings/Harness.test.tsx @@ -29,6 +29,20 @@ vi.mock('./codex/views/CodexHarness', () => ({ CodexHarness: () =>
, })); +vi.mock('@core/context/user', () => ({ + useUserId: () => () => 'macro|demo@example.com', +})); +vi.mock('@queries/claude-auth/connection', () => ({ + useClaudeConnectionSource: () => ({ + status: () => ({ enabled: true, connected: false, ephemeral: true }), + failed: () => false, + begin: vi.fn(), + complete: vi.fn(), + disconnect: vi.fn(), + refresh: vi.fn(), + }), +})); + const mocks = vi.hoisted(() => ({ status: { isSuccess: true, @@ -278,11 +292,17 @@ describe('Harness', () => { } ); - it('shows the three configurable harness options', () => { + it('shows Claude with the Anthropic logo above Cursor in the harness list', () => { render(() => ); expect(screen.getByRole('heading', { name: 'In-memory' })).toBeTruthy(); expect(screen.getByRole('heading', { name: 'Cursor' })).toBeTruthy(); + const claude = screen.getByRole('heading', { name: 'Claude Cloud (demo)' }); + const cursor = screen.getByRole('heading', { name: 'Cursor' }); + expect( + claude.compareDocumentPosition(cursor) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(screen.getByLabelText('Anthropic')).toBeTruthy(); expect( screen.getByRole('heading', { name: 'Bring your own agent' }) ).toBeTruthy(); diff --git a/apps/web/src/features/settings/Harness.tsx b/apps/web/src/features/settings/Harness.tsx index 045b5b9c82a..e07b7f6c022 100644 --- a/apps/web/src/features/settings/Harness.tsx +++ b/apps/web/src/features/settings/Harness.tsx @@ -22,6 +22,7 @@ import type { Harness as RegisteredHarness } from '@service-storage/client'; import { useSearchParams } from '@solidjs/router'; import { Button, Dialog, Panel } from '@ui'; import { createSignal, For, onMount, Show } from 'solid-js'; +import { ClaudeConnection } from '../claude-connection/claude-connection'; import { CodexHarness } from './codex/views/CodexHarness'; import { HarnessPairingDialog } from './HarnessPairingDialog'; import { ConnectAction, HarnessIcon, StatusDot } from './integration-ui'; @@ -177,6 +178,8 @@ export function Harness() {
+ +
diff --git a/apps/web/src/lib/queries/agent-session/session.ts b/apps/web/src/lib/queries/agent-session/session.ts index 36f7007cd61..084212a4813 100644 --- a/apps/web/src/lib/queries/agent-session/session.ts +++ b/apps/web/src/lib/queries/agent-session/session.ts @@ -1,5 +1,5 @@ /** - * Bounded snapshot poll for a Cursor session whose `external.url` is still + * Bounded snapshot poll for a cloud session whose `external.url` is still * missing. * * Cursor mints that url inside the session's first prompt, and nothing @@ -26,7 +26,7 @@ export const EXTERNAL_URL_POLL_ATTEMPTS = 15; /** * Re-read an agent session snapshot while `sessionId` is set. Callers pass - * `undefined` when the session is not a Cursor bot, already has a url, or + * `undefined` when the session is not a cloud harness, already has a url, or * has not loaded yet — the query disables rather than fetching. * * `gcTime: 0` drops the observer when the call site unmounts so reopening diff --git a/apps/web/src/lib/queries/agents/models.test.tsx b/apps/web/src/lib/queries/agents/models.test.tsx index cd2e086abc3..cf0cd87ba55 100644 --- a/apps/web/src/lib/queries/agents/models.test.tsx +++ b/apps/web/src/lib/queries/agents/models.test.tsx @@ -60,7 +60,7 @@ describe('agent model discovery', () => { await vi.waitFor(() => { expect(agentHarnessServiceClient.loadAgentModels).toHaveBeenCalledTimes( - 4 + 5 ); }); expect( @@ -69,6 +69,7 @@ describe('agent model discovery', () => { .mock.calls.map(([request]) => request) ).toEqual([ { harness: 'in-memory' }, + { harness: 'claude-cloud' }, { harness: 'cursor' }, { harness: 'macrod', harnessId: 'harness-a' }, { harness: 'macrod', harnessId: 'harness-b' }, @@ -78,6 +79,7 @@ describe('agent model discovery', () => { it('omits Cursor when it is not registered', () => { expect(buildAgentModelTargets(false, [{ id: 'harness-a' }])).toEqual([ { harness: 'in-memory' }, + { harness: 'claude-cloud' }, { harness: 'macrod', harnessId: 'harness-a' }, ]); }); diff --git a/apps/web/src/lib/queries/agents/models.ts b/apps/web/src/lib/queries/agents/models.ts index ca980c84015..900d2f7bf48 100644 --- a/apps/web/src/lib/queries/agents/models.ts +++ b/apps/web/src/lib/queries/agents/models.ts @@ -14,7 +14,10 @@ export function buildAgentModelTargets( cursorRegistered: boolean, harnesses: readonly Pick[] ): AgentModelTarget[] { - const targets: AgentModelTarget[] = [{ harness: 'in-memory' }]; + const targets: AgentModelTarget[] = [ + { harness: 'in-memory' }, + { harness: 'claude-cloud' }, + ]; if (cursorRegistered) targets.push({ harness: 'cursor' }); targets.push( ...harnesses.map( diff --git a/apps/web/src/lib/queries/claude-auth/connection.ts b/apps/web/src/lib/queries/claude-auth/connection.ts new file mode 100644 index 00000000000..9cabb3a28de --- /dev/null +++ b/apps/web/src/lib/queries/claude-auth/connection.ts @@ -0,0 +1,42 @@ +import { throwOnErr } from '@core/util/result'; +import { claudeAuthClient } from '@service-agent-harness/claude-auth'; +import { useQuery, useQueryClient } from '@tanstack/solid-query'; +import type { ClaudeConnectionSource } from '../../../features/claude-connection/core/connection'; +import { claudeAuthKeys } from './keys'; + +/** Only safe status is cached. One-time codes never enter a mutation cache. */ +export function useClaudeConnectionSource( + owner: () => string | undefined +): ClaudeConnectionSource { + const client = useQueryClient(); + const query = useQuery(() => ({ + queryKey: claudeAuthKeys.status(owner()).queryKey, + queryFn: ({ signal }) => throwOnErr(() => claudeAuthClient.status(signal)), + enabled: !!owner(), + staleTime: 0, + gcTime: 0, + retry: false, + })); + const refresh = async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: claudeAuthKeys._def }), + client.invalidateQueries({ + queryKey: ['agent-models', 'load', 'claude-cloud'], + }), + ]); + }; + return { + status: () => (query.isSuccess ? query.data : undefined), + failed: () => query.isError, + begin: () => throwOnErr(() => claudeAuthClient.begin()), + complete: async (attemptId, code) => { + await throwOnErr(() => claudeAuthClient.complete(attemptId, code)); + await refresh(); + }, + disconnect: async () => { + await throwOnErr(() => claudeAuthClient.disconnect()); + await refresh(); + }, + refresh, + }; +} diff --git a/apps/web/src/lib/queries/claude-auth/keys.ts b/apps/web/src/lib/queries/claude-auth/keys.ts new file mode 100644 index 00000000000..18cfc28adb3 --- /dev/null +++ b/apps/web/src/lib/queries/claude-auth/keys.ts @@ -0,0 +1,4 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; +export const claudeAuthKeys = createQueryKeys('claude-auth', { + status: (owner: string | undefined) => [owner], +}); diff --git a/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts b/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts new file mode 100644 index 00000000000..0b5822a6227 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts @@ -0,0 +1,66 @@ +import { SERVER_HOSTS } from '@core/constant/servers'; +import { fetchWithToken } from '@core/util/fetchWithToken'; +import type { ErrorResponseHandler } from '@core/util/safeFetch'; + +// Mirrors the Rust /claude-auth DTOs. Neither response can contain provider credentials. +export type ClaudeAuthStatus = { + enabled: boolean; + connected: boolean; + ephemeral: boolean; +}; +export type ClaudeAuthStart = { + attemptId: string; + authorizationUrl: string; + expiresIn: number; +}; +const host = `${SERVER_HOSTS['agent-harness']}/claude-auth`; + +const authError: ErrorResponseHandler = async (response) => { + const data: unknown = await response.json(); + return { + code: response.status === 401 ? 'UNAUTHORIZED' : 'HTTP_ERROR', + message: + typeof data === 'object' && + data !== null && + 'message' in data && + typeof data.message === 'string' + ? data.message + : 'Claude connection request failed. Start Connect Claude again.', + }; +}; +const writeOptions = { + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' as const, + errorResponseHandler: authError, +}; + +export const claudeAuthClient = { + status(signal?: AbortSignal) { + return fetchWithToken(host, { + method: 'GET', + cache: 'no-store', + signal, + }); + }, + begin() { + return fetchWithToken(`${host}/start`, { + ...writeOptions, + method: 'POST', + body: '{}', + }); + }, + complete(attemptId: string, code: string) { + return fetchWithToken>(`${host}/complete`, { + ...writeOptions, + method: 'POST', + body: JSON.stringify({ attemptId, code }), + }); + }, + disconnect() { + return fetchWithToken>(host, { + ...writeOptions, + method: 'DELETE', + body: '{}', + }); + }, +}; diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts index 15d05b1db33..0b1b76195e9 100644 --- a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts @@ -15,5 +15,6 @@ export type ModelHarnessDto = export const ModelHarnessDto = { 'in-memory': 'in-memory', cursor: 'cursor', + 'claude-cloud': 'claude-cloud', macrod: 'macrod', } as const; diff --git a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json index 0bb631f4158..b7314f3abae 100644 --- a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json +++ b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json @@ -10,6 +10,145 @@ "version": "0.1.0" }, "paths": { + "/claude-auth": { + "get": { + "tags": [ + "claude-auth" + ], + "summary": "Read connection status for the authenticated user only.", + "operationId": "status", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + }, + "401": { + "description": "Unauthenticated" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "claude-auth" + ], + "summary": "Forget only the authenticated user's grant and cancel pending consent.", + "operationId": "disconnect", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Disconnected" + }, + "403": { + "description": "Disabled" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/complete": { + "post": { + "tags": [ + "claude-auth" + ], + "summary": "Exchange one code; never return access or refresh tokens.", + "operationId": "complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Connected" + }, + "400": { + "description": "Invalid code" + }, + "409": { + "description": "Expired or replayed" + }, + "502": { + "description": "Provider failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/start": { + "post": { + "tags": [ + "claude-auth" + ], + "summary": "Create an expiring PKCE challenge for the authenticated user.", + "operationId": "start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartResponse" + } + } + } + }, + "403": { + "description": "Disabled" + }, + "429": { + "description": "Too many attempts" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/agent-models/load": { "post": { "tags": ["agent-models"], @@ -920,6 +1059,78 @@ }, "components": { "schemas": { + "StatusResponse": { + "type": "object", + "description": "Safe connection metadata.", + "required": [ + "enabled", + "connected", + "ephemeral" + ], + "properties": { + "connected": { + "type": "boolean", + "description": "Whether the authenticated Macro user has connected." + }, + "enabled": { + "type": "boolean", + "description": "Whether this deployment supports browser connection." + }, + "ephemeral": { + "type": "boolean", + "description": "Whether reconnecting after service restart is required." + } + } + }, + "StartResponse": { + "type": "object", + "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", + "required": [ + "attemptId", + "authorizationUrl", + "expiresIn" + ], + "properties": { + "attemptId": { + "type": "string", + "description": "Opaque owner-bound attempt handle." + }, + "authorizationUrl": { + "type": "string", + "description": "Claude-hosted consent page." + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Attempt lifetime in seconds.", + "minimum": 0 + } + } + }, + "CompleteRequest": { + "type": "object", + "description": "One-time manual code. Deliberately does not implement Debug.", + "required": [ + "attemptId", + "code" + ], + "properties": { + "attemptId": { + "type": "string", + "description": "Server-issued handle; never an owner selected by the caller." + }, + "code": { + "type": "string", + "description": "Claude's complete code#state string, not an access token." + } + }, + "additionalProperties": false + }, + "EmptyRequest": { + "type": "object", + "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", + "additionalProperties": false + }, "AgentAction": { "oneOf": [ { @@ -1668,7 +1879,7 @@ "ModelHarnessDto": { "type": "string", "description": "Harness names accepted by the model discovery endpoint.", - "enum": ["in-memory", "cursor", "macrod"] + "enum": ["in-memory", "cursor", "claude-cloud", "macrod"] }, "PreviewAgentSessionsRequest": { "type": "object", diff --git a/crates/agent_harness/src/domain/model.rs b/crates/agent_harness/src/domain/model.rs index 1095d4f8876..fe44237e842 100644 --- a/crates/agent_harness/src/domain/model.rs +++ b/crates/agent_harness/src/domain/model.rs @@ -66,6 +66,8 @@ pub enum AgentKind { Cursor, /// A per-owner Codex cloud conversation served over ACP. CodexCloud, + /// Anthropic-hosted Claude Code using the session owner's subscription. + ClaudeCloud, /// The in-process (in-memory) "macro(new)" bot, served by `agent_inmem`. InMemory, /// The bot's operator hosts the runtime and dials the gateway; no @@ -96,6 +98,7 @@ impl AgentKind { match harness { "cursor" => Self::Cursor, "codex-cloud" => Self::CodexCloud, + "claude-cloud" => Self::ClaudeCloud, "in-memory" | "macro-inmem" => Self::InMemory, // Registered macrod harnesses are the deliberate external case: // the agent's `harness_id` names whose daemon serves it. diff --git a/crates/agent_harness/src/domain/model_load.rs b/crates/agent_harness/src/domain/model_load.rs index 6cc491f1a7c..f93101b0fc6 100644 --- a/crates/agent_harness/src/domain/model_load.rs +++ b/crates/agent_harness/src/domain/model_load.rs @@ -19,6 +19,8 @@ pub enum ModelHarness { InMemory, /// The caller's Cursor account. Cursor, + /// The caller's allowlisted Claude subscription demo. + ClaudeCloud, /// A paired macrod runtime. Macrod, } @@ -142,6 +144,15 @@ pub trait CursorModelProbe: Send + Sync + 'static { ) -> impl Future> + Send; } +/// Optional demo provider; disabled deployments advertise no Claude models. +pub trait ClaudeModelProbe: Send + Sync + 'static { + /// Discover availability for this caller, never another user's subscription. + fn probe<'a>( + &'a self, + caller: &'a MacroUserIdStr<'static>, + ) -> std::pin::Pin> + Send + 'a>>; +} + /// Fresh paired-macrod model probe. pub trait MacrodModelProbe: Send + Sync + 'static { /// Probe the live runtime connection for `harness`. @@ -168,6 +179,7 @@ pub struct AgentModelsServiceImpl { cursor: Cursor, macrod: Macrod, timeout: Duration, + claude: Option>, } impl AgentModelsServiceImpl { @@ -185,8 +197,15 @@ impl AgentModelsServiceImpl) -> Self { + self.claude = Some(probe); + self + } } impl AgentModelsService @@ -203,6 +222,12 @@ where request: LoadAgentModels, ) -> Result { let probe = match (request.harness, request.harness_id) { + (ModelHarness::ClaudeCloud, None) => match &self.claude { + Some(probe) => tokio::time::timeout(self.timeout, probe.probe(&caller)) + .await + .map_err(|_| LoadAgentModelsError::Timeout)?, + None => Ok(RawModelProbe::Unsupported), + }, (ModelHarness::InMemory, None) => { tokio::time::timeout(self.timeout, self.in_memory.probe()) .await diff --git a/crates/agent_harness/src/domain/model_load/test.rs b/crates/agent_harness/src/domain/model_load/test.rs index aec7cc9f328..2bd594eb507 100644 --- a/crates/agent_harness/src/domain/model_load/test.rs +++ b/crates/agent_harness/src/domain/model_load/test.rs @@ -11,6 +11,29 @@ fn caller() -> MacroUserIdStr<'static> { MacroUserIdStr::try_from_email("models@example.com").unwrap() } +#[tokio::test] +async fn claude_is_unavailable_unless_explicitly_configured() { + let service = AgentModelsServiceImpl::new( + Access(true), + Probe::new(unsupported), + Probe::new(unsupported), + Probe::new(unsupported), + Duration::from_secs(1), + ); + let result = service + .load( + caller(), + LoadAgentModels { + harness: ModelHarness::ClaudeCloud, + harness_id: None, + }, + ) + .await + .unwrap(); + assert_eq!(result.status, AgentModelsStatus::Unsupported); + assert!(result.models.is_empty()); +} + fn options() -> Vec { vec![SessionConfigOption::select( "model", diff --git a/crates/agent_harness/src/domain/service/queue.rs b/crates/agent_harness/src/domain/service/queue.rs index 4dbb4883e87..00c798ec1e1 100644 --- a/crates/agent_harness/src/domain/service/queue.rs +++ b/crates/agent_harness/src/domain/service/queue.rs @@ -293,6 +293,12 @@ where | HarnessCommand::EditQueued { actor, .. } | HarnessCommand::RemoveQueued { actor, .. } => { let session = self.sessions.get_session(session_id).await?; + if AgentKind::for_session(session.bot_id, &session.harness) + == AgentKind::ClaudeCloud + && actor.as_ref() != Some(&session.owner_id) + { + return Err(AgentSessionError::Forbidden.into()); + } if AgentKind::of(session.bot_id) == AgentKind::SandboxedCoder && !actor.as_ref().is_some_and(is_macro_staff) { diff --git a/crates/agent_harness/src/domain/service/test.rs b/crates/agent_harness/src/domain/service/test.rs index 456b41e57e1..bc66555a5dc 100644 --- a/crates/agent_harness/src/domain/service/test.rs +++ b/crates/agent_harness/src/domain/service/test.rs @@ -556,6 +556,53 @@ async fn open_creates_announces_and_delivers_the_mention() { ); } +#[tokio::test] +async fn claude_cloud_only_accepts_control_from_the_subscription_owner() { + let (service, _repo, containers, _announcer, _runtimes) = harness(); + let mut command = open_command(); + command.runtime.kind = AgentKind::ClaudeCloud; + command.runtime.harness = "claude-cloud".into(); + let owner = command.origin.sender.clone(); + let id = AgentSessionId::new(); + let open = service.execute(id, HarnessCommand::Open(command)); + let drive = async { + while containers.spawned() == 0 { + tokio::task::yield_now().await; + } + let container = containers.container(id).unwrap(); + complete_handshake(&container).await; + }; + let (opened, ()) = tokio::join!(open, drive); + opened.unwrap(); + for actor in [None, Some(staff_sender())] { + let error = service + .control_event( + id, + ControlEvent { + action: AgentAction::prompt("spend another user's subscription"), + actor, + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, AgentSessionError::Forbidden)); + } + // A non-turn control exercises the same owner gate without opening another turn. + let owner_action = service.control_event( + id, + ControlEvent { + action: AgentAction::prompt("owner follow-up"), + actor: Some(owner), + }, + ); + let drive = async { + let container = containers.container(id).unwrap(); + container.agent().completes_prompt().await; + }; + let (accepted, ()) = tokio::join!(owner_action, drive); + accepted.unwrap(); +} + /// The agent's MCP selection is snapshotted onto the session row at open, so /// the proxy enforces exactly what this attach advertised for as long as the /// session lives, whatever the agent is edited to later. diff --git a/crates/agent_harness/src/inbound/kafka/test.rs b/crates/agent_harness/src/inbound/kafka/test.rs index b2f4d566e6b..985a9297f80 100644 --- a/crates/agent_harness/src/inbound/kafka/test.rs +++ b/crates/agent_harness/src/inbound/kafka/test.rs @@ -22,6 +22,7 @@ fn runtime(kind: AgentKind) -> Option { harness: match kind { AgentKind::Cursor => "cursor", AgentKind::CodexCloud => "codex-cloud", + AgentKind::ClaudeCloud => "claude-cloud", AgentKind::InMemory => "in-memory", AgentKind::SandboxedCoder => "opencode", AgentKind::External => "byoa", diff --git a/crates/agent_harness/src/inbound/model_load.rs b/crates/agent_harness/src/inbound/model_load.rs index c8dee480ba8..7f902774004 100644 --- a/crates/agent_harness/src/inbound/model_load.rs +++ b/crates/agent_harness/src/inbound/model_load.rs @@ -41,6 +41,8 @@ pub enum ModelHarnessDto { InMemory, /// The caller's Cursor account. Cursor, + /// The caller's allowlisted Claude subscription demo. + ClaudeCloud, /// A paired macrod runtime. Macrod, } @@ -52,6 +54,7 @@ impl TryFrom for LoadAgentModels { let harness = match value.harness { ModelHarnessDto::InMemory => ModelHarness::InMemory, ModelHarnessDto::Cursor => ModelHarness::Cursor, + ModelHarnessDto::ClaudeCloud => ModelHarness::ClaudeCloud, ModelHarnessDto::Macrod => ModelHarness::Macrod, }; Ok(Self { diff --git a/crates/agent_harness/src/outbound/routing.rs b/crates/agent_harness/src/outbound/routing.rs index 992931ca3c3..c1536bc1c86 100644 --- a/crates/agent_harness/src/outbound/routing.rs +++ b/crates/agent_harness/src/outbound/routing.rs @@ -77,7 +77,7 @@ where .spawn(command) .await? .map_transport(RoutedTransport::Sandbox)), - AgentKind::External => Err(external_is_unroutable()), + AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), } } @@ -102,7 +102,7 @@ where .resume(session) .await? .map_transport(RoutedTransport::Sandbox)), - AgentKind::External => Err(external_is_unroutable()), + AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), } } @@ -114,7 +114,7 @@ where AgentKind::SandboxedCoder | AgentKind::InMemory => { self.sandbox.session_token(session).await } - AgentKind::External => Err(external_is_unroutable()), + AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), } } @@ -124,7 +124,7 @@ where AgentKind::CodexCloud => self.codex.teardown(session).await, AgentKind::Cursor => self.cursor.teardown(session).await, AgentKind::SandboxedCoder | AgentKind::InMemory => self.sandbox.teardown(session).await, - AgentKind::External => Err(external_is_unroutable()), + AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), } } @@ -148,7 +148,7 @@ where AgentKind::InMemory => Err(HarnessError::Container( "an in-memory session has no sandbox to resize".to_owned(), )), - AgentKind::External => Err(external_is_unroutable()), + AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), } } } @@ -158,7 +158,9 @@ where /// corrupt session row or a broken gate, and refusing loudly beats picking a /// provider that cannot serve it. fn external_is_unroutable() -> HarnessError { - HarnessError::Container("an external bot's session has no provider to route to".to_owned()) + HarnessError::Container( + "this session requires a provider outside the sandbox/Cursor router".to_owned(), + ) } /// A transport that is one provider's or the other's, decided per session. diff --git a/crates/agent_session/src/domain/model.rs b/crates/agent_session/src/domain/model.rs index af3842d1387..537ab1e3bbc 100644 --- a/crates/agent_session/src/domain/model.rs +++ b/crates/agent_session/src/domain/model.rs @@ -280,6 +280,25 @@ pub struct ExternalSession { pub last_run_id: Option, } +impl ExternalSession { + /// Provider link, including Claude demo mappings created before URLs were saved. + pub fn web_url(&self) -> Option { + self.external_url.clone().or_else(|| { + (self.provider == "claude-cloud" + && self.external_id.starts_with("cse_") + && self.external_id.len() > 4 + && self + .external_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_')) + .then(|| format!("https://claude.ai/code/{}", self.external_id)) + }) + } +} + +#[cfg(test)] +mod test; + /// The agent behind a session, as much of it as rendering a message needs. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] diff --git a/crates/agent_session/src/domain/model/test.rs b/crates/agent_session/src/domain/model/test.rs new file mode 100644 index 00000000000..2a9b33d723e --- /dev/null +++ b/crates/agent_session/src/domain/model/test.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn legacy_claude_links_are_derived_without_changing_other_providers() { + let mut external = ExternalSession { + provider: "claude-cloud".into(), + external_id: "cse_demo".into(), + external_name: None, + external_url: None, + last_run_id: None, + }; + assert_eq!( + external.web_url().as_deref(), + Some("https://claude.ai/code/cse_demo") + ); + external.external_id = "cse_x/../../other".into(); + assert!(external.web_url().is_none()); + external.provider = "claude-cloud-create-pending".into(); + external.external_id = "cse_demo".into(); + assert!(external.web_url().is_none()); + external.provider = "cursor".into(); + assert!(external.web_url().is_none()); + external.external_url = Some("https://cursor.com/agents/bc-test".into()); + assert_eq!(external.web_url(), external.external_url); +} diff --git a/crates/agent_session/src/inbound/axum_router.rs b/crates/agent_session/src/inbound/axum_router.rs index e4daf3d4f48..1e5ddeb0526 100644 --- a/crates/agent_session/src/inbound/axum_router.rs +++ b/crates/agent_session/src/inbound/axum_router.rs @@ -513,10 +513,11 @@ pub struct ExternalSessionResponse { impl From for ExternalSessionResponse { fn from(external: ExternalSession) -> Self { + let url = external.web_url(); Self { provider: external.provider, name: external.external_name, - url: external.external_url, + url, } } } diff --git a/crates/claude_cloud_agents/Cargo.toml b/crates/claude_cloud_agents/Cargo.toml new file mode 100644 index 00000000000..dfe2f14d484 --- /dev/null +++ b/crates/claude_cloud_agents/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "claude_cloud_agents" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +async-trait = { workspace = true } +sqlx = { workspace = true } +cursor_api_key = { path = "../cursor_api_key" } +axum = { workspace = true } +base64 = { workspace = true } +macro_authorization = { path = "../macro_authorization", features = ["axum"] } +rand = { workspace = true } +sha2 = { workspace = true } +subtle = "2" +utoipa = { workspace = true } +agent-runtime-protocol = { package = "agent_runtime_protocol", path = "../agent_runtime_protocol", default-features = false, features = ["transport"] } +futures = { workspace = true } +reqwest = { workspace = true, features = ["stream", "query"] } +serde = { workspace = true } +serde_json = { workspace = true } +sse-core = { version = "0.2.3", default-features = false, features = ["std"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "sync", "time", "macros"] } +uuid = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +macro_db_migrator = { path = "../macro_db_migrator" } +tempfile = "3" +clap = { workspace = true, features = ["derive"] } diff --git a/crates/claude_cloud_agents/examples/smoke.rs b/crates/claude_cloud_agents/examples/smoke.rs new file mode 100644 index 00000000000..77655c0cd58 --- /dev/null +++ b/crates/claude_cloud_agents/examples/smoke.rs @@ -0,0 +1,110 @@ +//! Explicit opt-in live smoke test. Credentials are read privately, never printed. +use agent_runtime_protocol::domain::{ + connection::ServerChannel, + schema::v0::{AcpMessage, ToRuntimeMessage, ToServerMessage}, +}; +use clap::Parser; +use claude_cloud_agents::{ + domain::{model::SessionId, service::Session}, + inbound::acp, + outbound::{credentials::FileCredentials, http::Client}, +}; +use serde_json::{Value, json}; + +#[derive(Parser)] +struct Args { + #[arg(long)] + credentials: std::path::PathBuf, + #[arg(long)] + owner: String, + /// Existing cloud conversation. Omitting this creates one new conversation. + #[arg(long)] + session: Option, +} + +fn send(channel: &ServerChannel, value: Value) { + channel + .tx + .send(ToRuntimeMessage::Acp(AcpMessage( + serde_json::from_value(value).unwrap(), + ))) + .unwrap(); +} + +async fn response(channel: &mut ServerChannel, id: u8) -> Value { + loop { + let message = tokio::time::timeout(std::time::Duration::from_secs(180), channel.rx.recv()) + .await + .expect("cloud response timeout") + .expect("ACP connection closed"); + if let ToServerMessage::Acp(AcpMessage(frame)) = message { + let frame = serde_json::to_value(frame).unwrap(); + if frame["id"] == id { + return frame; + } + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let credentials = FileCredentials::open(args.credentials).await?; + let client = Client::new(credentials, args.owner).await?; + let id = match args.session { + Some(id) => SessionId::parse(&id)?, + None => client.create("").await?, + }; + println!("Testing Claude cloud session {}", id.as_str()); + let session = Session::new(client, id.clone()); + let mut channel = acp::attach(session); + send( + &channel, + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}), + ); + assert!(response(&mut channel, 1).await.get("result").is_some()); + send( + &channel, + json!({"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":id.as_str(),"cwd":"/","mcpServers":[]}}), + ); + let loaded = response(&mut channel, 2).await; + assert!( + loaded.get("result").is_some(), + "history load failed: {loaded}" + ); + send( + &channel, + json!({"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":id.as_str(),"prompt":[{"type":"text","text":"Reply exactly MACRO_CLAUDE_HARNESS_OK. Do not use tools, read files, access the network, or modify anything."}]}}), + ); + let mut text = String::new(); + let mut chunks = 0; + loop { + let message = tokio::time::timeout(std::time::Duration::from_secs(180), channel.rx.recv()) + .await? + .ok_or("ACP closed")?; + let ToServerMessage::Acp(AcpMessage(frame)) = message else { + continue; + }; + let frame = serde_json::to_value(frame)?; + if frame["params"]["update"]["sessionUpdate"] == "agent_message_chunk" { + text.push_str( + frame["params"]["update"]["content"]["text"] + .as_str() + .ok_or("missing text")?, + ); + chunks += 1; + } + if frame["id"] == 3 { + assert_eq!( + frame["result"]["stopReason"], "end_turn", + "turn failed: {frame}" + ); + break; + } + } + assert_eq!(text.trim(), "MACRO_CLAUDE_HARNESS_OK"); + println!( + "PASS: ACP initialize + history load + prompt + {chunks} text chunks + successful completion. Cloud transcript retained." + ); + Ok(()) +} diff --git a/crates/claude_cloud_agents/src/domain/auth.rs b/crates/claude_cloud_agents/src/domain/auth.rs new file mode 100644 index 00000000000..c1c4b741e19 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/auth.rs @@ -0,0 +1,231 @@ +//! One-time, owner-bound browser consent. No provider tokens cross the inbound boundary. +use super::model::{Credentials, Error, Secret}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use subtle::ConstantTimeEq; +use tokio::sync::Mutex; + +const LOGIN_TTL: Duration = Duration::from_secs(600); +const MAX_ATTEMPTS: usize = 1024; + +/// Redacted, user-actionable connection errors. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + /// This deployment does not enable interactive demo connections. + #[error("Claude connection is available only in the local demo")] + Disabled, + /// Attempt expired, was canceled, replayed, or belongs to another user. + #[error("Sign-in expired or was replaced. Start Connect Claude again.")] + InvalidAttempt, + /// Manual callback input must contain Claude's code and matching state. + #[error("Paste the complete one-time code from Claude, including the # suffix.")] + InvalidCode, + /// Global capacity or repeated starts are bounded. + #[error("Please wait a moment before starting another sign-in.")] + Busy, + /// Provider or storage failures never include response bodies or secrets. + #[error("Claude connection failed: {0}")] + Provider(#[from] Error), +} + +/// Browser-safe status; tokens and provider identifiers are never returned. +pub struct ConnectionStatus { + /// Whether connecting is enabled in this deployment. + pub enabled: bool, + /// Whether the authenticated user has a grant. + pub connected: bool, + /// In-memory connections disappear when the service restarts. + pub ephemeral: bool, +} + +/// Browser-safe start response. +pub struct Login { + /// Opaque attempt handle bound to the initiating Macro user. + pub attempt_id: String, + /// Fixed-provider consent URL containing only public PKCE information. + pub authorization_url: String, + /// Seconds until this attempt expires. + pub expires_in: u64, +} + +/// OAuth protocol capability; implemented outside the domain. +pub trait OAuthProvider: Send + Sync + 'static { + /// Construct the fixed-origin consent URL. + fn authorization_url(&self, state: &str, verifier: &Secret) -> String; + /// Exchange a one-time code and resolve the grant's cloud environment. + fn exchange( + &self, + code: Secret, + state: &str, + verifier: Secret, + ) -> impl Future> + Send; +} + +/// Owner-keyed credential storage capability. +pub trait ConnectionStore: Send + Sync + 'static { + /// Query presence without returning secrets. + fn connected(&self, owner: &str) -> impl Future + Send; + /// Save a grant for exactly the authenticated owner. + fn save( + &self, + owner: &str, + credentials: Credentials, + ) -> impl Future> + Send; + /// Forget only this owner's grant. Does not revoke unrelated provider sessions. + fn remove(&self, owner: &str) -> impl Future> + Send; +} + +/// Use cases exposed to the HTTP adapter. +pub trait ClaudeAuth: Send + Sync + 'static { + /// Return the current user's connection status. + fn status(&self, owner: &str) -> impl Future + Send; + /// Start a new user-bound PKCE attempt, replacing older attempts. + fn begin(&self, owner: &str) -> impl Future> + Send; + /// Consume a code once and save the resulting grant for its initiator. + fn complete( + &self, + owner: &str, + attempt_id: &str, + code: Secret, + ) -> impl Future> + Send; + /// Cancel pending consent and forget this user's connection. + fn disconnect(&self, owner: &str) -> impl Future> + Send; +} + +struct Attempt { + id: String, + state: String, + verifier: Option, + started: Instant, +} + +/// Local-demo policy: bounded, expiring, one-use attempts, bound to authenticated users. +pub struct AuthService { + provider: P, + store: Option, + ephemeral: bool, + attempts: Arc>>, +} + +impl AuthService { + /// Enable via a store supplied by the composition root; None disables all writes. + pub fn new(provider: P, store: Option, ephemeral: bool) -> Self { + Self { + provider, + store, + ephemeral, + attempts: Arc::default(), + } + } +} + +fn random_secret() -> String { + URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) +} + +impl ClaudeAuth for AuthService { + async fn status(&self, owner: &str) -> ConnectionStatus { + ConnectionStatus { + enabled: self.store.is_some(), + connected: match &self.store { + Some(store) => store.connected(owner).await, + None => false, + }, + ephemeral: self.ephemeral, + } + } + + async fn begin(&self, owner: &str) -> Result { + self.store.as_ref().ok_or(AuthError::Disabled)?; + let mut attempts = self.attempts.lock().await; + attempts.retain(|_, attempt| attempt.started.elapsed() < LOGIN_TTL); + if attempts + .get(owner) + .is_some_and(|a| a.started.elapsed() < Duration::from_secs(2)) + || (!attempts.contains_key(owner) && attempts.len() >= MAX_ATTEMPTS) + { + return Err(AuthError::Busy); + } + let state = random_secret(); + let verifier = Secret::parse(random_secret())?; + let attempt_id = random_secret(); + let authorization_url = self.provider.authorization_url(&state, &verifier); + attempts.insert( + owner.to_owned(), + Attempt { + id: attempt_id.clone(), + state, + verifier: Some(verifier), + started: Instant::now(), + }, + ); + Ok(Login { + attempt_id, + authorization_url, + expires_in: LOGIN_TTL.as_secs(), + }) + } + + async fn complete( + &self, + owner: &str, + attempt_id: &str, + input: Secret, + ) -> Result<(), AuthError> { + let store = self.store.as_ref().ok_or(AuthError::Disabled)?; + let raw = input.expose().trim(); + if raw.len() > 4096 { + return Err(AuthError::InvalidCode); + } + let (code, state) = raw.split_once('#').ok_or(AuthError::InvalidCode)?; + if code.is_empty() + || !code + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.~".contains(&b)) + { + return Err(AuthError::InvalidCode); + } + let verifier = { + let mut attempts = self.attempts.lock().await; + let attempt = attempts.get_mut(owner).ok_or(AuthError::InvalidAttempt)?; + if attempt.id != attempt_id || attempt.started.elapsed() >= LOGIN_TTL { + return Err(AuthError::InvalidAttempt); + } + if !bool::from(attempt.state.as_bytes().ct_eq(state.as_bytes())) { + return Err(AuthError::InvalidCode); + } + // Taking the verifier consumes the attempt before making any network request. + attempt.verifier.take().ok_or(AuthError::InvalidAttempt)? + }; + let result = self + .provider + .exchange(Secret::parse(code.to_owned())?, state, verifier) + .await; + let mut attempts = self.attempts.lock().await; + if !attempts + .get(owner) + .is_some_and(|a| a.id == attempt_id && a.started.elapsed() < LOGIN_TTL) + { + return Err(AuthError::InvalidAttempt); + } + attempts.remove(owner); + // Serialize save against disconnect/restart of consent; late completion cannot reconnect. + store.save(owner, result?).await?; + Ok(()) + } + + async fn disconnect(&self, owner: &str) -> Result<(), AuthError> { + let store = self.store.as_ref().ok_or(AuthError::Disabled)?; + let mut attempts = self.attempts.lock().await; + attempts.remove(owner); + store.remove(owner).await?; + Ok(()) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/domain/auth/test.rs b/crates/claude_cloud_agents/src/domain/auth/test.rs new file mode 100644 index 00000000000..b809e208286 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/auth/test.rs @@ -0,0 +1,187 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Clone, Default)] +struct Store(Arc>>); +impl ConnectionStore for Store { + async fn connected(&self, owner: &str) -> bool { + self.0.lock().await.contains_key(owner) + } + async fn save(&self, owner: &str, c: Credentials) -> Result<(), Error> { + self.0.lock().await.insert(owner.into(), c); + Ok(()) + } + async fn remove(&self, owner: &str) -> Result<(), Error> { + self.0.lock().await.remove(owner); + Ok(()) + } +} +#[derive(Default)] +struct Provider { + calls: AtomicUsize, + fail: bool, + gate: Option>, +} +fn secret(s: &str) -> Secret { + Secret::parse(s.to_owned()).unwrap() +} +impl OAuthProvider for Provider { + fn authorization_url(&self, state: &str, _: &Secret) -> String { + format!("https://claude.com/consent?state={state}") + } + async fn exchange(&self, _: Secret, _: &str, _: Secret) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(gate) = &self.gate { + gate.acquire().await.unwrap().forget(); + } + if self.fail { + return Err(Error::Authorization); + } + Ok(Credentials { + access_token: secret("access"), + refresh_token: Some(secret("refresh")), + expires_at: u64::MAX, + organization_id: "org".into(), + environment_id: "env_test".into(), + }) + } +} +fn code(login: &Login) -> Secret { + secret(&format!( + "one-time-code#{}", + login.authorization_url.split("state=").nth(1).unwrap() + )) +} + +#[tokio::test] +async fn owner_bound_single_use_and_disconnect() { + let store = Store::default(); + let service = AuthService::new(Provider::default(), Some(store.clone()), true); + let login = service.begin("macro|alice").await.unwrap(); + assert!(matches!( + service + .complete("macro|bob", &login.attempt_id, code(&login)) + .await, + Err(AuthError::InvalidAttempt) + )); + assert_eq!(service.provider.calls.load(Ordering::SeqCst), 0); + assert!(matches!( + service + .complete("macro|alice", &login.attempt_id, secret("code#wrong-state")) + .await, + Err(AuthError::InvalidCode) + )); + service + .complete("macro|alice", &login.attempt_id, code(&login)) + .await + .unwrap(); + assert!(service.status("macro|alice").await.connected); + assert!(!service.status("macro|bob").await.connected); + assert!(matches!( + service + .complete("macro|alice", &login.attempt_id, code(&login)) + .await, + Err(AuthError::InvalidAttempt) + )); + service.disconnect("macro|bob").await.unwrap(); + assert!(service.status("macro|alice").await.connected); + service.disconnect("macro|alice").await.unwrap(); + assert!(!service.status("macro|alice").await.connected); +} + +#[tokio::test] +async fn expiry_replacement_and_rate_limit() { + let service = AuthService::new(Provider::default(), Some(Store::default()), true); + let old = service.begin("alice").await.unwrap(); + assert!(matches!(service.begin("alice").await, Err(AuthError::Busy))); + service + .attempts + .lock() + .await + .get_mut("alice") + .unwrap() + .started -= LOGIN_TTL; + assert!(matches!( + service.complete("alice", &old.attempt_id, code(&old)).await, + Err(AuthError::InvalidAttempt) + )); + let new = service.begin("alice").await.unwrap(); + assert_ne!(old.attempt_id, new.attempt_id); + assert!(matches!( + service.complete("alice", &old.attempt_id, code(&old)).await, + Err(AuthError::InvalidAttempt) + )); + service + .complete("alice", &new.attempt_id, code(&new)) + .await + .unwrap(); +} + +#[tokio::test] +async fn failure_consumes_code_and_disabled_never_starts() { + let disabled = AuthService::<_, Store>::new(Provider::default(), None, true); + assert!(!disabled.status("alice").await.enabled); + assert!(matches!( + disabled.begin("alice").await, + Err(AuthError::Disabled) + )); + let service = AuthService::new( + Provider { + fail: true, + ..Default::default() + }, + Some(Store::default()), + true, + ); + let login = service.begin("alice").await.unwrap(); + assert!(matches!( + service + .complete("alice", &login.attempt_id, code(&login)) + .await, + Err(AuthError::Provider(_)) + )); + assert!(!service.status("alice").await.connected); + assert!(matches!( + service + .complete("alice", &login.attempt_id, code(&login)) + .await, + Err(AuthError::InvalidAttempt) + )); + assert_eq!(service.provider.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn disconnect_during_exchange_cannot_reconnect() { + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let service = Arc::new(AuthService::new( + Provider { + gate: Some(gate.clone()), + ..Default::default() + }, + Some(Store::default()), + true, + )); + let login = service.begin("alice").await.unwrap(); + let running = { + let service = service.clone(); + tokio::spawn(async move { + service + .complete("alice", &login.attempt_id, code(&login)) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(1), async { + while service.provider.calls.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + service.disconnect("alice").await.unwrap(); + gate.add_permits(1); + assert!(matches!( + running.await.unwrap(), + Err(AuthError::InvalidAttempt) + )); + assert!(!service.status("alice").await.connected); +} diff --git a/crates/claude_cloud_agents/src/domain/credentials.rs b/crates/claude_cloud_agents/src/domain/credentials.rs new file mode 100644 index 00000000000..3ff08342cd1 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/credentials.rs @@ -0,0 +1,102 @@ +//! Owner-scoped grant lifecycle, shared by browser consent and provider calls. +use super::{ + auth::ConnectionStore, + model::{Credentials, Error, Result}, +}; +use std::{ + collections::BTreeMap, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Mutex; + +#[cfg(test)] +mod test; + +/// Persistence stores only the requested owner's grant. +#[async_trait::async_trait] +pub trait GrantRepository: Send + Sync { + /// Load one grant. + async fn get(&self, owner: &str) -> Result>; + /// Persist one grant, including refresh-token rotation. + async fn put(&self, owner: &str, grant: &Credentials) -> Result<()>; + /// Forget one grant. + async fn delete(&self, owner: &str) -> Result<()>; +} + +/// Provider refresh capability; never retries a token exchange automatically. +#[async_trait::async_trait] +pub trait RefreshGrant: Send + Sync { + /// Refresh an expired grant. + async fn refresh(&self, grant: &Credentials) -> Result; +} + +/// Single-replica demo service. Serializes refresh, connect, and disconnect. +#[derive(Clone)] +pub struct AccountCredentials { + repository: Arc, + refresher: Arc, + // Keep rotated grants after a persistence failure so we never reuse an old + // refresh token. The next operation retries persistence before using them. + pending: Arc>>, +} + +impl AccountCredentials { + /// Wire persistence and refresh ports at the composition root. + pub fn new(repository: Arc, refresher: Arc) -> Self { + Self { + repository, + refresher, + pending: Arc::default(), + } + } + + /// Presence for this exact owner, without exposing a grant. + pub async fn contains(&self, owner: &str) -> bool { + self.repository.get(owner).await.ok().flatten().is_some() + } + + /// Resolve and persist a refresh before a provider operation. + pub async fn resolve(&self, owner: &str) -> Result { + let mut pending = self.pending.lock().await; + if let Some(grant) = pending.get(owner) { + self.repository.put(owner, grant).await?; + pending.remove(owner); + } + let grant = self + .repository + .get(owner) + .await? + .ok_or(Error::NotConnected)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Credentials)? + .as_secs(); + if grant.expires_at > now + 120 { + return Ok(grant); + } + let refreshed = self.refresher.refresh(&grant).await?; + pending.insert(owner.to_owned(), refreshed.clone()); + self.repository.put(owner, &refreshed).await?; + pending.remove(owner); + Ok(refreshed) + } +} + +impl ConnectionStore for AccountCredentials { + async fn connected(&self, owner: &str) -> bool { + self.contains(owner).await + } + async fn save(&self, owner: &str, grant: Credentials) -> Result<()> { + let mut pending = self.pending.lock().await; + self.repository.put(owner, &grant).await?; + pending.remove(owner); + Ok(()) + } + async fn remove(&self, owner: &str) -> Result<()> { + let mut pending = self.pending.lock().await; + self.repository.delete(owner).await?; + pending.remove(owner); + Ok(()) + } +} diff --git a/crates/claude_cloud_agents/src/domain/credentials/test.rs b/crates/claude_cloud_agents/src/domain/credentials/test.rs new file mode 100644 index 00000000000..e000af1baf6 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/credentials/test.rs @@ -0,0 +1,82 @@ +use super::*; +use crate::domain::model::Secret; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +#[derive(Default)] +struct Repo { + data: Mutex>, + fail: AtomicBool, +} +#[async_trait::async_trait] +impl GrantRepository for Repo { + async fn get(&self, owner: &str) -> Result> { + Ok(self.data.lock().await.get(owner).cloned()) + } + async fn put(&self, owner: &str, grant: &Credentials) -> Result<()> { + if self.fail.load(Ordering::SeqCst) { + return Err(Error::Credentials); + } + self.data.lock().await.insert(owner.into(), grant.clone()); + Ok(()) + } + async fn delete(&self, owner: &str) -> Result<()> { + self.data.lock().await.remove(owner); + Ok(()) + } +} +#[derive(Default)] +struct Refresh(AtomicUsize); +#[async_trait::async_trait] +impl RefreshGrant for Refresh { + async fn refresh(&self, grant: &Credentials) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + let mut updated = grant.clone(); + updated.expires_at = 4_000_000_000; + updated.refresh_token = Some(Secret::parse("rotated".into()).unwrap()); + Ok(updated) + } +} +#[tokio::test] +async fn refresh_rotation_retries_persistence_without_reusing_old_token_and_disconnect_wins() { + let repo = Arc::new(Repo::default()); + let refresh = Arc::new(Refresh::default()); + let service = AccountCredentials::new(repo.clone(), refresh.clone()); + service + .save( + "alice", + Credentials { + access_token: Secret::parse("access".into()).unwrap(), + refresh_token: Some(Secret::parse("old".into()).unwrap()), + expires_at: 0, + organization_id: "org".into(), + environment_id: "env_test".into(), + }, + ) + .await + .unwrap(); + assert!(matches!( + service.resolve("bob").await, + Err(Error::NotConnected) + )); + repo.fail.store(true, Ordering::SeqCst); + assert!(service.resolve("alice").await.is_err()); + assert!(service.resolve("alice").await.is_err()); + assert_eq!(refresh.0.load(Ordering::SeqCst), 1); + repo.fail.store(false, Ordering::SeqCst); + assert_eq!( + service + .resolve("alice") + .await + .unwrap() + .refresh_token + .unwrap() + .expose(), + "rotated" + ); + service.remove("alice").await.unwrap(); + assert!(matches!( + service.resolve("alice").await, + Err(Error::NotConnected) + )); + assert_eq!(refresh.0.load(Ordering::SeqCst), 1); +} diff --git a/crates/claude_cloud_agents/src/domain/mod.rs b/crates/claude_cloud_agents/src/domain/mod.rs new file mode 100644 index 00000000000..b79470285ff --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/mod.rs @@ -0,0 +1,9 @@ +//! Provider-independent session behavior and capabilities. +/// Owner-bound interactive OAuth connection use cases. +pub mod auth; +pub mod credentials; +pub mod model; +pub mod models; +pub mod ports; +pub mod service; +pub mod translate; diff --git a/crates/claude_cloud_agents/src/domain/model.rs b/crates/claude_cloud_agents/src/domain/model.rs new file mode 100644 index 00000000000..0cc9aafb53a --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/model.rs @@ -0,0 +1,145 @@ +//! Validated provider identities and safe failures. +use serde::{Deserialize, Serialize}; + +/// The deliberately bounded failures exposed to Macro. Never includes HTTP bodies. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Invalid choice or a provider-rejected switch; does not expose provider bodies. + #[error( + "Claude could not select that model. Choose another model available on your subscription." + )] + ModelUnavailable, + /// The demo requires one unambiguous active Claude Code cloud environment. + #[error( + "This demo needs exactly one active Claude Code cloud environment. Check Claude Code on the web first." + )] + CloudEnvironment, + /// No configured credentials for this session owner. + #[error("Connect Claude for this Macro user before starting a Claude Cloud session")] + NotConnected, + /// Expired, revoked, or insufficient OAuth grant. + #[error("Claude authorization failed; reconnect Claude")] + Authorization, + /// Provider rejected a request; body is intentionally omitted. + #[error("Claude cloud request failed (HTTP {0})")] + Http(u16), + /// Transport failed, with secrets and response bodies omitted. + #[error("Claude cloud connection interrupted")] + Network, + /// A malformed or unsupported response. + #[error("Unsupported Claude cloud response")] + Protocol, + /// Local credential storage cannot be used safely. + #[error("Claude credential storage is unavailable or invalid; retry or reconnect Claude")] + Credentials, + /// An uncertain create must not be automatically repeated. + #[error( + "Claude session creation outcome is uncertain; inspect the Claude account before retrying" + )] + UncertainCreate, + /// A stream replay cannot be safely reconciled. + #[error("Claude history recovery is required; reconnect the session")] + Recovery, + /// The local or cloud conversation still has an unfinished turn. + #[error( + "Claude still has a turn in progress; stop it or wait and reload before prompting again" + )] + Busy, +} + +/// One operation's safe result. +pub type Result = std::result::Result; + +/// A validated Claude cloud session identifier. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SessionId(String); +impl SessionId { + /// Validate before interpolation in URLs. + pub fn parse(value: &str) -> Result { + if !value.starts_with("cse_") + || value.len() <= 4 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::Protocol); + } + Ok(Self(value.to_owned())) + } + /// Provider's wire identifier. + pub fn as_str(&self) -> &str { + &self.0 + } + /// Claude's web UI for this cloud conversation. + pub fn web_url(&self) -> String { + format!("https://claude.ai/code/{}", self.0) + } +} + +/// An OAuth credential, redacted in diagnostics and zeroized on drop. +#[derive(Clone)] +pub struct Secret(zeroize::Zeroizing); +impl Secret { + /// Reject empty credentials and header-control characters. + pub fn parse(value: String) -> Result { + if value.is_empty() + || value.len() > 16_384 + || value + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + { + return Err(Error::Credentials); + } + Ok(Self(zeroize::Zeroizing::new(value))) + } + /// Plaintext, only for the provider or private persistence. + pub fn expose(&self) -> &str { + &self.0 + } +} +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Secret([REDACTED])") + } +} +impl Serialize for Secret { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(self.expose()) + } +} +impl<'de> Deserialize<'de> for Secret { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// Owner-bound demo credentials; this structure must never be returned by an API. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Credentials { + /// Short-lived OAuth bearer. + pub access_token: Secret, + /// Refresh grant, when consent provided one. + pub refresh_token: Option, + /// Unix timestamp in seconds. + pub expires_at: u64, + /// Claude organization selected at consent. + pub organization_id: String, + /// Environment explicitly selected for this account. + pub environment_id: String, +} + +/// A decoded provider SSE event, independent of the HTTP implementation. +#[derive(Debug, Clone)] +pub struct Event { + /// SSE event name. + pub kind: String, + /// Decoded JSON payload. + pub data: serde_json::Value, + /// Durable sequence number, absent on ephemeral deltas. + pub sequence: Option, +} diff --git a/crates/claude_cloud_agents/src/domain/models.rs b/crates/claude_cloud_agents/src/domain/models.rs new file mode 100644 index 00000000000..20d3ae66b1d --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/models.rs @@ -0,0 +1,139 @@ +//! Provider-reported catalogs and validated, open-ended model identifiers. +use super::{ + model::{Error, Event, Result}, + ports::Cloud, +}; + +/// An open-ended model ID; default retains the original Macro persisted ID. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Model(String); +impl Default for Model { + fn default() -> Self { + Self("claude-default".into()) + } +} +impl Model { + /// Validate shape, not membership: membership belongs to the session catalog. + pub fn parse(value: &str) -> Result { + if matches!(value, "default" | "claude-default") { + return Ok(Self::default()); + } + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:[]".contains(&b)) + { + return Err(Error::ModelUnavailable); + } + Ok(Self(value.into())) + } + /// Stable Macro option ID. + pub fn id(&self) -> &str { + &self.0 + } + /// Provider reset uses null, not the Macro-specific default ID. + pub fn provider_value(&self) -> Option<&str> { + (self != &Self::default()).then_some(self.id()) + } +} + +/// One choice reported by the cloud worker. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModelOption { + /// Provider model ID, normalized only for default. + pub model: Model, + /// Provider display name. + pub name: String, + /// Provider description, when present. + pub description: Option, +} + +/// No catalog yet is distinct from a reported empty catalog. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Catalog { + /// Bootstrap without claiming account-entitled models. + #[default] + Unknown, + /// Complete, ordered catalog from a successful initialization response. + Reported(Vec), +} +impl Catalog { + /// Invalid events cannot replace the last usable catalog. + pub fn from_event(event: &Event) -> Option { + if event.kind != "client_event" { + return None; + } + let payload = &event.data["payload"]; + let response = &payload["response"]; + if payload["type"] != "control_response" || response["subtype"] != "success" { + return None; + } + let models = response["response"]["models"].as_array()?; + if models.len() > 256 { + return None; + } + let mut seen = std::collections::BTreeSet::new(); + let mut options = Vec::new(); + for value in models { + let model = Model::parse(value["value"].as_str()?).ok()?; + let name = value["displayName"].as_str()?; + if name.trim().is_empty() || name.len() > 512 || !seen.insert(model.id().to_owned()) { + return None; + } + let description = match value.get("description") { + None | Some(serde_json::Value::Null) => None, + Some(value) => Some(value.as_str().filter(|s| s.len() <= 4096)?.to_owned()), + }; + options.push(ModelOption { + model, + name: name.into(), + description, + }); + } + Some(Self::Reported(options)) + } + /// Use the newest valid catalog in ordered durable history. + pub fn from_history(events: &[Event]) -> Self { + events + .iter() + .rev() + .find_map(Self::from_event) + .unwrap_or_default() + } + /// Bootstrap offers only the provider's reset behavior. + pub fn options(&self) -> Vec { + match self { + Self::Reported(options) => options.clone(), + Self::Unknown => vec![ModelOption { + model: Model::default(), name: "Claude · subscription default".into(), + description: Some("No Claude model catalog reported yet. Start a session with default; choices appear when Claude reports them.".into()), + }], + } + } + /// Reject choices absent from the latest provider catalog. + pub fn contains(&self, model: &Model) -> bool { + self.options().iter().any(|option| &option.model == model) + } +} + +/// Discover without creating sessions or inference. The source is account-scoped; +/// catalogs are never cached across owners. This is a last-reported catalog, +/// not a fresh entitlement guarantee. +pub async fn discover(cloud: &C) -> Result { + for id in cloud.recent_sessions().await?.into_iter().take(5) { + let history = match cloud.history(&id).await { + Ok(history) => history, + Err(Error::Http(404 | 410)) => continue, + Err(error) => return Err(error), + }; + let catalog = Catalog::from_history(&history); + if matches!(catalog, Catalog::Reported(_)) { + return Ok(catalog); + } + } + Ok(Catalog::Unknown) +} + +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/domain/models/test.rs b/crates/claude_cloud_agents/src/domain/models/test.rs new file mode 100644 index 00000000000..55471f2c4a2 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/models/test.rs @@ -0,0 +1,131 @@ +use super::*; +use serde_json::json; + +fn event(models: serde_json::Value) -> Event { + Event { + kind: "client_event".into(), + sequence: Some(1), + data: json!({"payload":{"type":"control_response","response":{"subtype":"success","response":{"models":models}}}}), + } +} + +#[test] +fn new_provider_ids_labels_descriptions_and_order_need_no_code_change() { + let catalog = Catalog::from_event(&event(json!([ + {"value":"brand-new-model[1m]","displayName":"New model","description":"Provider description"}, + {"value":"default","displayName":"Default (recommended)"} + ]))).unwrap(); + let options = catalog.options(); + assert_eq!(options[0].model.id(), "brand-new-model[1m]"); + assert_eq!(options[0].name, "New model"); + assert_eq!( + options[0].description.as_deref(), + Some("Provider description") + ); + assert_eq!(options[1].model.id(), "claude-default"); + assert!(options[1].model.provider_value().is_none()); + assert!(!catalog.contains(&Model::parse("opus").unwrap())); +} + +#[test] +fn newest_valid_catalog_replaces_instead_of_merging_and_empty_is_authoritative() { + let old = event(json!([{"value":"old","displayName":"Old"}])); + let new = event(json!([{"value":"new","displayName":"New"}])); + let invalid = event(json!([{"value":"bad/id","displayName":"Bad"}])); + let catalog = Catalog::from_history(&[old, new, invalid]); + assert_eq!(catalog.options().len(), 1); + assert_eq!(catalog.options()[0].model.id(), "new"); + assert!( + Catalog::from_event(&event(json!([]))) + .unwrap() + .options() + .is_empty() + ); + assert_eq!(Catalog::Unknown.options().len(), 1); + assert!(Catalog::Unknown.contains(&Model::default())); +} + +#[test] +fn rejects_duplicate_aliases_malformed_catalogs_and_unsuccessful_responses() { + for models in [ + json!({}), + json!([{"value":"x"}]), + json!([ + {"value":"default","displayName":"Default"},{"value":"claude-default","displayName":"Duplicate"} + ]), + ] { + assert!(Catalog::from_event(&event(models)).is_none()); + } + let mut rejected = event(json!([{"value":"x","displayName":"X"}])); + rejected.data["payload"]["response"]["subtype"] = json!("error"); + assert!(Catalog::from_event(&rejected).is_none()); + for id in ["", "../other", "model\nheader", "model with spaces"] { + assert!(Model::parse(id).is_err()); + } +} + +#[derive(Clone)] +struct AccountCloud { + histories: Vec>, + reads: std::sync::Arc, +} +impl Cloud for AccountCloud { + async fn recent_sessions(&self) -> Result> { + (0..self.histories.len()) + .map(|i| super::super::model::SessionId::parse(&format!("cse_{i}"))) + .collect() + } + async fn history(&self, session: &super::super::model::SessionId) -> Result> { + self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let i: usize = session.as_str().trim_start_matches("cse_").parse().unwrap(); + Ok(self.histories[i].clone()) + } + async fn send(&self, _: &super::super::model::SessionId, _: serde_json::Value) -> Result<()> { + panic!("discovery is read-only") + } + async fn send_batch( + &self, + _: &super::super::model::SessionId, + _: Vec, + ) -> Result<()> { + panic!("discovery is read-only") + } + async fn stream( + &self, + _: &super::super::model::SessionId, + _: Option, + ) -> Result { + panic!("discovery must not wake a worker") + } +} + +#[tokio::test] +async fn discovery_is_bounded_account_scoped_and_skips_uninitialized_sessions() { + let first = AccountCloud { + histories: vec![ + vec![], + vec![event(json!([{"value":"account-a","displayName":"A"}]))], + ], + reads: Default::default(), + }; + let second = AccountCloud { + histories: vec![vec![event( + json!([{"value":"account-b","displayName":"B"}]), + )]], + reads: Default::default(), + }; + assert_eq!( + discover(&first).await.unwrap().options()[0].model.id(), + "account-a" + ); + assert_eq!( + discover(&second).await.unwrap().options()[0].model.id(), + "account-b" + ); + let empty = AccountCloud { + histories: vec![vec![]; 6], + reads: Default::default(), + }; + assert_eq!(discover(&empty).await.unwrap(), Catalog::Unknown); + assert_eq!(empty.reads.load(std::sync::atomic::Ordering::SeqCst), 5); +} diff --git a/crates/claude_cloud_agents/src/domain/ports.rs b/crates/claude_cloud_agents/src/domain/ports.rs new file mode 100644 index 00000000000..a3a4fac0c84 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/ports.rs @@ -0,0 +1,33 @@ +//! Capabilities needed by the Claude session adapter. +use super::model::{Event, Result, SessionId}; +use futures::Stream; +use std::{future::Future, pin::Pin}; + +/// A bounded provider event stream. +pub type Events = Pin> + Send>>; + +/// Account-scoped provider operations. Implementors must never choose another user's credential. +pub trait Cloud: Clone + Send + Sync + 'static { + /// At most five recent sessions visible through this account's credential. + fn recent_sessions(&self) -> impl Future>> + Send; + /// Submit one event. Mutations are not automatically retried. + fn send( + &self, + session: &SessionId, + payload: serde_json::Value, + ) -> impl Future> + Send; + /// Submit ordered events in a single request. Never retry an uncertain batch. + fn send_batch( + &self, + session: &SessionId, + payloads: Vec, + ) -> impl Future> + Send; + /// Resume durable events after `cursor`, with live ephemeral deltas. + fn stream( + &self, + session: &SessionId, + cursor: Option, + ) -> impl Future> + Send; + /// Recover durable transcript from the start for an ACP session/load. + fn history(&self, session: &SessionId) -> impl Future>> + Send; +} diff --git a/crates/claude_cloud_agents/src/domain/service.rs b/crates/claude_cloud_agents/src/domain/service.rs new file mode 100644 index 00000000000..0d5ef740ebc --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/service.rs @@ -0,0 +1,323 @@ +//! Session orchestration: single-turn admission, replay, and bounded stream reconnect. +use super::{ + model::{Error, Result, SessionId}, + models::{Catalog, Model}, + ports::Cloud, + translate::{Translator, Update}, +}; +use futures::StreamExt; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// One owner-bound cloud conversation. +pub struct Session { + cloud: C, + id: SessionId, + turn: Mutex<()>, + prompt_admission: Mutex<()>, + cursor: Mutex>, + translator: Mutex, + model: Mutex, + catalog: Mutex, +} + +#[cfg(test)] +mod test; + +impl Session { + /// Construct around an already-persisted provider identity. + pub fn new(cloud: C, id: SessionId) -> Arc { + Self::with_model(cloud, id, Model::default()) + } + /// Restore the model projected by Macro's acknowledged config responses. + pub fn with_model(cloud: C, id: SessionId, model: Model) -> Arc { + Arc::new(Self { + cloud, + id, + turn: Mutex::new(()), + prompt_admission: Mutex::new(()), + cursor: Mutex::new(None), + translator: Mutex::new(Translator::default()), + model: Mutex::new(model), + catalog: Mutex::new(Catalog::Unknown), + }) + } + /// Saved preference for the next turn, not the worker's current running model. + pub async fn model(&self) -> Model { + self.model.lock().await.clone() + } + + /// Current provider catalog, or the explicit default-only bootstrap state. + pub async fn catalog(&self) -> Catalog { + self.catalog.lock().await.clone() + } + + /// Read-only discovery; never starts a cloud worker merely to populate a picker. + pub async fn refresh_catalog(&self) -> Result<()> { + let history = self.cloud.history(&self.id).await?; + let mut catalog = Catalog::from_history(&history); + if matches!(catalog, Catalog::Unknown) { + catalog = super::models::discover(&self.cloud).await?; + } + *self.catalog.lock().await = catalog; + Ok(()) + } + + async fn learn_catalog( + &self, + event: &super::model::Event, + emit: impl Fn(Update) -> Result<()>, + ) -> Result<()> { + if let Some(next) = Catalog::from_event(event) { + let mut catalog = self.catalog.lock().await; + if *catalog != next { + emit(Update::Models { + catalog: next.clone(), + current: self.model().await, + })?; + *catalog = next; + } + } + Ok(()) + } + + /// Submit a preference without waiting for an idle worker to wake. The next + /// prompt repeats it in the same ordered batch, like the desktop client. + pub async fn set_model(&self, model: Model) -> Result<()> { + let _admission = self.prompt_admission.try_lock().map_err(|_| Error::Busy)?; + let _turn = self.turn.lock().await; + self.refresh_catalog().await?; + if !self.catalog().await.contains(&model) { + return Err(Error::ModelUnavailable); + } + self.cloud + .send(&self.id, model_request(model.clone()).1) + .await?; + *self.model.lock().await = model; + Ok(()) + } + + /// Provider identity; also used as the ACP session identity. + pub fn id(&self) -> &SessionId { + &self.id + } + + /// Replay durable transcript for Macro's normal ACP load flow. + pub async fn load(&self) -> Result> { + let _turn = self.turn.try_lock().map_err(|_| Error::Busy)?; + let mut translator = Translator::default(); + let mut updates = Vec::new(); + let mut cursor = None; + for event in self.cloud.history(&self.id).await? { + if let Some(catalog) = Catalog::from_event(&event) { + *self.catalog.lock().await = catalog; + } + updates.extend(translator.accept(&event, true)?); + if let Some(seq) = event.sequence { + cursor = Some(cursor.unwrap_or(0).max(seq)); + } + } + *self.cursor.lock().await = cursor; + *self.translator.lock().await = translator; + if matches!(self.catalog().await, Catalog::Unknown) { + *self.catalog.lock().await = super::models::discover(&self.cloud).await?; + } + Ok(updates) + } + + /// Mirror cloud-side durable messages while idle. The turn gate prevents + /// interleaving with Macro prompts; cursor and translator are shared with SSE. + pub async fn sync_foreign(&self, emit: impl Fn(Update) -> Result<()> + Send) -> Result<()> { + let Ok(_turn) = self.turn.try_lock() else { + return Ok(()); + }; + let history = self.cloud.history(&self.id).await?; + let mut cursor = self.cursor.lock().await; + let mut translator = self.translator.lock().await; + for event in history { + let sequence = event.sequence.ok_or(Error::Recovery)?; + if cursor.is_some_and(|last| sequence <= last) { + continue; + } + // A malformed record must not enter the dedup set and disappear on + // the next poll. Commit translation and cursor only after delivery. + let mut next = translator.clone(); + self.learn_catalog(&event, &emit).await?; + for update in next.accept(&event, true)? { + emit(update)?; + } + *translator = next; + *cursor = Some(sequence); + } + Ok(()) + } + + /// Run a single text prompt, opening the stream first and never retrying the send. + pub async fn prompt( + &self, + text: String, + emit: impl Fn(Update) -> Result<()> + Send, + ) -> Result<()> { + // Wait behind a short idle poll, but never admit two Macro prompts. + let _admission = self.prompt_admission.try_lock().map_err(|_| Error::Busy)?; + let _turn = self.turn.lock().await; + if text.trim().is_empty() { + return Err(Error::Protocol); + } + let mut cursor = *self.cursor.lock().await; + // An interrupted Macro process does not stop the cloud worker. Do not + // attribute a previous/out-of-band turn's output to this new prompt. + let history = self.cloud.history(&self.id).await?; + if let Some(event) = history + .iter() + .rev() + .find(|event| Catalog::from_event(event).is_some()) + { + self.learn_catalog(event, &emit).await?; + } + let mut pending = std::collections::BTreeSet::new(); + for event in &history { + let payload = &event.data["payload"]; + let content = &payload["message"]["content"]; + if payload["type"] == "user" + && (content.is_string() + || content + .as_array() + .is_some_and(|blocks| blocks.iter().any(|block| block["type"] == "text"))) + && let Some(id) = payload["uuid"].as_str() + { + pending.insert(id); + } + if payload["type"] == "result" { + if let Some(id) = payload["user_message_uuid"].as_str() { + pending.remove(id); + } + for id in payload["user_message_uuids"] + .as_array() + .into_iter() + .flatten() + .filter_map(|id| id.as_str()) + { + pending.remove(id); + } + } + } + if !pending.is_empty() { + return Err(Error::Busy); + } + if !self.catalog().await.contains(&self.model().await) { + return Err(Error::ModelUnavailable); + } + let mut translator = self.translator.lock().await; + // Catch completed foreign turns even if a prompt beats the next poll. + for event in &history { + let seq = event.sequence.ok_or(Error::Recovery)?; + if cursor.is_some_and(|last| seq <= last) { + continue; + } + for update in translator.accept(event, true)? { + emit(update)?; + } + cursor = Some(seq); + *self.cursor.lock().await = cursor; + } + let mut stream = self.cloud.stream(&self.id, cursor).await?; + let message_id = uuid::Uuid::now_v7().to_string(); + let (model_request_id, model_event) = model_request(self.model().await); + let mut batch = Vec::new(); + if matches!(Catalog::from_history(&history), Catalog::Unknown) { + // Ask the worker for its catalog as part of the user's first turn, + // never by creating or waking a session during settings discovery. + batch.push(json!({"type":"control_request","request_id":uuid::Uuid::now_v7().to_string(),"request":{"subtype":"initialize"}})); + } + batch.push(model_event); + batch.push( + json!({"type":"user", "uuid":message_id, "session_id":self.id.as_str(), + "parent_tool_use_id":null, "message":{"role":"user","content":text}}), + ); + self.cloud.send_batch(&self.id, batch).await?; + let mut retries = 0u8; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(600); + loop { + let event = tokio::time::timeout_at(deadline, stream.next()) + .await + .map_err(|_| Error::Network)?; + match event { + Some(Ok(event)) => { + if event.kind == "client_event" + && event + .sequence + .is_some_and(|seq| cursor.is_some_and(|last| seq <= last)) + { + continue; + } + let payload = &event.data["payload"]; + self.learn_catalog(&event, &emit).await?; + if payload["type"] == "control_response" + && payload["response"]["request_id"].as_str() == Some(&model_request_id) + && payload["response"]["subtype"] == "error" + { + // The batch is accepted already; best-effort interrupt + // avoids knowingly continuing with a rejected preference. + let _ = self.cancel().await; + return Err(Error::ModelUnavailable); + } + // The demo never grants permissions automatically. Respond through the + // actual control protocol so a worker cannot hang waiting for approval. + if payload["type"] == "control_request" + && payload["request"]["subtype"] == "can_use_tool" + { + self.cloud.send(&self.id, json!({"type":"control_response", "response":{ + "subtype":"success", "request_id":payload["request_id"], + "response":{"behavior":"deny","message":"Tool approval is not enabled in the Macro Claude demo"} + }})).await?; + } + let own_result = payload["type"] == "result" + && (payload["user_message_uuid"].as_str() == Some(&message_id) + || payload["user_message_uuids"].as_array().is_some_and(|ids| { + ids.iter().any(|id| id.as_str() == Some(&message_id)) + })); + for update in translator.accept(&event, false)? { + if matches!(update, Update::Finished { .. }) && !own_result { + continue; + } + emit(update)?; + } + if event.kind == "client_event" + && let Some(seq) = event.sequence + { + cursor = Some(seq); + *self.cursor.lock().await = cursor; + } + if own_result { + return Ok(()); + } + } + Some(Err(error @ (Error::Authorization | Error::Protocol | Error::Recovery))) => { + return Err(error); + } + Some(Err(_)) | None => { + if retries >= 3 { + return Err(Error::Network); + } + retries += 1; + tokio::time::sleep(std::time::Duration::from_secs(u64::from(retries))).await; + stream = self.cloud.stream(&self.id, cursor).await?; + } + } + } + } + + /// Ask the cloud worker to interrupt; closing SSE alone would not stop it. + pub async fn cancel(&self) -> Result<()> { + self.cloud.send(&self.id, json!({"type":"control_request", "request_id":format!("interrupt-{}",uuid::Uuid::now_v7()), "request":{"subtype":"interrupt"}})).await + } +} + +fn model_request(model: Model) -> (String, serde_json::Value) { + let id = uuid::Uuid::now_v7().to_string(); + let payload = json!({"type":"control_request","request_id":id, + "request":{"subtype":"set_model","model":model.provider_value()}}); + (id, payload) +} diff --git a/crates/claude_cloud_agents/src/domain/service/test.rs b/crates/claude_cloud_agents/src/domain/service/test.rs new file mode 100644 index 00000000000..77bbfc0a27c --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/service/test.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::domain::{model::Event, ports::Events}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Clone, Default)] +struct ReconnectingCloud { + sends: Arc>>, + cursors: Arc>>>, + streams: Arc, + history: Arc>>, +} +impl Cloud for ReconnectingCloud { + async fn recent_sessions(&self) -> Result> { + Ok(Vec::new()) + } + async fn send_batch( + &self, + session: &SessionId, + payloads: Vec, + ) -> Result<()> { + assert_eq!( + payloads[payloads.len() - 2]["request"]["subtype"], + "set_model" + ); + if payloads.len() == 3 { + assert_eq!(payloads[0]["request"]["subtype"], "initialize"); + } + self.send(session, payloads.last().unwrap().clone()).await + } + async fn send(&self, _: &SessionId, payload: serde_json::Value) -> Result<()> { + self.sends.lock().await.push(payload); + Ok(()) + } + async fn history(&self, _: &SessionId) -> Result> { + Ok(self.history.lock().await.clone()) + } + async fn stream(&self, _: &SessionId, cursor: Option) -> Result { + self.cursors.lock().await.push(cursor); + let attempt = self.streams.fetch_add(1, Ordering::SeqCst); + let sends = self.sends.clone(); + Ok(futures::stream::once(async move { + let sent = loop { + if let Some(sent) = sends.lock().await.first().cloned() { break sent; } + tokio::task::yield_now().await; + }; + let durable = Event { kind:"client_event".into(), sequence:Some(1), data:json!({"payload":{ + "type":"assistant", "uuid":"a1", "message":{"id":"msg1","content":[{"type":"text","text":"hello"}]} + }}) }; + if attempt == 0 { vec![Ok(durable), Err(Error::Network)] } + else { vec![Ok(durable), Ok(Event {kind:"client_event".into(), sequence:Some(2), data:json!({"payload":{ + "type":"result","uuid":"r1","subtype":"success","is_error":false,"user_message_uuid":sent["uuid"],"usage":{} + }})})] } + }).flat_map(futures::stream::iter).boxed()) + } +} + +#[tokio::test] +async fn stream_reconnect_resumes_after_delivery_without_resending_prompt() { + let cloud = ReconnectingCloud::default(); + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + let output = std::sync::Mutex::new(Vec::new()); + session + .prompt("hello".into(), |update| { + output.lock().unwrap().push(update); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(cloud.sends.lock().await.len(), 1); + assert_eq!(*cloud.cursors.lock().await, vec![None, Some(1)]); + assert_eq!( + output + .lock() + .unwrap() + .iter() + .filter(|u| matches!(u, Update::Text(_))) + .count(), + 1 + ); +} + +#[tokio::test] +async fn cancel_sends_a_cloud_interrupt_not_just_a_local_disconnect() { + let cloud = ReconnectingCloud::default(); + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + session.cancel().await.unwrap(); + assert_eq!( + cloud.sends.lock().await[0]["request"]["subtype"], + "interrupt" + ); +} + +fn foreign_turn() -> Vec { + vec![ + Event { + kind: "client_event".into(), + sequence: Some(1), + data: json!({"payload":{"type":"user","uuid":"foreign-user","message":{"content":"sent in Claude"}}}), + }, + Event { + kind: "client_event".into(), + sequence: Some(2), + data: json!({"payload":{"type":"assistant","uuid":"foreign-answer","message":{"id":"foreign-msg","content":[{"type":"text","text":"cloud reply"}]}}}), + }, + Event { + kind: "client_event".into(), + sequence: Some(3), + data: json!({"payload":{"type":"result","uuid":"foreign-result","subtype":"success","user_message_uuid":"foreign-user","usage":{}}}), + }, + ] +} + +#[tokio::test] +async fn foreign_updates_are_ordered_deduplicated_and_resume_after_load() { + let cloud = ReconnectingCloud::default(); + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + let output = std::sync::Mutex::new(Vec::new()); + let emit = |update| { + output.lock().unwrap().push(update); + Ok(()) + }; + *cloud.history.lock().await = foreign_turn(); + session.sync_foreign(emit).await.unwrap(); + session.sync_foreign(emit).await.unwrap(); + assert_eq!(output.lock().unwrap().len(), 3); + assert_eq!( + output.lock().unwrap()[0], + Update::User("sent in Claude".into()) + ); + assert_eq!( + output.lock().unwrap()[1], + Update::Text("cloud reply".into()) + ); + assert!(matches!(output.lock().unwrap()[2], Update::Finished { .. })); + assert!(cloud.sends.lock().await.is_empty()); + session.load().await.unwrap(); + output.lock().unwrap().clear(); + session.sync_foreign(emit).await.unwrap(); + assert!(output.lock().unwrap().is_empty()); + // A fresh attachment hydrates once, then its poll does not replay history. + let resumed = Session::new(cloud, SessionId::parse("cse_test").unwrap()); + assert_eq!(resumed.load().await.unwrap().len(), 3); + resumed.sync_foreign(emit).await.unwrap(); + assert!(output.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn poll_skips_active_macro_turn_without_contacting_provider() { + let cloud = ReconnectingCloud::default(); + *cloud.history.lock().await = foreign_turn(); + let session = Session::new(cloud, SessionId::parse("cse_test").unwrap()); + let _active = session.turn.lock().await; + session + .sync_foreign(|_| panic!("must not interleave")) + .await + .unwrap(); + assert!(session.cursor.lock().await.is_none()); +} + +#[test] +fn session_link_is_fixed_origin_and_rejects_path_injection() { + assert_eq!( + SessionId::parse("cse_test").unwrap().web_url(), + "https://claude.ai/code/cse_test" + ); + assert!(SessionId::parse("cse_x/../../other").is_err()); +} + +#[derive(Clone, Default)] +struct ModelCloud { + sends: Arc>>, + reject: Arc, + reject_model: Arc, +} +impl Cloud for ModelCloud { + async fn recent_sessions(&self) -> Result> { + Ok(Vec::new()) + } + async fn send(&self, _: &SessionId, payload: serde_json::Value) -> Result<()> { + if self.reject.load(Ordering::SeqCst) { + return Err(Error::Network); + } + self.sends.lock().await.push(payload); + Ok(()) + } + async fn send_batch(&self, _: &SessionId, payloads: Vec) -> Result<()> { + assert_eq!(payloads.len(), 2); + assert_eq!(payloads[0]["request"]["subtype"], "set_model"); + assert_eq!(payloads[1]["type"], "user"); + self.sends.lock().await.extend(payloads); + Ok(()) + } + async fn history(&self, _: &SessionId) -> Result> { + Ok(vec![Event { + kind: "client_event".into(), + sequence: Some(0), + data: json!({"payload":{"type":"control_response","response":{"subtype":"success","response":{"models":[ + {"value":"default","displayName":"Default"}, + {"value":"opus","displayName":"Opus"}, + {"value":"sonnet","displayName":"Sonnet"}, + {"value":"haiku","displayName":"Haiku"}]}}}}), + }]) + } + async fn stream(&self, _: &SessionId, _: Option) -> Result { + let before = self.sends.lock().await.len(); + let cloud = self.clone(); + Ok(futures::stream::once(async move { + let sent = loop { + let sends = cloud.sends.lock().await; + if sends.len() >= before + 2 { break sends[before..].to_vec(); } + drop(sends); + tokio::task::yield_now().await; + }; + vec![ + Ok(Event { kind:"client_event".into(), sequence:Some(1), data:json!({"payload":{"type":"control_response","response":{"subtype":"error","request_id":"unrelated"}}}) }), + Ok(Event { kind:"client_event".into(), sequence:Some(2), data:json!({"payload":{"type":"control_response","response":{"subtype":if cloud.reject_model.load(Ordering::SeqCst) { "error" } else { "success" },"request_id":sent[0]["request_id"]}}}) }), + Ok(Event { kind:"client_event".into(), sequence:Some(3), data:json!({"payload":{"type":"result","uuid":"result","subtype":"success","user_message_uuid":sent[1]["uuid"],"usage":{}}}) }), + ] + }).flat_map(futures::stream::iter).boxed()) + } +} + +#[tokio::test] +async fn idle_model_preference_does_not_wait_for_worker_and_failed_sends_preserve_it() { + let cloud = ModelCloud::default(); + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + session + .set_model(Model::parse("opus").unwrap()) + .await + .unwrap(); + assert_eq!(session.model().await, Model::parse("opus").unwrap()); + assert_eq!(cloud.sends.lock().await[0]["request"]["model"], "opus"); + assert!(session.cursor.lock().await.is_none()); + cloud.reject.store(true, Ordering::SeqCst); + assert!(matches!( + session.set_model(Model::parse("haiku").unwrap()).await, + Err(Error::Network) + )); + assert_eq!(session.model().await, Model::parse("opus").unwrap()); + cloud.reject.store(false, Ordering::SeqCst); + session.set_model(Model::default()).await.unwrap(); + assert!(cloud.sends.lock().await.last().unwrap()["request"]["model"].is_null()); +} + +#[tokio::test] +async fn provider_rejection_of_own_model_request_interrupts_and_fails_the_turn() { + let cloud = ModelCloud::default(); + cloud.reject_model.store(true, Ordering::SeqCst); + let session = Session::with_model( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + Model::parse("opus").unwrap(), + ); + assert!(matches!( + session.prompt("hello".into(), |_| Ok(())).await, + Err(Error::ModelUnavailable) + )); + assert_eq!( + cloud.sends.lock().await.last().unwrap()["request"]["subtype"], + "interrupt" + ); +} + +#[tokio::test] +async fn restored_model_is_batched_before_next_prompt_and_busy_changes_are_rejected() { + let cloud = ModelCloud::default(); + let session = Session::with_model( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + Model::parse("haiku").unwrap(), + ); + let admitted = session.prompt_admission.lock().await; + assert!(matches!( + session.set_model(Model::parse("opus").unwrap()).await, + Err(Error::Busy) + )); + drop(admitted); + session.prompt("hello".into(), |_| Ok(())).await.unwrap(); + assert_eq!(cloud.sends.lock().await[0]["request"]["model"], "haiku"); + assert_eq!(cloud.sends.lock().await[1]["type"], "user"); +} + +#[tokio::test] +async fn default_is_also_reset_in_the_prompt_batch() { + let cloud = ModelCloud::default(); + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + session.prompt("hello".into(), |_| Ok(())).await.unwrap(); + assert!(cloud.sends.lock().await[0]["request"]["model"].is_null()); +} + +#[tokio::test] +async fn malformed_foreign_record_is_not_silently_skipped_on_retry() { + let cloud = ReconnectingCloud::default(); + *cloud.history.lock().await = vec![Event { + kind: "client_event".into(), + sequence: Some(1), + data: json!({"payload":{"type":"assistant","uuid":"invalid","message":{}}}), + }]; + let session = Session::new(cloud, SessionId::parse("cse_test").unwrap()); + for _ in 0..2 { + assert!( + session + .sync_foreign(|_| panic!("invalid record")) + .await + .is_err() + ); + assert!(session.cursor.lock().await.is_none()); + } +} diff --git a/crates/claude_cloud_agents/src/domain/translate.rs b/crates/claude_cloud_agents/src/domain/translate.rs new file mode 100644 index 00000000000..c2e72156f59 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/translate.rs @@ -0,0 +1,185 @@ +//! Reconcile ephemeral deltas with durable Claude messages before emitting UI updates. +use super::model::{Error, Event, Result}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// A provider-neutral display or lifecycle update. +#[derive(Debug, Clone, PartialEq)] +pub enum Update { + /// Provider-reported models changed while replaying or streaming. + Models { + /// Complete catalog, replacing the previous one. + catalog: super::models::Catalog, + /// Saved next-turn preference; discovery must not reset it. + current: super::models::Model, + }, + /// Assistant-visible text delta. + Text(String), + /// Reasoning text explicitly emitted by the provider. + Thought(String), + /// Historical user input, emitted only while loading a transcript. + User(String), + /// A tool invocation with its provider identity and input. + Tool { + /// Stable tool-call id. + id: String, + /// Display/tool name. + name: String, + /// Tool input. + input: Value, + /// Parent call for a delegated agent. + parent: Option, + }, + /// Completed tool output. + ToolResult { + /// Corresponding call id. + id: String, + /// Provider output. + output: Value, + /// Whether the tool failed. + failed: bool, + }, + /// Final provider result. Usage remains provider-reported, not a Macro API charge. + Finished { + /// Failed rather than completed successfully. + failed: bool, + /// User cancelled the turn. + cancelled: bool, + /// Provider token usage. + usage: Value, + }, +} + +/// Per-attachment state that deduplicates durable messages and reconciles live text. +#[derive(Clone, Default)] +pub struct Translator { + current_message: Option, + blocks: BTreeMap<(String, u64), String>, + durable: BTreeSet, +} + +impl Translator { + /// Process one provider record. History mode includes user messages. + pub fn accept(&mut self, event: &Event, history: bool) -> Result> { + if event.kind == "catch_up_truncated" { + return Err(Error::Recovery); + } + let payload = &event.data["payload"]; + let mut updates = Vec::new(); + if event.kind == "ephemeral_event" && payload["type"] == "stream_event" { + let frame = &payload["event"]; + match frame["type"].as_str() { + Some("message_start") => { + self.current_message = frame["message"]["id"].as_str().map(str::to_owned) + } + Some("content_block_delta") => { + if let (Some(message), Some(index)) = + (&self.current_message, frame["index"].as_u64()) + { + let (field, thought) = match frame["delta"]["type"].as_str() { + Some("text_delta") => ("text", false), + Some("thinking_delta") => ("thinking", true), + _ => return Ok(updates), + }; + if let Some(text) = frame["delta"][field].as_str() { + self.blocks + .entry((message.clone(), index)) + .or_default() + .push_str(text); + updates.push(if thought { + Update::Thought(text.to_owned()) + } else { + Update::Text(text.to_owned()) + }); + } + } + } + _ => {} + } + return Ok(updates); + } + if event.kind != "client_event" { + return Ok(updates); + } + if let Some(id) = payload["uuid"].as_str() + && !self.durable.insert(id.to_owned()) + { + return Ok(updates); + } + match payload["type"].as_str() { + Some("assistant") => { + let id = payload["message"]["id"].as_str().ok_or(Error::Protocol)?; + for (index, block) in payload["message"]["content"] + .as_array() + .ok_or(Error::Protocol)? + .iter() + .enumerate() + { + match block["type"].as_str() { + Some("text" | "thinking") => { + let thought = block["type"] == "thinking"; + let text = block[if thought { "thinking" } else { "text" }] + .as_str() + .ok_or(Error::Protocol)?; + let sent = self + .blocks + .entry((id.to_owned(), index as u64)) + .or_default(); + let remaining = + text.strip_prefix(sent.as_str()).ok_or(Error::Recovery)?; + if !remaining.is_empty() { + updates.push(if thought { + Update::Thought(remaining.to_owned()) + } else { + Update::Text(remaining.to_owned()) + }); + } + *sent = text.to_owned(); + } + Some("tool_use") => updates.push(Update::Tool { + id: block["id"].as_str().ok_or(Error::Protocol)?.to_owned(), + name: block["name"].as_str().ok_or(Error::Protocol)?.to_owned(), + input: block["input"].clone(), + parent: payload["parent_tool_use_id"].as_str().map(str::to_owned), + }), + _ => {} + } + } + } + Some("user") => { + let content = &payload["message"]["content"]; + if history && let Some(text) = content.as_str() { + updates.push(Update::User(text.to_owned())); + } + for block in content.as_array().into_iter().flatten() { + if block["type"] == "tool_result" { + updates.push(Update::ToolResult { + id: block["tool_use_id"] + .as_str() + .ok_or(Error::Protocol)? + .to_owned(), + output: block["content"].clone(), + failed: block["is_error"].as_bool().unwrap_or(false), + }); + } else if history && block["type"] == "text" { + updates.push(Update::User( + block["text"].as_str().ok_or(Error::Protocol)?.to_owned(), + )); + } + } + } + Some("result") => updates.push(Update::Finished { + failed: payload["is_error"].as_bool().unwrap_or(false) + || payload["subtype"] != "success", + cancelled: payload["stop_reason"] == "interrupt" + || payload["subtype"] == "interrupted", + usage: payload["usage"].clone(), + }), + _ => {} + } + Ok(updates) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/domain/translate/test.rs b/crates/claude_cloud_agents/src/domain/translate/test.rs new file mode 100644 index 00000000000..67ec937bf47 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/translate/test.rs @@ -0,0 +1,97 @@ +use super::*; +use serde_json::json; +fn event(kind: &str, payload: Value) -> Event { + Event { + kind: kind.into(), + data: json!({"payload":payload}), + sequence: None, + } +} + +#[test] +fn reconciles_live_deltas_and_durable_message_without_duplication() { + let mut translator = Translator::default(); + translator.accept(&event("ephemeral_event", json!({"type":"stream_event","event":{"type":"message_start","message":{"id":"msg1"}}})), false).unwrap(); + let delta = event( + "ephemeral_event", + json!({"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hé"}}}), + ); + assert_eq!( + translator.accept(&delta, false).unwrap(), + vec![Update::Text("hé".into())] + ); + let durable = event( + "client_event", + json!({"type":"assistant","uuid":"a1","message":{"id":"msg1","content":[{"type":"text","text":"héllo"}]}}), + ); + assert_eq!( + translator.accept(&durable, false).unwrap(), + vec![Update::Text("llo".into())] + ); + assert!(translator.accept(&durable, false).unwrap().is_empty()); +} + +#[test] +fn durable_only_transcript_replays_full_text() { + let mut translator = Translator::default(); + let durable = event( + "client_event", + json!({"type":"assistant","uuid":"a1","message":{"id":"msg1","content":[{"type":"text","text":"hello"}]}}), + ); + assert_eq!( + translator.accept(&durable, true).unwrap(), + vec![Update::Text("hello".into())] + ); +} + +#[test] +fn text_with_no_message_start_waits_for_durable_recovery() { + let mut translator = Translator::default(); + let delta = event( + "ephemeral_event", + json!({"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}}), + ); + assert!(translator.accept(&delta, false).unwrap().is_empty()); +} + +#[test] +fn history_includes_user_but_live_echo_does_not_duplicate_prompt() { + let message = event( + "client_event", + json!({"type":"user","uuid":"u1","message":{"content":"hi"}}), + ); + assert!( + Translator::default() + .accept(&message, false) + .unwrap() + .is_empty() + ); + assert_eq!( + Translator::default().accept(&message, true).unwrap(), + vec![Update::User("hi".into())] + ); +} + +#[test] +fn tool_output_retains_call_identity_and_failure() { + let message = event( + "client_event", + json!({"type":"user","uuid":"u1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"denied","is_error":true}]}}), + ); + assert_eq!( + Translator::default().accept(&message, false).unwrap(), + vec![Update::ToolResult { + id: "t1".into(), + output: json!("denied"), + failed: true + }] + ); +} + +#[test] +fn truncated_replay_is_not_silently_accepted() { + assert!(matches!( + Translator::default().accept(&event("catch_up_truncated", json!({})), false), + Err(Error::Recovery) + )); +} diff --git a/crates/claude_cloud_agents/src/inbound/acp.rs b/crates/claude_cloud_agents/src/inbound/acp.rs new file mode 100644 index 00000000000..65b51ecbc11 --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/acp.rs @@ -0,0 +1,255 @@ +//! Thin ACP transport over the account-scoped Claude session service. +use crate::domain::{ + model::{Error, Result}, + models::{Catalog, Model}, + ports::Cloud, + service::Session, + translate::Update, +}; +use agent_runtime_protocol::domain::{ + connection::ServerChannel, + schema::v0::{AcpMessage, SystemEvent, ToRuntimeMessage, ToServerMessage}, +}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc::UnboundedSender; + +/// Attach a Claude conversation to Macro's existing runtime protocol. +pub fn attach(session: Arc>) -> ServerChannel { + let (server, mut runtime) = ServerChannel::duplex(); + tokio::spawn(async move { + let tx = runtime.tx; + let _ = tx.send(ToServerMessage::Event { + event: SystemEvent::AcpReady, + }); + let mut turns = tokio::task::JoinSet::new(); + let mut mirrors = tokio::task::JoinSet::new(); + let usage_totals = Arc::new(Mutex::new((0u64, 0u64))); + let every = std::time::Duration::from_secs(2); + let mut mirror = tokio::time::interval_at(tokio::time::Instant::now() + every, every); + mirror.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut ready = false; + let mut retry_after = tokio::time::Instant::now(); + loop { + tokio::select! { + _ = tx.closed() => break, + Some(_) = turns.join_next(), if !turns.is_empty() => {}, + Some(result) = mirrors.join_next(), if !mirrors.is_empty() => { + if !matches!(result, Ok(Ok(()))) { + retry_after = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + } + } + _ = mirror.tick(), if ready && turns.is_empty() && mirrors.is_empty() => { + if tokio::time::Instant::now() < retry_after { continue; } + let session = session.clone(); + let tx = tx.clone(); + let usage_totals = usage_totals.clone(); + mirrors.spawn(async move { + session.sync_foreign(|update| { + if let Update::Finished { usage, .. } = &update { let _ = cumulative_usage(&usage_totals, usage); } + notification(&tx, session.id().as_str(), update) + }).await + }); + } + incoming = runtime.rx.recv() => { + let Some(ToRuntimeMessage::Acp(AcpMessage(raw))) = incoming else { break; }; + let Ok(frame) = serde_json::to_value(raw) else { break; }; + let id = frame.get("id").cloned(); + let params = &frame["params"]; + let method = frame["method"].as_str().unwrap_or_default(); + if method.starts_with("session/") && method != "session/new" && params["sessionId"].as_str() != Some(session.id().as_str()) { + if let Some(id) = id { let _ = failure(&tx, id, "Unknown Claude session"); } + continue; + } + match method { + "initialize" => { if let Some(id) = id { let _ = reply(&tx, id, json!({ + "protocolVersion":1, "agentCapabilities":{"loadSession":true}, + "agentInfo":{"name":"claude-cloud","title":"Claude Cloud (demo)","version":"0.1.0"}, "authMethods":[] + })); } } + "session/new" => { + if let Err(error) = session.refresh_catalog().await { + if let Some(id) = id { let _ = failure(&tx, id, &error.to_string()); } + continue; + } + if let Some(id) = id { + let _ = reply(&tx, id, json!({"sessionId":session.id().as_str(), "configOptions":model_options(&session.catalog().await, &session.model().await)})); + ready = true; + } + } + "session/load" => { + mirrors.abort_all(); + while mirrors.join_next().await.is_some() {} + if let Some(id) = id { + match session.load().await { + Ok(updates) => { + if let Ok(mut totals) = usage_totals.lock() { *totals = (0, 0); } + for update in updates { + if let Update::Finished { usage, .. } = &update { let _ = cumulative_usage(&usage_totals, usage); } + let _ = notification(&tx, session.id().as_str(), update); + } + let _ = reply(&tx, id, json!({"configOptions":model_options(&session.catalog().await, &session.model().await)})); + ready = true; + } + Err(error) => { let _ = failure(&tx, id, &error.to_string()); } + } + } + } + "session/prompt" => { + let Some(id) = id else { continue; }; + if !turns.is_empty() { let _ = failure(&tx, id, "Claude still has a turn in progress"); continue; } + let Some(blocks) = params["prompt"].as_array() else { let _ = failure(&tx, id, "A text prompt is required"); continue; }; + if blocks.iter().any(|b| b["type"] != "text") { let _ = failure(&tx, id, "The Claude demo currently supports text prompts only"); continue; } + let text = blocks.iter().filter_map(|b| b["text"].as_str()).collect::>().join("\n"); + let session = session.clone(); + let tx = tx.clone(); + let usage_totals = usage_totals.clone(); + turns.spawn(async move { + let final_result = Arc::new(Mutex::new(None)); + let observed = final_result.clone(); + let outcome = session.prompt(text, |update| { + if let Update::Finished { failed, cancelled, usage } = update { + *observed.lock().map_err(|_| Error::Protocol)? = Some((failed, cancelled, usage)); + Ok(()) + } else { notification(&tx, session.id().as_str(), update) } + }).await; + match outcome { + Err(error) => { let _ = failure(&tx, id, &error.to_string()); } + Ok(()) => { + let result = final_result.lock().ok().and_then(|r| r.clone()); + match result { + Some((false, cancelled, usage)) => { let _ = reply(&tx, id, json!({"stopReason":if cancelled {"cancelled"} else {"end_turn"}, "usage":cumulative_usage(&usage_totals, &usage), "_meta":{"claudeCode":{"usage":usage}}})); } + Some((true, true, _)) => { let _ = reply(&tx, id, json!({"stopReason":"cancelled"})); } + _ => { let _ = failure(&tx, id, "Claude reported an unsuccessful turn"); } + } + } + } + }); + } + "session/cancel" => { + let outcome = session.cancel().await; + if let Some(id) = id { + match outcome { Ok(()) => { let _ = reply(&tx, id, json!({})); }, Err(error) => { let _ = failure(&tx, id, &error.to_string()); } } + } else if outcome.is_err() { + let _ = notification(&tx, session.id().as_str(), Update::Text("\nClaude could not confirm cancellation; check the session in Claude.\n".into())); + } + } + "session/set_config_option" if params["configId"] == "model" => { + let Some(id) = id else { continue; }; + let model = match Model::parse(params["value"].as_str().unwrap_or_default()) { + Ok(model) => model, + Err(error) => { let _ = failure(&tx, id, &error.to_string()); continue; } + }; + let session = session.clone(); + let tx = tx.clone(); + turns.spawn(async move { + match session.set_model(model.clone()).await { + Ok(()) => { let _ = reply(&tx, id, json!({"configOptions":model_options(&session.catalog().await, &model)})); } + Err(error) => { let _ = failure(&tx, id, &error.to_string()); } + } + }); + } + _ => { if let Some(id) = id { let _ = failure(&tx, id, "Unsupported operation in the Claude Cloud demo"); } } + } + } + } + } + // Losing Macro's transport must not leave locally-running adapter tasks behind. + // The provider conversation remains resumable; do not archive on disconnect. + turns.abort_all(); + mirrors.abort_all(); + }); + server +} + +/// Map domain discovery to Macro's standard ACP model picker. +pub fn model_options(catalog: &Catalog, model: &Model) -> Value { + let options: Vec<_> = catalog + .options() + .iter() + .map(|m| json!({"value":m.model.id(),"name":m.name,"description":m.description})) + .collect(); + json!([{"id":"model","name":"Model","category":"model","type":"select","currentValue":model.id(),"options":options}]) +} + +fn cumulative_usage(totals: &Mutex<(u64, u64)>, usage: &Value) -> Value { + let Ok(mut totals) = totals.lock() else { + return Value::Null; + }; + totals.0 = totals + .0 + .saturating_add(usage["input_tokens"].as_u64().unwrap_or(0)); + totals.1 = totals + .1 + .saturating_add(usage["output_tokens"].as_u64().unwrap_or(0)); + json!({"inputTokens":totals.0,"outputTokens":totals.1}) +} + +fn send(tx: &UnboundedSender, value: Value) -> Result<()> { + let frame = serde_json::from_value(value).map_err(|_| Error::Protocol)?; + tx.send(ToServerMessage::Acp(AcpMessage(frame))) + .map_err(|_| Error::Network) +} +fn reply(tx: &UnboundedSender, id: Value, result: Value) -> Result<()> { + send(tx, json!({"jsonrpc":"2.0","id":id,"result":result})) +} +fn failure(tx: &UnboundedSender, id: Value, message: &str) -> Result<()> { + send( + tx, + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":message}}), + ) +} + +fn notification( + tx: &UnboundedSender, + session: &str, + update: Update, +) -> Result<()> { + let update = match update { + Update::Models { catalog, current } => { + json!({"sessionUpdate":"config_option_update","configOptions":model_options(&catalog, ¤t)}) + } + Update::Text(text) => { + json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":text}}) + } + Update::Thought(text) => { + json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":text}}) + } + Update::User(text) => { + json!({"sessionUpdate":"user_message_chunk","content":{"type":"text","text":text}}) + } + Update::Tool { + id, + name, + input, + parent, + } => { + json!({"sessionUpdate":"tool_call","toolCallId":id,"title":name,"kind":"other","status":"in_progress","rawInput":input, + "_meta":{"claudeCode":{"toolName":name,"parentToolUseId":parent}}}) + } + Update::ToolResult { id, output, failed } => { + json!({"sessionUpdate":"tool_call_update","toolCallId":id,"status":if failed {"failed"} else {"completed"},"rawOutput":output}) + } + Update::Finished { + failed, cancelled, .. + } => { + let outcome = if cancelled { + json!({"kind":"cancelled"}) + } else if failed { + json!({"kind":"failed", "message":"Claude reported an unsuccessful turn"}) + } else { + json!({"kind":"finished"}) + }; + return send( + tx, + json!({"jsonrpc":"2.0","method":"_session/turn_complete","params":{"sessionId":session,"outcome":outcome}}), + ); + } + }; + send( + tx, + json!({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":session,"update":update}}), + ) +} + +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/inbound/acp/test.rs b/crates/claude_cloud_agents/src/inbound/acp/test.rs new file mode 100644 index 00000000000..b81773026dd --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/acp/test.rs @@ -0,0 +1,231 @@ +use super::*; +use crate::domain::{ + model::{Event, SessionId}, + ports::Events, +}; +use futures::StreamExt; + +#[derive(Clone, Default)] +struct FakeCloud { + submitted: Arc>>, + history: Arc>>, +} +impl Cloud for FakeCloud { + async fn recent_sessions(&self) -> Result> { + Ok(Vec::new()) + } + async fn send_batch(&self, session: &SessionId, payloads: Vec) -> Result<()> { + assert_eq!( + payloads[payloads.len() - 2]["request"]["subtype"], + "set_model" + ); + if payloads.len() == 3 { + assert_eq!(payloads[0]["request"]["subtype"], "initialize"); + } + self.send(session, payloads.last().unwrap().clone()).await + } + async fn send(&self, _: &SessionId, payload: Value) -> Result<()> { + self.submitted.lock().await.push(payload); + Ok(()) + } + async fn history(&self, _: &SessionId) -> Result> { + Ok(self.history.lock().await.clone()) + } + async fn stream(&self, _: &SessionId, _: Option) -> Result { + let submitted = self.submitted.clone(); + let before = submitted.lock().await.len(); + Ok(futures::stream::once(async move { + let payload = loop { + if let Some(value) = submitted.lock().await.get(before).cloned() { break value; } + tokio::task::yield_now().await; + }; + if payload["type"] == "control_request" { + return Ok(Event {kind:"client_event".into(), sequence:Some(1), data:json!({"payload":{ + "type":"control_response", "response":{"subtype":"success","request_id":payload["request_id"]} + }})}); + } + Ok(Event {kind:"client_event".into(), sequence:Some(1), data:json!({"payload":{ + "type":"result", "uuid":"result1", "subtype":"success", "is_error":false, + "user_message_uuid":payload["uuid"], "usage":{"input_tokens":1,"output_tokens":2} + }})}) + }).boxed()) + } +} + +#[tokio::test] +async fn model_catalog_and_submitted_preference_roundtrip() { + let cloud = FakeCloud::default(); + *cloud.history.lock().await = vec![Event { + kind: "client_event".into(), + sequence: Some(0), + data: json!({"payload":{"type":"control_response","response":{"subtype":"success","response":{"models":[ + {"value":"default","displayName":"Provider Default"}, + {"value":"sonnet","displayName":"Provider Sonnet"}, + {"value":"new-model-2030","displayName":"New model","description":"New from provider"}]}}}}), + }]; + let session = Session::new(cloud.clone(), SessionId::parse("cse_test").unwrap()); + let mut channel = attach(session.clone()); + channel + .tx + .send(frame( + json!({"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}), + )) + .unwrap(); + let response = read(&mut channel).await; + let config = &response["result"]["configOptions"][0]; + assert_eq!(config["currentValue"], "claude-default"); + assert_eq!(config["options"].as_array().unwrap().len(), 3); + for (id, value) in [(2, "sonnet"), (3, "new-model-2030"), (4, "claude-default")] { + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":id,"method":"session/set_config_option","params":{"sessionId":"cse_test","configId":"model","value":value}}))).unwrap(); + let response = read(&mut channel).await; + assert_eq!(response["id"], id); + assert_eq!( + response["result"]["configOptions"][0]["currentValue"], + value + ); + assert_eq!(session.model().await.id(), value); + } + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":5,"method":"session/set_config_option","params":{"sessionId":"cse_test","configId":"model","value":"made-up"}}))).unwrap(); + assert!(read(&mut channel).await.get("error").is_some()); + let submitted = cloud.submitted.lock().await; + assert_eq!(submitted.len(), 3); + assert!(submitted.iter().all(|p| p["type"] == "control_request")); + assert!(submitted[2]["request"]["model"].is_null()); +} +fn frame(value: Value) -> ToRuntimeMessage { + ToRuntimeMessage::Acp(AcpMessage(serde_json::from_value(value).unwrap())) +} + +#[tokio::test] +async fn polled_catalog_updates_the_picker_without_resetting_the_saved_model() { + let cloud = FakeCloud::default(); + let mut channel = attach(Session::with_model( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + Model::parse("saved-model").unwrap(), + )); + channel + .tx + .send(frame( + json!({"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}), + )) + .unwrap(); + let initial = read(&mut channel).await; + assert_eq!( + initial["result"]["configOptions"][0]["options"] + .as_array() + .unwrap() + .len(), + 1 + ); + *cloud.history.lock().await = vec![Event { + kind: "client_event".into(), + sequence: Some(1), + data: json!({"payload":{"type":"control_response","response":{"subtype":"success","response":{"models":[{"value":"future-model","displayName":"Future model","description":"Fresh catalog"}]}}}}), + }]; + let update = read(&mut channel).await; + assert_eq!( + update["params"]["update"]["sessionUpdate"], + "config_option_update" + ); + let config = &update["params"]["update"]["configOptions"][0]; + assert_eq!(config["currentValue"], "saved-model"); + assert_eq!(config["options"][0]["value"], "future-model"); + assert_eq!(config["options"][0]["description"], "Fresh catalog"); + assert!(cloud.submitted.lock().await.is_empty()); +} +async fn read(channel: &mut ServerChannel) -> Value { + loop { + if let ToServerMessage::Acp(AcpMessage(raw)) = + tokio::time::timeout(std::time::Duration::from_secs(5), channel.rx.recv()) + .await + .unwrap() + .unwrap() + { + return serde_json::to_value(raw).unwrap(); + } + } +} + +#[tokio::test] +async fn handshake_prompt_completion_and_usage_roundtrip() { + let cloud = FakeCloud::default(); + let mut channel = attach(Session::new( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + )); + channel + .tx + .send(frame( + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}), + )) + .unwrap(); + assert_eq!( + read(&mut channel).await["result"]["agentCapabilities"]["loadSession"], + true + ); + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/","mcpServers":[]}}))).unwrap(); + assert_eq!(read(&mut channel).await["result"]["sessionId"], "cse_test"); + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"cse_test","prompt":[{"type":"text","text":"hello"}]}}))).unwrap(); + let response = read(&mut channel).await; + assert_eq!(response["result"]["stopReason"], "end_turn"); + assert_eq!(response["result"]["usage"]["inputTokens"], 1); + assert_eq!(cloud.submitted.lock().await.len(), 1); +} + +#[tokio::test] +async fn cannot_address_another_users_cloud_session() { + let cloud = FakeCloud::default(); + let mut channel = attach(Session::new( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + )); + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"sessionId":"cse_other","prompt":[{"type":"text","text":"hello"}]}}))).unwrap(); + assert!(read(&mut channel).await.get("error").is_some()); + assert!(cloud.submitted.lock().await.is_empty()); +} + +#[tokio::test] +async fn idle_cloud_turn_is_polled_into_acp_and_completed_without_a_macro_prompt() { + let cloud = FakeCloud::default(); + let mut channel = attach(Session::new( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + )); + channel.tx.send(frame(json!({"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/","mcpServers":[]}}))).unwrap(); + assert!(read(&mut channel).await.get("result").is_some()); + *cloud.history.lock().await = vec![ + Event { + kind: "client_event".into(), + sequence: Some(1), + data: json!({"payload":{"type":"user","uuid":"u","message":{"content":"web prompt"}}}), + }, + Event { + kind: "client_event".into(), + sequence: Some(2), + data: json!({"payload":{"type":"assistant","uuid":"a","message":{"id":"msg","content":[{"type":"text","text":"web answer"}]}}}), + }, + Event { + kind: "client_event".into(), + sequence: Some(3), + data: json!({"payload":{"type":"result","uuid":"r","subtype":"success","user_message_uuid":"u","usage":{}}}), + }, + ]; + assert_eq!( + read(&mut channel).await["params"]["update"]["sessionUpdate"], + "user_message_chunk" + ); + assert_eq!( + read(&mut channel).await["params"]["update"]["content"]["text"], + "web answer" + ); + let completed = read(&mut channel).await; + assert_eq!(completed["method"], "_session/turn_complete"); + assert_eq!(completed["params"]["outcome"]["kind"], "finished"); + assert!(cloud.submitted.lock().await.is_empty()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(2200), channel.rx.recv()) + .await + .is_err() + ); +} diff --git a/crates/claude_cloud_agents/src/inbound/auth.rs b/crates/claude_cloud_agents/src/inbound/auth.rs new file mode 100644 index 00000000000..1dbd65a8a72 --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/auth.rs @@ -0,0 +1,199 @@ +//! Authenticated connection endpoints; ownership and consent policy live in the domain. +use crate::domain::{ + auth::{AuthError, ClaudeAuth}, + model::Secret, +}; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, FromRef, State}, + http::{HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use macro_authorization::{ + MacroAuthorizationExtractor, MacroAuthorizationService, MacroAuthorizationState, UserOnly, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +/// Router dependencies, assembled by the service composition root. +pub struct ClaudeAuthState { + service: Arc, + authorization: MacroAuthorizationState, +} +impl ClaudeAuthState { + /// Bind the domain service and standard Macro authentication. + pub fn new(service: Arc, authorization: MacroAuthorizationState) -> Self { + Self { + service, + authorization, + } + } +} +impl Clone for ClaudeAuthState { + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + authorization: self.authorization.clone(), + } + } +} +impl FromRef> for MacroAuthorizationState { + fn from_ref(state: &ClaudeAuthState) -> Self { + state.authorization.clone() + } +} + +/// A JSON body is required on writes, including start/disconnect (no form-based CSRF). +#[derive(Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EmptyRequest {} + +/// One-time manual code. Deliberately does not implement Debug. +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CompleteRequest { + /// Server-issued handle; never an owner selected by the caller. + pub attempt_id: String, + /// Claude's complete code#state string, not an access token. + #[schema(value_type = String)] + pub code: Secret, +} + +/// Safe connection metadata. +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StatusResponse { + /// Whether this deployment supports browser connection. + pub enabled: bool, + /// Whether the authenticated Macro user has connected. + pub connected: bool, + /// Whether reconnecting after service restart is required. + pub ephemeral: bool, +} + +/// Public PKCE challenge and attempt handle; contains no verifier or provider tokens. +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StartResponse { + /// Opaque owner-bound attempt handle. + pub attempt_id: String, + /// Claude-hosted consent page. + pub authorization_url: String, + /// Attempt lifetime in seconds. + pub expires_in: u64, +} + +/// Build `/claude-auth` routes. Bot and harness credentials are rejected by UserOnly. +pub fn router(state: ClaudeAuthState) -> Router { + Router::new() + .route( + "/claude-auth", + get(status::).delete(disconnect::), + ) + .route("/claude-auth/start", post(start::)) + .route("/claude-auth/complete", post(complete::)) + .layer(DefaultBodyLimit::max(8192)) + .layer(axum::middleware::map_response( + |mut response: Response| async move { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + }, + )) + .with_state(state) +} + +/// Read connection status for the authenticated user only. +#[utoipa::path(get, path = "/claude-auth", tag = "claude-auth", security(("bearerAuth" = [])), responses((status = 200, body = StatusResponse), (status = 401, description = "Unauthenticated")))] +pub async fn status( + State(state): State>, + auth: MacroAuthorizationExtractor, +) -> Json { + let status = state + .service + .status(auth.authorization.macro_user_id.as_ref()) + .await; + Json(StatusResponse { + enabled: status.enabled, + connected: status.connected, + ephemeral: status.ephemeral, + }) +} + +/// Create an expiring PKCE challenge for the authenticated user. +#[utoipa::path(post, path = "/claude-auth/start", tag = "claude-auth", security(("bearerAuth" = [])), request_body = EmptyRequest, responses((status = 200, body = StartResponse), (status = 403, description = "Disabled"), (status = 429, description = "Too many attempts")))] +pub async fn start( + State(state): State>, + auth: MacroAuthorizationExtractor, + Json(_input): Json, +) -> Response { + match state + .service + .begin(auth.authorization.macro_user_id.as_ref()) + .await + { + Ok(login) => Json(StartResponse { + attempt_id: login.attempt_id, + authorization_url: login.authorization_url, + expires_in: login.expires_in, + }) + .into_response(), + Err(error) => failure(error), + } +} + +/// Exchange one code; never return access or refresh tokens. +#[utoipa::path(post, path = "/claude-auth/complete", tag = "claude-auth", security(("bearerAuth" = [])), request_body = CompleteRequest, responses((status = 204, description = "Connected"), (status = 400, description = "Invalid code"), (status = 409, description = "Expired or replayed"), (status = 502, description = "Provider failed")))] +pub async fn complete( + State(state): State>, + auth: MacroAuthorizationExtractor, + Json(input): Json, +) -> Response { + match state + .service + .complete( + auth.authorization.macro_user_id.as_ref(), + &input.attempt_id, + input.code, + ) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => failure(error), + } +} + +/// Forget only the authenticated user's grant and cancel pending consent. +#[utoipa::path(delete, path = "/claude-auth", tag = "claude-auth", security(("bearerAuth" = [])), request_body = EmptyRequest, responses((status = 204, description = "Disconnected"), (status = 403, description = "Disabled")))] +pub async fn disconnect( + State(state): State>, + auth: MacroAuthorizationExtractor, + Json(_input): Json, +) -> Response { + match state + .service + .disconnect(auth.authorization.macro_user_id.as_ref()) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => failure(error), + } +} + +fn failure(error: AuthError) -> Response { + let status = match error { + AuthError::Disabled => StatusCode::FORBIDDEN, + AuthError::InvalidAttempt => StatusCode::CONFLICT, + AuthError::InvalidCode => StatusCode::BAD_REQUEST, + AuthError::Busy => StatusCode::TOO_MANY_REQUESTS, + AuthError::Provider(_) => StatusCode::BAD_GATEWAY, + }; + ( + status, + Json(serde_json::json!({"message": error.to_string()})), + ) + .into_response() +} diff --git a/crates/claude_cloud_agents/src/inbound/mod.rs b/crates/claude_cloud_agents/src/inbound/mod.rs new file mode 100644 index 00000000000..ec91e75cafc --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/mod.rs @@ -0,0 +1,4 @@ +//! Macro's ACP-facing adapter. +pub mod acp; +/// Authenticated browser connection endpoints. +pub mod auth; diff --git a/crates/claude_cloud_agents/src/lib.rs b/crates/claude_cloud_agents/src/lib.rs new file mode 100644 index 00000000000..dcd68a35853 --- /dev/null +++ b/crates/claude_cloud_agents/src/lib.rs @@ -0,0 +1,6 @@ +#![deny(missing_docs)] +//! Demo-only Claude Code cloud provider using user subscription OAuth. + +pub mod domain; +pub mod inbound; +pub mod outbound; diff --git a/crates/claude_cloud_agents/src/outbound/credentials.rs b/crates/claude_cloud_agents/src/outbound/credentials.rs new file mode 100644 index 00000000000..394ed615a8f --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/credentials.rs @@ -0,0 +1,205 @@ +//! Legacy private-file and memory adapters for standalone smoke tests. +pub use crate::domain::credentials::AccountCredentials; +use crate::domain::{ + credentials::{GrantRepository, RefreshGrant}, + model::{Credentials, Error, Result, Secret}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::{io::AsyncWriteExt, sync::Mutex}; + +/// Public OAuth client used by Claude Code Desktop. +pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; +/// Compatibility name for the standalone smoke runner. +pub type FileCredentials = AccountCredentials; + +#[derive(Default, Serialize, Deserialize)] +struct FileData { + users: BTreeMap, +} +struct FileRepository { + path: Option, + data: Mutex, +} + +impl AccountCredentials { + /// Empty process-local store for tests. Reads no existing credentials. + pub fn memory() -> Result { + Ok(Self::new( + Arc::new(FileRepository { + path: None, + data: Mutex::default(), + }), + Arc::new(ClaudeRefresh::new()?), + )) + } + /// Explicit private-file opt-in for standalone experiments only. + pub async fn open(path: PathBuf) -> Result { + check_private(&path, false).await?; + check_private(path.parent().ok_or(Error::Credentials)?, true).await?; + let bytes = zeroize::Zeroizing::new( + tokio::fs::read(&path) + .await + .map_err(|_| Error::Credentials)?, + ); + if bytes.len() > 2_000_000 { + return Err(Error::Credentials); + } + let data: FileData = serde_json::from_slice(&bytes).map_err(|_| Error::Credentials)?; + for (owner, grant) in &data.users { + if owner.trim() != owner || !owner.starts_with("macro|") || owner.contains('\0') { + return Err(Error::Credentials); + } + uuid::Uuid::parse_str(&grant.organization_id).map_err(|_| Error::Credentials)?; + if !grant.environment_id.starts_with("env_") + || !grant + .environment_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::Credentials); + } + } + Ok(Self::new( + Arc::new(FileRepository { + path: Some(path), + data: Mutex::new(data), + }), + Arc::new(ClaudeRefresh::new()?), + )) + } +} + +impl FileRepository { + async fn persist(&self, data: &FileData) -> Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let bytes = + zeroize::Zeroizing::new(serde_json::to_vec(data).map_err(|_| Error::Credentials)?); + let temp = path.with_extension(format!("{}.tmp", uuid::Uuid::now_v7())); + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&temp).await.map_err(|_| Error::Credentials)?; + file.write_all(&bytes) + .await + .map_err(|_| Error::Credentials)?; + file.sync_all().await.map_err(|_| Error::Credentials)?; + tokio::fs::rename(&temp, path) + .await + .map_err(|_| Error::Credentials) + } +} +#[async_trait::async_trait] +impl GrantRepository for FileRepository { + async fn get(&self, owner: &str) -> Result> { + Ok(self.data.lock().await.users.get(owner).cloned()) + } + async fn put(&self, owner: &str, grant: &Credentials) -> Result<()> { + let mut data = self.data.lock().await; + let previous = data.users.insert(owner.to_owned(), grant.clone()); + if let Err(error) = self.persist(&data).await { + match previous { + Some(value) => { + data.users.insert(owner.to_owned(), value); + } + None => { + data.users.remove(owner); + } + } + return Err(error); + } + Ok(()) + } + async fn delete(&self, owner: &str) -> Result<()> { + let mut data = self.data.lock().await; + let previous = data.users.remove(owner); + if let Err(error) = self.persist(&data).await { + if let Some(value) = previous { + data.users.insert(owner.to_owned(), value); + } + return Err(error); + } + Ok(()) + } +} + +/// Fixed-origin token-refresh transport. +pub struct ClaudeRefresh { + http: reqwest::Client, +} +impl ClaudeRefresh { + /// Construct without redirects or automatic retries. + pub fn new() -> Result { + Ok(Self { + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|_| Error::Network)?, + }) + } +} +#[async_trait::async_trait] +impl RefreshGrant for ClaudeRefresh { + async fn refresh(&self, current: &Credentials) -> Result { + let refresh = current.refresh_token.as_ref().ok_or(Error::Authorization)?; + let response = self.http.post(TOKEN_URL).json(&serde_json::json!({ + "grant_type":"refresh_token", "refresh_token":refresh.expose(), "client_id":CLIENT_ID, + "scope":"user:inference user:profile user:sessions:claude_code" + })).send().await.map_err(|_| Error::Network)?; + if !response.status().is_success() { + return Err(Error::Authorization); + } + #[derive(Deserialize)] + struct Refresh { + access_token: Secret, + refresh_token: Option, + expires_in: u64, + } + let refreshed: Refresh = response.json().await.map_err(|_| Error::Protocol)?; + if refreshed.expires_in < 1 { + return Err(Error::Authorization); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Credentials)? + .as_secs(); + let mut updated = current.clone(); + updated.access_token = refreshed.access_token; + updated.refresh_token = refreshed.refresh_token.or(updated.refresh_token); + updated.expires_at = now.saturating_add(refreshed.expires_in); + Ok(updated) + } +} +async fn check_private(path: &Path, directory: bool) -> Result<()> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .map_err(|_| Error::Credentials)?; + if metadata.file_type().is_symlink() + || (directory && !metadata.is_dir()) + || (!directory && !metadata.is_file()) + { + return Err(Error::Credentials); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(Error::Credentials); + } + } + #[cfg(not(unix))] + return Err(Error::Credentials); + Ok(()) +} +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/outbound/credentials/test.rs b/crates/claude_cloud_agents/src/outbound/credentials/test.rs new file mode 100644 index 00000000000..564e02ac21f --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/credentials/test.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::domain::auth::ConnectionStore; +use std::os::unix::fs::PermissionsExt; + +async fn fixture() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = dir.path().join("credentials.json"); + let value = serde_json::json!({"users":{"macro|demo@example.com":{ + "access_token":"fixture-access", "refresh_token":"fixture-refresh", "expires_at":4_000_000_000u64, + "organization_id":"00000000-0000-4000-8000-000000000001", "environment_id":"env_fixture" + }}}); + tokio::fs::write(&path, serde_json::to_vec(&value).unwrap()) + .await + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + (dir, path) +} + +#[tokio::test] +async fn exact_owner_only_and_redacted_debug() { + let (_dir, path) = fixture().await; + let store = FileCredentials::open(path).await.unwrap(); + assert!(matches!( + store.resolve("macro|other@example.com").await, + Err(Error::NotConnected) + )); + let credentials = store.resolve("macro|demo@example.com").await.unwrap(); + assert_eq!(credentials.access_token.expose(), "fixture-access"); + let debug = format!("{credentials:?}"); + assert!(!debug.contains("fixture-access")); + assert!(!debug.contains("fixture-refresh")); +} + +#[tokio::test] +async fn refuses_group_readable_credential_file() { + let (_dir, path) = fixture().await; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + assert!(matches!( + FileCredentials::open(path).await, + Err(Error::Credentials) + )); +} + +#[tokio::test] +async fn refuses_symlink_credential_file() { + let (dir, path) = fixture().await; + let link = dir.path().join("link.json"); + std::os::unix::fs::symlink(path, &link).unwrap(); + assert!(matches!( + FileCredentials::open(link).await, + Err(Error::Credentials) + )); +} + +#[test] +fn rejects_header_injection_and_path_injection() { + assert!(Secret::parse("abc\r\nAuthorization: injected".into()).is_err()); + assert!(crate::domain::model::SessionId::parse("cse_x/../../other").is_err()); +} + +#[tokio::test] +async fn memory_connection_is_owner_scoped_shared_and_forgotten_on_restart() { + let store = AccountCredentials::memory().unwrap(); + let credentials = Credentials { + access_token: Secret::parse("memory-access".into()).unwrap(), + refresh_token: Some(Secret::parse("memory-refresh".into()).unwrap()), + expires_at: 4_000_000_000, + organization_id: "00000000-0000-4000-8000-000000000001".into(), + environment_id: "env_fixture".into(), + }; + store + .save("macro|alice@example.com", credentials) + .await + .unwrap(); + assert!(store.clone().connected("macro|alice@example.com").await); + assert!(!store.connected("macro|bob@example.com").await); + assert!(matches!( + store.resolve("macro|bob@example.com").await, + Err(Error::NotConnected) + )); + assert_eq!( + store + .resolve("macro|alice@example.com") + .await + .unwrap() + .access_token + .expose(), + "memory-access" + ); + assert!( + !AccountCredentials::memory() + .unwrap() + .connected("macro|alice@example.com") + .await + ); + store.remove("macro|bob@example.com").await.unwrap(); + assert!(store.connected("macro|alice@example.com").await); + store.remove("macro|alice@example.com").await.unwrap(); + assert!(matches!( + store.resolve("macro|alice@example.com").await, + Err(Error::NotConnected) + )); +} diff --git a/crates/claude_cloud_agents/src/outbound/http.rs b/crates/claude_cloud_agents/src/outbound/http.rs new file mode 100644 index 00000000000..dc202a9d16a --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/http.rs @@ -0,0 +1,292 @@ +//! The experimentally verified OAuth API origin, not the cookie-authenticated web origin. +use super::credentials::FileCredentials; +use crate::domain::{ + model::{Error, Event, Result, SessionId}, + ports::{Cloud, Events}, +}; +use futures::StreamExt; +use serde_json::{Value, json}; +use std::collections::VecDeque; + +const API: &str = "https://api.anthropic.com"; +const BETA: &str = "ccr-byoc-2025-07-29"; + +/// HTTP client pinned to one Macro account and the official Anthropic origin. +#[derive(Clone)] +pub struct Client { + credentials: FileCredentials, + owner: String, + http: reqwest::Client, +} +impl Client { + /// Resolve account allowlisting before attaching a runtime. + pub async fn new(credentials: FileCredentials, owner: String) -> Result { + credentials.resolve(&owner).await?; + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| Error::Network)?; + Ok(Self { + credentials, + owner, + http, + }) + } + + async fn request(&self, path: &str, body: Option) -> Result { + let credentials = self.credentials.resolve(&self.owner).await?; + let url = format!("{API}{path}"); + let request = match body { + Some(body) => self.http.post(url).json(&body), + None => self.http.get(url), + }; + let response = request + .bearer_auth(credentials.access_token.expose()) + .header("anthropic-version", "2023-06-01") + .header("anthropic-beta", BETA) + .header("anthropic-client-feature", "ccr") + .header("x-organization-uuid", &credentials.organization_id) + .header("anthropic-client-platform", "desktop_app") + .header("anthropic-client-app", "com.anthropic.claudefordesktop") + .header("anthropic-client-version", "1.24012.9") + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| Error::Network)?; + checked(response) + } + + /// Create only in the explicitly selected, active Anthropic cloud environment. + pub async fn create(&self, instructions: &str) -> Result { + let credentials = self.credentials.resolve(&self.owner).await?; + let response = self + .http + .get(format!("{API}/v1/environments")) + .bearer_auth(credentials.access_token.expose()) + .header("anthropic-version", "2023-06-01") + .header( + "anthropic-beta", + "environments-2026-03-01,environments-package-support-2025-12-09", + ) + .header("x-organization-uuid", &credentials.organization_id) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| Error::Network)?; + let environments: Value = checked(response)? + .json() + .await + .map_err(|_| Error::Protocol)?; + let valid = environments["data"].as_array().is_some_and(|all| { + all.iter().any(|e| { + e["id"].as_str() == Some(&credentials.environment_id) + && e["config"]["type"] == "cloud" + && e["state"] == "active" + && e["archived_at"].is_null() + }) + }); + if !valid { + return Err(Error::Protocol); + } + let mut config = json!({"sources": [], "outcomes": []}); + if !instructions.is_empty() { + config["append_system_prompt"] = json!(instructions); + } + let response = self.request("/v1/code/sessions", Some(json!({ + "title": "Macro Claude Cloud demo", "environment_id": credentials.environment_id, + "config": config, "events": [] + }))).await.map_err(|error| match error { Error::Network => Error::UncertainCreate, other => other })?; + let data: Value = response.json().await.map_err(|_| Error::UncertainCreate)?; + SessionId::parse( + data["session"]["id"] + .as_str() + .ok_or(Error::UncertainCreate)?, + ) + } + + /// Reversible cleanup; never deletes the user's Claude transcript. + pub async fn archive(&self, session: &SessionId) -> Result<()> { + self.request( + &format!("/v1/code/sessions/{}/archive", session.as_str()), + Some(json!({})), + ) + .await?; + Ok(()) + } +} + +impl Cloud for Client { + async fn recent_sessions(&self) -> Result> { + let response: Value = self + .request("/v1/code/sessions?limit=5", None) + .await? + .json() + .await + .map_err(|_| Error::Protocol)?; + response["data"] + .as_array() + .ok_or(Error::Protocol)? + .iter() + .take(5) + .map(|row| SessionId::parse(row["id"].as_str().ok_or(Error::Protocol)?)) + .collect() + } + async fn send_batch(&self, session: &SessionId, payloads: Vec) -> Result<()> { + self.request( + &format!("/v1/code/sessions/{}/events", session.as_str()), + Some(event_batch(payloads)), + ) + .await?; + Ok(()) + } + async fn send(&self, session: &SessionId, payload: Value) -> Result<()> { + self.request( + &format!("/v1/code/sessions/{}/events", session.as_str()), + Some(json!({"events": [{"payload": payload}]})), + ) + .await?; + Ok(()) + } + + async fn history(&self, session: &SessionId) -> Result> { + let mut events = Vec::new(); + let mut next = None::; + // Bound memory and requests; never silently treat a partial history as complete. + for _ in 0..20 { + let mut url = reqwest::Url::parse(&format!( + "{API}/v1/code/sessions/{}/events", + session.as_str() + )) + .map_err(|_| Error::Protocol)?; + url.query_pairs_mut() + .append_pair("limit", "500") + .append_pair("sort_order", "asc"); + if let Some(cursor) = &next { + url.query_pairs_mut().append_pair("cursor", cursor); + } + let path = format!("{}?{}", url.path(), url.query().unwrap_or_default()); + let response: Value = self + .request(&path, None) + .await? + .json() + .await + .map_err(|_| Error::Protocol)?; + for data in response["data"].as_array().ok_or(Error::Protocol)? { + events.push(Event { + kind: "client_event".into(), + sequence: sequence(&data["sequence_num"]), + data: data.clone(), + }); + } + match response["next_cursor"].as_str().filter(|s| !s.is_empty()) { + None => return Ok(events), + Some(cursor) if next.as_deref() != Some(cursor) => next = Some(cursor.to_owned()), + _ => return Err(Error::Recovery), + } + } + Err(Error::Recovery) + } + + async fn stream(&self, session: &SessionId, cursor: Option) -> Result { + let credentials = self.credentials.resolve(&self.owner).await?; + let mut request = self + .http + .get(format!( + "{API}/v1/code/sessions/{}/events/stream", + session.as_str() + )) + .bearer_auth(credentials.access_token.expose()) + .header("Accept", "text/event-stream") + .header("anthropic-version", "2023-06-01") + .header("anthropic-beta", BETA) + .header("x-organization-uuid", &credentials.organization_id); + if let Some(cursor) = cursor { + request = request + .query(&[("from_sequence_num", cursor)]) + .header("last-event-id", cursor.to_string()); + } + let response = tokio::time::timeout(std::time::Duration::from_secs(30), request.send()) + .await + .map_err(|_| Error::Network)? + .map_err(|_| Error::Network)?; + let response = checked(response)?; + if !response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .is_some_and(|s| s.starts_with("text/event-stream")) + { + return Err(Error::Protocol); + } + let state = ( + response.bytes_stream().boxed(), + sse_core::SseDecoder::with_limit( + std::num::NonZeroUsize::new(4 * 1024 * 1024).expect("nonzero payload bound"), + ), + VecDeque::new(), + ); + Ok( + futures::stream::try_unfold( + state, + |(mut bytes, mut decoder, mut pending)| async move { + loop { + if let Some(event) = pending.pop_front() { + return Ok(Some((event, (bytes, decoder, pending)))); + } + match tokio::time::timeout(std::time::Duration::from_secs(45), bytes.next()) + .await + { + Ok(Some(Ok(mut chunk))) => { + while let Some(record) = decoder.next(&mut chunk) { + let sse_core::SseEvent::Message(message) = + record.map_err(|_| Error::Protocol)? + else { + continue; + }; + if message.data.is_empty() { + continue; + } + let data: Value = serde_json::from_str(&message.data) + .map_err(|_| Error::Protocol)?; + let sequence = message + .last_event_id + .as_ref() + .and_then(|s| s.parse().ok()) + .or_else(|| sequence(&data["sequence_num"])); + pending.push_back(Event { + kind: message.event.into_owned(), + data, + sequence, + }); + } + } + Ok(None) => return Ok(None), + _ => return Err(Error::Network), + } + } + }, + ) + .boxed(), + ) + } +} + +fn checked(response: reqwest::Response) -> Result { + match response.status().as_u16() { + 200..=299 => Ok(response), + 401 | 403 => Err(Error::Authorization), + 410 => Err(Error::Recovery), + status => Err(Error::Http(status)), + } +} +fn sequence(value: &Value) -> Option { + value.as_u64().or_else(|| value.as_str()?.parse().ok()) +} + +fn event_batch(payloads: Vec) -> Value { + json!({"events": payloads.into_iter().map(|payload| json!({"payload":payload})).collect::>()}) +} + +#[cfg(test)] +mod test; diff --git a/crates/claude_cloud_agents/src/outbound/http/test.rs b/crates/claude_cloud_agents/src/outbound/http/test.rs new file mode 100644 index 00000000000..2d68b5dfa29 --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/http/test.rs @@ -0,0 +1,12 @@ +use super::*; + +#[test] +fn model_and_prompt_are_serialized_in_one_ordered_batch() { + let batch = event_batch(vec![ + json!({"type":"control_request","request":{"subtype":"set_model","model":"sonnet"}}), + json!({"type":"user","message":{"content":"hello"}}), + ]); + assert_eq!(batch["events"].as_array().unwrap().len(), 2); + assert_eq!(batch["events"][0]["payload"]["request"]["model"], "sonnet"); + assert_eq!(batch["events"][1]["payload"]["type"], "user"); +} diff --git a/crates/claude_cloud_agents/src/outbound/mod.rs b/crates/claude_cloud_agents/src/outbound/mod.rs new file mode 100644 index 00000000000..4b46d63cc81 --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/mod.rs @@ -0,0 +1,6 @@ +//! Private demo credentials and Anthropic HTTP transport. +pub mod credentials; +pub mod http; +/// Claude's browser-consent OAuth protocol. +pub mod oauth; +pub mod postgres; diff --git a/crates/claude_cloud_agents/src/outbound/oauth.rs b/crates/claude_cloud_agents/src/outbound/oauth.rs new file mode 100644 index 00000000000..cc0fa661c9c --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/oauth.rs @@ -0,0 +1,162 @@ +//! Claude Code's registered manual callback works across host/container boundaries. +use super::credentials::CLIENT_ID; +use crate::domain::{ + auth::OAuthProvider, + model::{Credentials, Error, Secret}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const REDIRECT_URI: &str = "https://platform.claude.com/oauth/code/callback"; +const SCOPES: &str = "user:profile user:inference user:sessions:claude_code"; + +/// HTTP OAuth adapter. Origins, redirect, client and scopes cannot be caller-controlled. +pub struct ClaudeOAuth { + http: reqwest::Client, +} + +impl ClaudeOAuth { + /// Construct a bounded client without redirects or automatic mutation retries. + pub fn new() -> Result { + Ok(Self { + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| Error::Network)?, + }) + } +} + +impl OAuthProvider for ClaudeOAuth { + fn authorization_url(&self, state: &str, verifier: &Secret) -> String { + let mut url = reqwest::Url::parse("https://claude.com/cai/oauth/authorize") + .expect("fixed OAuth origin"); + url.query_pairs_mut() + .append_pair("code", "true") + .append_pair("client_id", CLIENT_ID) + .append_pair("response_type", "code") + .append_pair("redirect_uri", REDIRECT_URI) + .append_pair("scope", SCOPES) + .append_pair("state", state) + .append_pair( + "code_challenge", + &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.expose().as_bytes())), + ) + .append_pair("code_challenge_method", "S256"); + url.into() + } + + async fn exchange( + &self, + code: Secret, + state: &str, + verifier: Secret, + ) -> Result { + let response = self.http.post("https://platform.claude.com/v1/oauth/token") + .json(&serde_json::json!({ "grant_type": "authorization_code", "code": code.expose(), + "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "state": state, "code_verifier": verifier.expose() })) + .send().await.map_err(|_| Error::Network)?; + #[derive(Deserialize)] + struct Tokens { + access_token: Secret, + refresh_token: Option, + expires_in: u64, + scope: String, + } + let tokens: Tokens = checked(response)? + .json() + .await + .map_err(|_| Error::Protocol)?; + if tokens.expires_in == 0 + || !SCOPES + .split_whitespace() + .all(|scope| tokens.scope.split_whitespace().any(|s| s == scope)) + { + return Err(Error::Authorization); + } + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Protocol)? + .as_secs() + .saturating_add(tokens.expires_in); + let response = self + .http + .get("https://api.anthropic.com/api/oauth/profile") + .bearer_auth(tokens.access_token.expose()) + .header("anthropic-version", "2023-06-01") + .send() + .await + .map_err(|_| Error::Network)?; + #[derive(Deserialize)] + struct Organization { + uuid: uuid::Uuid, + } + #[derive(Deserialize)] + struct Profile { + organization: Organization, + } + let profile: Profile = checked(response)? + .json() + .await + .map_err(|_| Error::Protocol)?; + let organization_id = profile.organization.uuid.to_string(); + let response = self + .http + .get("https://api.anthropic.com/v1/environments") + .bearer_auth(tokens.access_token.expose()) + .header("anthropic-version", "2023-06-01") + .header("x-organization-uuid", &organization_id) + .header( + "anthropic-beta", + "environments-2026-03-01,environments-package-support-2025-12-09", + ) + .send() + .await + .map_err(|_| Error::Network)?; + let environments: serde_json::Value = checked(response)? + .json() + .await + .map_err(|_| Error::Protocol)?; + let mut candidates = environments["data"] + .as_array() + .ok_or(Error::Protocol)? + .iter() + .filter(|e| { + e["config"]["type"] == "cloud" + && e["state"] == "active" + && e["archived_at"].is_null() + }); + let environment_id = candidates + .next() + .and_then(|e| e["id"].as_str()) + .ok_or(Error::CloudEnvironment)?; + if candidates.next().is_some() { + return Err(Error::CloudEnvironment); + } + if !environment_id.starts_with("env_") + || !environment_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::Protocol); + } + Ok(Credentials { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at, + organization_id, + environment_id: environment_id.to_owned(), + }) + } +} + +fn checked(response: reqwest::Response) -> Result { + match response.status().as_u16() { + 200..=299 => Ok(response), + 400 | 401 | 403 => Err(Error::Authorization), + status => Err(Error::Http(status)), + } +} diff --git a/crates/claude_cloud_agents/src/outbound/postgres.rs b/crates/claude_cloud_agents/src/outbound/postgres.rs new file mode 100644 index 00000000000..ce91779adbd --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/postgres.rs @@ -0,0 +1,73 @@ +//! Encrypted owner-keyed Claude grants. No plaintext tokens are stored in SQL. +use crate::domain::{ + credentials::GrantRepository, + model::{Credentials, Error, Result}, +}; +use cursor_api_key::cipher::KmsCiphertexts; +use std::collections::HashMap; + +#[cfg(test)] +mod test; + +/// PostgreSQL persistence with purpose- and owner-bound KMS ciphertext. +pub struct PgClaudeGrants { + pool: sqlx::PgPool, + cipher: K, +} +impl PgClaudeGrants { + /// Supply the existing local KMS transport at the composition root. + pub fn new(pool: sqlx::PgPool, cipher: K) -> Self { + Self { pool, cipher } + } +} +fn context(owner: &str) -> HashMap { + HashMap::from([ + ("purpose".into(), "claude-cloud-oauth".into()), + ("user_id".into(), owner.into()), + ("encryption_version".into(), "1".into()), + ]) +} +#[async_trait::async_trait] +impl GrantRepository for PgClaudeGrants { + async fn get(&self, owner: &str) -> Result> { + let row = sqlx::query!("SELECT grant_ciphertext, kms_key_id, encryption_version FROM claude_oauth_grants WHERE user_id = $1", owner) + .fetch_optional(&self.pool).await.map_err(|_| Error::Credentials)?; + let Some(row) = row else { + return Ok(None); + }; + if row.encryption_version != 1 { + return Err(Error::Credentials); + } + let bytes = self + .cipher + .decrypt(&row.kms_key_id, &row.grant_ciphertext, context(owner)) + .await + .map_err(|_| Error::Credentials)?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| Error::Credentials) + } + async fn put(&self, owner: &str, grant: &Credentials) -> Result<()> { + let bytes = + zeroize::Zeroizing::new(serde_json::to_vec(grant).map_err(|_| Error::Credentials)?); + // Direct KMS encryption has a 4 KiB plaintext limit. Fail closed. + if bytes.len() > 4096 { + return Err(Error::Credentials); + } + let (ciphertext, key_id) = self + .cipher + .encrypt(context(owner), &bytes) + .await + .map_err(|_| Error::Credentials)?; + sqlx::query!("INSERT INTO claude_oauth_grants (user_id, grant_ciphertext, kms_key_id, encryption_version) VALUES ($1, $2, $3, 1) ON CONFLICT (user_id) DO UPDATE SET grant_ciphertext = EXCLUDED.grant_ciphertext, kms_key_id = EXCLUDED.kms_key_id, encryption_version = 1, updated_at = now()", owner, ciphertext, key_id) + .execute(&self.pool).await.map_err(|_| Error::Credentials)?; + Ok(()) + } + async fn delete(&self, owner: &str) -> Result<()> { + sqlx::query!("DELETE FROM claude_oauth_grants WHERE user_id = $1", owner) + .execute(&self.pool) + .await + .map_err(|_| Error::Credentials)?; + Ok(()) + } +} diff --git a/crates/claude_cloud_agents/src/outbound/postgres/test.rs b/crates/claude_cloud_agents/src/outbound/postgres/test.rs new file mode 100644 index 00000000000..c94b8d76a79 --- /dev/null +++ b/crates/claude_cloud_agents/src/outbound/postgres/test.rs @@ -0,0 +1,102 @@ +use super::*; +use crate::domain::model::Secret; +use cursor_api_key::cipher::KmsCiphertextsError; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use std::sync::{Arc, Mutex}; +use zeroize::Zeroizing; + +type CipherRecords = HashMap, (HashMap, Vec)>; +#[derive(Clone, Default)] +struct FakeKms(Arc>); +#[async_trait::async_trait] +impl KmsCiphertexts for FakeKms { + async fn encrypt( + &self, + context: HashMap, + plain: &[u8], + ) -> std::result::Result<(Vec, String), KmsCiphertextsError> { + let ciphertext = uuid::Uuid::now_v7().as_bytes().to_vec(); + self.0 + .lock() + .unwrap() + .insert(ciphertext.clone(), (context, plain.to_vec())); + Ok((ciphertext, "test-key".into())) + } + async fn decrypt( + &self, + key: &str, + bytes: &[u8], + context: HashMap, + ) -> std::result::Result>, KmsCiphertextsError> { + let records = self.0.lock().unwrap(); + let (expected, plain) = records.get(bytes).ok_or(KmsCiphertextsError)?; + if key != "test-key" || expected != &context { + return Err(KmsCiphertextsError); + } + Ok(Zeroizing::new(plain.clone())) + } +} + +async fn user(pool: &sqlx::PgPool, id: &str) { + let uuid = uuid::Uuid::now_v7(); + let email = format!("{id}@example.com"); + sqlx::query!(r#"WITH u AS (INSERT INTO macro_user (id, username, email, stripe_customer_id) VALUES ($1,$2,$2,$2) RETURNING id) INSERT INTO "User" ("id", "email", "macro_user_id") SELECT $3,$2,u.id FROM u"#, uuid, email, id).execute(pool).await.unwrap(); +} +fn grant() -> Credentials { + Credentials { + access_token: Secret::parse("test-access".into()).unwrap(), + refresh_token: Some(Secret::parse("test-refresh".into()).unwrap()), + expires_at: 4_000_000_000, + organization_id: "00000000-0000-4000-8000-000000000001".into(), + environment_id: "env_test".into(), + } +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn durable_encrypted_grants_are_owner_bound_replaceable_and_removable(pool: sqlx::PgPool) { + user(&pool, "alice").await; + user(&pool, "bob").await; + let kms = FakeKms::default(); + let store = PgClaudeGrants::new(pool.clone(), kms.clone()); + store.put("alice", &grant()).await.unwrap(); + assert!(store.get("bob").await.unwrap().is_none()); + let ciphertext = sqlx::query_scalar!( + "SELECT grant_ciphertext FROM claude_oauth_grants WHERE user_id = 'alice'" + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!ciphertext.windows(11).any(|w| w == b"test-access")); + // New adapter instance, same durable DB: no process-local grant cache. + let restarted = PgClaudeGrants::new(pool.clone(), kms); + assert_eq!( + restarted + .get("alice") + .await + .unwrap() + .unwrap() + .access_token + .expose(), + "test-access" + ); + let mut rotated = grant(); + rotated.refresh_token = Some(Secret::parse("rotated-refresh".into()).unwrap()); + restarted.put("alice", &rotated).await.unwrap(); + assert_eq!( + store + .get("alice") + .await + .unwrap() + .unwrap() + .refresh_token + .unwrap() + .expose(), + "rotated-refresh" + ); + sqlx::query!("INSERT INTO claude_oauth_grants (user_id, grant_ciphertext, kms_key_id, encryption_version) SELECT 'bob', grant_ciphertext, kms_key_id, encryption_version FROM claude_oauth_grants WHERE user_id = 'alice'").execute(&pool).await.unwrap(); + assert!(matches!(store.get("bob").await, Err(Error::Credentials))); + restarted.delete("bob").await.unwrap(); + assert!(store.get("alice").await.unwrap().is_some()); + restarted.delete("alice").await.unwrap(); + assert!(store.get("alice").await.unwrap().is_none()); +} diff --git a/crates/macro_db_client/migrations/20260910144348_add_claude_oauth_grants.sql b/crates/macro_db_client/migrations/20260910144348_add_claude_oauth_grants.sql new file mode 100644 index 00000000000..15800dd96ea --- /dev/null +++ b/crates/macro_db_client/migrations/20260910144348_add_claude_oauth_grants.sql @@ -0,0 +1,10 @@ +-- Refreshable subscription grants, separate from Cursor keys and macrod hashes. +-- KMS binds the encrypted JSON to purpose and user_id. +CREATE TABLE claude_oauth_grants ( + user_id TEXT PRIMARY KEY REFERENCES "User" ("id") ON DELETE CASCADE, + grant_ciphertext BYTEA NOT NULL CHECK (octet_length(grant_ciphertext) > 0), + kms_key_id TEXT NOT NULL CHECK (kms_key_id <> ''), + encryption_version SMALLINT NOT NULL CHECK (encryption_version > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/docs/AGENT_GUIDE/README.md b/docs/AGENT_GUIDE/README.md index 4f1bd73819c..caea37a9626 100644 --- a/docs/AGENT_GUIDE/README.md +++ b/docs/AGENT_GUIDE/README.md @@ -10,6 +10,7 @@ verified live against a local stack (`just run_local`). | [navigation.md](navigation.md) | Routes, sidebar, command menu, keyboard model, splits | | [documents.md](documents.md) | Creating docs, typing in the editor, AI edit, comments, side panel | | [ai-chat.md](ai-chat.md) | Standalone and doc-scoped AI chat | +| [../CLAUDE_CLOUD_DEMO.md](../CLAUDE_CLOUD_DEMO.md) | Claude in Harness settings, encrypted saved connection, Open in Claude, and cloud-side transcript polling | | [channels.md](channels.md) | Channels: create, invite, message, participants, bots | | [tasks.md](tasks.md) | Task list and creation dialog | | [surfaces.md](surfaces.md) | Every other surface: inbox, email, search, files, calendar, calls, customers, activity, settings | diff --git a/docs/AGENT_GUIDE/navigation.md b/docs/AGENT_GUIDE/navigation.md index 9f3605316a2..0423ca814f4 100644 --- a/docs/AGENT_GUIDE/navigation.md +++ b/docs/AGENT_GUIDE/navigation.md @@ -244,3 +244,14 @@ category, Esc closes. The category strip and footer have transparent backgrounds Settings → Agents and Settings → Harness render while their requests are pending. A pending Cursor model catalog shows `Loading models…` beside a disabled model picker; a failed catalog shows an inline error. The rest of settings stays usable. + +Claude Cloud connection setup is in Settings → Harness, above Cursor, with the +Anthropic logo. Settings → Agents selects an agent's harness but does not host +Claude's connection form. Claude's model picker uses provider-reported IDs, +names, descriptions, and order. Settings discovers from recent account sessions; +session catalogs update through replay, polling, and streaming. Before any catalog +is available, only subscription default is shown with an explanation. It saves the next-turn preference +without waking an idle worker; provider model rejections surface during the turn. +Claude sessions expose **Open in Claude** in the header +toolbar (or its overflow menu). With a live runtime, messages sent in Claude are +polled into Macro about every two seconds; disconnected runtimes must resume first. diff --git a/docs/CLAUDE_CLOUD_DEMO.md b/docs/CLAUDE_CLOUD_DEMO.md new file mode 100644 index 00000000000..e470fb992e9 --- /dev/null +++ b/docs/CLAUDE_CLOUD_DEMO.md @@ -0,0 +1,155 @@ +# Claude Cloud harness — internal demo + +`claude-cloud` runs Claude Code on Anthropic's cloud using **the session owner's subscription OAuth grant**. It does not use macrod, a local Claude process, or Macro's Anthropic API key. This uses an unofficial first-party protocol verified experimentally on September 15, 2026, not a supported third-party OAuth registration. + +## Connect in the local UI (recommended) + +Rebuild the local backend from this workspace (`r` in the running `just run_local` +terminal) and refresh the browser. In **Settings → Harness**, the +**Claude Cloud (demo)** row has the Anthropic logo and appears above Cursor, +even before an account is connected. Connection setup is not in Settings → Agents: + +1. Click **Connect Claude**, then **Open Claude sign-in**. +2. Sign in and approve on Claude's own page. Macro never asks for your password. +3. Copy the entire one-time `code#state` displayed by Claude, paste it into Macro, + and click **Finish connecting**. +4. Create/edit an agent and select **Claude Cloud (demo)** as its harness. + +This is Claude Code's manual authorization-code + PKCE flow, not an RFC 8628 +device-code grant. The registered callback stays on Claude's site, so this works +across Docker/host boundaries without exposing a localhost callback server. +The Macro user comes from authenticated request identity, never an email field. +Attempts expire after ten minutes, are one-use, and are canceled on disconnect. +Starting again invalidates the prior attempt. A failed exchange requires starting again. + +**No credential file, container mount, or environment variable is needed locally.** +With `ENVIRONMENT=local`, grants are stored in MacroDB's `claude_oauth_grants` +table. The serialized grant is KMS-encrypted, bound to its exact Macro owner and +the `claude-cloud-oauth` purpose. The local stack's existing Cursor KMS key is +reused with this separate encryption context; neither Cursor's configuration rows +nor macrod token hashes are reused. Grant updates include refresh-token rotation. +Reconnect once after upgrading from the old in-memory backend; subsequent +restarts/rebuilds retain the connection. Run one replica: refresh/connect/disconnect +are serialized in the domain service, not protected by a distributed lock. +Access/refresh tokens never reach frontend responses, query caches, +local storage, or logs. The one-time code is briefly in the masked input and POST body, +and is cleared on submission/cancel/expiry. Consent URLs are no-store responses. +Local Macro login may be a different email from the Claude account you authorize. + +**Disconnect Claude** forgets Macro's grant and pending attempt. It does not revoke +consent at Anthropic or stop a cloud turn already running; stop the turn first if +needed. Interactive onboarding is disabled outside `ENVIRONMENT=local`. + +## Legacy command-line provisioning (optional) + +From this workspace, with Node 22+: + +```sh +node tooling/claude-cloud/connect.mjs --owner you@macro.com +``` + +Complete Claude's own browser consent page. This helper uses authorization-code OAuth with PKCE and a loopback callback, not a device-code grant. It requests `user:profile user:inference user:sessions:claude_code`, resolves the selected Claude organization, and selects a single active cloud environment. If there are several, pass `--environment env_...` explicitly. No session is created by connecting. + +To reuse a grant obtained by the earlier isolated experiment, supply `--oauth-file /absolute/path/to/private/oauth.json`. Do not paste tokens into commands, chat, frontend storage, or tracked files. + +The output defaults to `.claude-cloud/credentials.json` (gitignored). The parent must be `0700` and the file `0600`. The helper refuses to overwrite a connection. The file is **plaintext private demo storage, not encrypted/KMS storage**, and the service must run as its owner. It maps exact `macro|email` identities to grants; there is no shared default credential. + +## Enable the service + +Local service startup always uses encrypted DB storage, ignoring the legacy file +path. Apply the MacroDB migration before rebuilding the service. Outside local, +`CLAUDE_CLOUD_CREDENTIALS_PATH` still enables the older private-file experiment; +empty leaves Claude disabled. Only that legacy mode needs a private directory +mount. Refresh atomically replaces the file, so mount the directory read/write, +not just the file. Run **one service replica**. + +Production startup refuses a nonempty path. Before a hosted dev deployment, register the optional config key in the service's Doppler configuration and provision a private persistent volume; this change does not deploy or modify shared credentials. Local overrides use the normal MacroConfig environment loading. + +The standalone frontend against the existing dev backend cannot exercise this new provider. Use a backend built from this workspace; see [running locally](RUNNING_LOCALLY.md). + +## Use in Macro + +1. Connect through the Claude row in Settings → Harness, then create or edit a private agent in Settings → Agents. +2. Select **Claude Cloud (demo)**. It is offered only when model discovery confirms that your Macro identity has a configured connection. +3. Choose from Claude's reported model catalog. Settings reads up to five recent sessions through your connected account and uses the first available initialization catalog; an existing session uses its own latest catalog. IDs, names, descriptions, and ordering come from Claude, not a fixed list. With no catalog yet, only **Claude · subscription default** is offered, with an explanatory description. The first prompt requests initialization alongside the model and user message, and the picker updates when Claude reports its catalog. Subsequent catalogs replace old choices during polling and streaming without resetting your saved preference. This is last-reported availability, not a fresh entitlement guarantee. Selection saves the next-turn preference after event submission; it does not wait for an idle worker. Each prompt repeats that preference immediately before the user message in one ordered batch, including after a Macro restart. A worker rejection surfaces an error and requests interruption; this cannot guarantee zero inference before the rejection arrives. +4. Start a session with that agent or mention it in a channel. Prompts, follow-ups, text streaming, tool cards, cancellation, and transcript load use Macro's existing session interface. +5. In the session header, use **Open in Claude** (the external-link icon; in the toolbar overflow on narrow screens). Existing demo sessions get the link too. +6. Send a message on that Claude page. While the Macro runtime connection is live, + durable user messages, assistant text, tool results, and turn completion are + mirrored back into Macro on a two-second polling cadence. Polls skip active + Macro turns, share the SSE replay cursor, and back off for 30 seconds on errors. + Polling uses the bounded history endpoint, so long transcripts may take longer + than two seconds; cloud-side text appears as durable messages, not token deltas. + +Only the session owner may send/control prompts or edit/remove queued input for this provider. Sharing visibility does not grant permission to spend the owner's subscription. Existing Macro access checks still apply. + +## Demo boundary + +- Text prompts only; no attachments, repository selection, or Macro/Pipedream MCP connectors are passed to Claude yet. Built-in cloud tools may run according to the provider's normal policy. Additional permission requests are denied rather than silently approved. +- Model discovery is read-only and bounded to five recent account sessions. No catalog is shared across credential owners. New/removed model IDs require no code change; choices absent from the current catalog are rejected. Provider errors stay visible rather than silently substituting a fixed list. No local filesystem. Agent instructions are forwarded as `append_system_prompt`. +- Access tokens refresh shortly before expiry; rotated refresh tokens are encrypted and saved before provider use. If saving fails, the process retains the rotated grant and retries persistence rather than reusing the old refresh token. A revoked grant produces a reconnect error. No API-key fallback or quota bypass. +- Prompt sends and session creation are not blindly retried. A durable `claude-cloud-create-pending` mapping prevents duplicate creation after an uncertain request or crash. An operator must inspect the Claude account before clearing a stuck intent. +- SSE reconnect is bounded and resumes durable sequence numbers. Durable assistant text reconciles with already-delivered ephemeral text; an inconsistent/truncated replay fails visibly. Session/load recovers the durable transcript. A result ends a turn, not the conversation. +- Closing the Macro transport closes its adapter tasks, not the provider's ongoing work. Use Stop for interruption. Deleting a Macro session requests reversible archive of the cloud session, not deletion of the user's Claude transcript. +- A process restart during a running cloud turn does not automatically reattach live output. Wait or interrupt, then reload the transcript; the harness refuses to send a new prompt while cloud history has an unfinished user turn. +- Provider token usage is reported into Macro's existing session telemetry. This is subscription usage, not a Macro-paid model invocation. +- Fresh-login/device-attestation requirements may differ across accounts. Only the standard cloud-session path was verified; errors are surfaced rather than bypassed. + +Before production: supported auth onboarding, a dedicated hosted KMS key and IAM +policy, distributed refresh locking, stronger lifecycle/recovery tests, remote MCP +validation, repo authorization, permission UI, and explicit shared-session billing policy. + +## Verification + +Discovery was verified against the connected account and the real session picker: +both returned Claude's four reported choices with provider names/descriptions, +without the old hard-coded standalone Opus entry. No inference was sent, and the +saved selection was restored to default. Regression tests cover new arbitrary +model IDs, catalog replacement/removal, malformed data, account separation, +bounded discovery, and polled ACP config updates preserving the saved model. + +Model switching was checked in the existing local Macro session: Sonnet was saved, and the picker restored subscription +default (confirmed in MacroDB). No inference was sent in that UI check. The +ordered model-plus-prompt batch and model-rejection interruption are regression +tested; control-only requests were observed to remain pending when the cloud +worker was disconnected, which is why selection does not await worker execution. + +The Rust ACP adapter was exercised against the existing experiment's live cloud session: initialize, full transcript load, one harmless prompt, two streaming text chunks, and a successful result. No tools were used. The earlier harness changes passed 200 agent-harness tests and 49 focused frontend tests. The interactive connection change passed 19 Claude adapter tests, 26 harness-service tests, 45 focused frontend tests, TypeScript checking, and a Linux service build. OAuth domain tests cover wrong-user/state rejection, expiry, replay, and disconnect racing an exchange. + +The polish pass verified the Anthropic-logo row above Cursor in Harness settings, +its absence from Agents settings, and start/cancel against the rebuilt local +backend. The masked code input, fixed PKCE consent URL, wrong-state rejection, +caller-supplied owner rejection, and canceled-attempt rejection were exercised. +The real session toolbar opened the expected Claude URL in a new tab using a +controlled read-only session fixture. Tests used `claude-ui-demo@example.com`; +no Claude grant or subscription turn was submitted for that identity. + +The updated Claude crate has 26 passing tests, including actual PostgreSQL +persistence with a fake KMS boundary, cross-owner ciphertext rejection, refresh +rotation recovery, replay deduplication, and idle polling through the real ACP +channel. The local KMS key is enabled. TypeScript, 45 frontend tests, and the +Linux service build passed. All 214 agent-session tests passed serially (an +existing telemetry test was flaky in parallel), and 26 harness-service tests passed. +Workspace SQLx preparation passed against the named +local database, including test queries; only the six relevant generated cache +files were retained. **A consenting user's reconnect and cloud-page follow-up +are still needed to verify the complete live flow with database-backed grants.** + +Repeat the non-provider browser smoke against your local frontend: + +```sh +node tooling/claude-cloud/browser-smoke.mjs --origin http://localhost:25510 +``` + +It signs into that disposable local identity and tests start/cancel; it does not authorize Claude or consume a subscription turn. `just check` cannot discover the jj-only workspace's diff because it uses Git; scoped checks were run directly instead. + +An explicit live smoke runner is available (one short subscription turn; transcript retained): + +```sh +cargo run -p claude_cloud_agents --example smoke -- \ + --credentials .claude-cloud/credentials.json \ + --owner 'macro|you@macro.com' \ + --session cse_existing_session +``` + +Omitting `--session` creates a new cloud conversation. Do not automatically rerun a failed create; inspect the account first. The runner prints only the session identity and outcome, not tokens or private transcript contents. diff --git a/packages/sdk/specs/agent-harness.json b/packages/sdk/specs/agent-harness.json index 0bb631f4158..b7314f3abae 100644 --- a/packages/sdk/specs/agent-harness.json +++ b/packages/sdk/specs/agent-harness.json @@ -10,6 +10,145 @@ "version": "0.1.0" }, "paths": { + "/claude-auth": { + "get": { + "tags": [ + "claude-auth" + ], + "summary": "Read connection status for the authenticated user only.", + "operationId": "status", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + }, + "401": { + "description": "Unauthenticated" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "claude-auth" + ], + "summary": "Forget only the authenticated user's grant and cancel pending consent.", + "operationId": "disconnect", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Disconnected" + }, + "403": { + "description": "Disabled" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/complete": { + "post": { + "tags": [ + "claude-auth" + ], + "summary": "Exchange one code; never return access or refresh tokens.", + "operationId": "complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Connected" + }, + "400": { + "description": "Invalid code" + }, + "409": { + "description": "Expired or replayed" + }, + "502": { + "description": "Provider failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/start": { + "post": { + "tags": [ + "claude-auth" + ], + "summary": "Create an expiring PKCE challenge for the authenticated user.", + "operationId": "start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartResponse" + } + } + } + }, + "403": { + "description": "Disabled" + }, + "429": { + "description": "Too many attempts" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/agent-models/load": { "post": { "tags": ["agent-models"], @@ -920,6 +1059,78 @@ }, "components": { "schemas": { + "StatusResponse": { + "type": "object", + "description": "Safe connection metadata.", + "required": [ + "enabled", + "connected", + "ephemeral" + ], + "properties": { + "connected": { + "type": "boolean", + "description": "Whether the authenticated Macro user has connected." + }, + "enabled": { + "type": "boolean", + "description": "Whether this deployment supports browser connection." + }, + "ephemeral": { + "type": "boolean", + "description": "Whether reconnecting after service restart is required." + } + } + }, + "StartResponse": { + "type": "object", + "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", + "required": [ + "attemptId", + "authorizationUrl", + "expiresIn" + ], + "properties": { + "attemptId": { + "type": "string", + "description": "Opaque owner-bound attempt handle." + }, + "authorizationUrl": { + "type": "string", + "description": "Claude-hosted consent page." + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Attempt lifetime in seconds.", + "minimum": 0 + } + } + }, + "CompleteRequest": { + "type": "object", + "description": "One-time manual code. Deliberately does not implement Debug.", + "required": [ + "attemptId", + "code" + ], + "properties": { + "attemptId": { + "type": "string", + "description": "Server-issued handle; never an owner selected by the caller." + }, + "code": { + "type": "string", + "description": "Claude's complete code#state string, not an access token." + } + }, + "additionalProperties": false + }, + "EmptyRequest": { + "type": "object", + "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", + "additionalProperties": false + }, "AgentAction": { "oneOf": [ { @@ -1668,7 +1879,7 @@ "ModelHarnessDto": { "type": "string", "description": "Harness names accepted by the model discovery endpoint.", - "enum": ["in-memory", "cursor", "macrod"] + "enum": ["in-memory", "cursor", "claude-cloud", "macrod"] }, "PreviewAgentSessionsRequest": { "type": "object", diff --git a/rules/ast-grep/ts-feature-components-presentational.yml b/rules/ast-grep/ts-feature-components-presentational.yml index 4fbc85c47cb..308dfdd358b 100644 --- a/rules/ast-grep/ts-feature-components-presentational.yml +++ b/rules/ast-grep/ts-feature-components-presentational.yml @@ -13,6 +13,7 @@ note: |- untyped wire JSON for property-changed rows. files: - apps/web/src/features/property/tags/components/** + - apps/web/src/features/claude-connection/components/** - apps/web/src/features/activity/components/** - apps/web/src/features/drive-view/components/** - apps/web/src/features/email-message/components/** diff --git a/rules/ast-grep/ts-feature-core-pure.yml b/rules/ast-grep/ts-feature-core-pure.yml index 10ae5e65c0b..6b5cae1cb84 100644 --- a/rules/ast-grep/ts-feature-core-pure.yml +++ b/rules/ast-grep/ts-feature-core-pure.yml @@ -15,6 +15,7 @@ note: |- The shared @core/util/base64 codec is also pure and may be reused by email. files: - apps/web/src/features/property/tags/core/** + - apps/web/src/features/claude-connection/core/** - apps/web/src/features/activity/core/** - apps/web/src/features/drive-view/core/** - apps/web/src/features/email-message/core/** diff --git a/rules/ast-grep/ts-feature-data-no-ui.yml b/rules/ast-grep/ts-feature-data-no-ui.yml index 62b7c70fca1..4d7ad4f17ad 100644 --- a/rules/ast-grep/ts-feature-data-no-ui.yml +++ b/rules/ast-grep/ts-feature-data-no-ui.yml @@ -13,6 +13,7 @@ note: |- files: - apps/web/src/features/property/tags/queries/** - apps/web/src/features/property/tags/primitives/** + - apps/web/src/features/claude-connection/primitives/** - apps/web/src/features/activity/queries/** - apps/web/src/features/drive-view/queries/** - apps/web/src/features/activity/primitives/** diff --git a/rules/ast-grep/ts-feature-layers-use-context.yml b/rules/ast-grep/ts-feature-layers-use-context.yml index f46a247db68..e076450f0e1 100644 --- a/rules/ast-grep/ts-feature-layers-use-context.yml +++ b/rules/ast-grep/ts-feature-layers-use-context.yml @@ -18,6 +18,8 @@ files: - apps/web/src/features/property/tags/queries/** - apps/web/src/features/property/tags/primitives/** - apps/web/src/features/property/tags/views/** + - apps/web/src/features/claude-connection/components/** + - apps/web/src/features/claude-connection/primitives/** - apps/web/src/features/activity/components/** - apps/web/src/features/drive-view/components/** - apps/web/src/features/activity/queries/** diff --git a/rules/ast-grep/tsx-feature-components-presentational.yml b/rules/ast-grep/tsx-feature-components-presentational.yml index 178026c50db..c35b58dad3a 100644 --- a/rules/ast-grep/tsx-feature-components-presentational.yml +++ b/rules/ast-grep/tsx-feature-components-presentational.yml @@ -13,6 +13,7 @@ note: |- untyped wire JSON for property-changed rows. files: - apps/web/src/features/property/tags/components/** + - apps/web/src/features/claude-connection/components/** - apps/web/src/features/activity/components/** - apps/web/src/features/drive-view/components/** - apps/web/src/features/email-message/components/** diff --git a/rules/ast-grep/tsx-feature-core-pure.yml b/rules/ast-grep/tsx-feature-core-pure.yml index d32870dfce8..04b94efd8d2 100644 --- a/rules/ast-grep/tsx-feature-core-pure.yml +++ b/rules/ast-grep/tsx-feature-core-pure.yml @@ -15,6 +15,7 @@ note: |- The shared @core/util/base64 codec is also pure and may be reused by email. files: - apps/web/src/features/property/tags/core/** + - apps/web/src/features/claude-connection/core/** - apps/web/src/features/activity/core/** - apps/web/src/features/drive-view/core/** - apps/web/src/features/email-message/core/** diff --git a/rules/ast-grep/tsx-feature-data-no-ui.yml b/rules/ast-grep/tsx-feature-data-no-ui.yml index a3c35c03f69..89b2e377aa7 100644 --- a/rules/ast-grep/tsx-feature-data-no-ui.yml +++ b/rules/ast-grep/tsx-feature-data-no-ui.yml @@ -13,6 +13,7 @@ note: |- files: - apps/web/src/features/property/tags/queries/** - apps/web/src/features/property/tags/primitives/** + - apps/web/src/features/claude-connection/primitives/** - apps/web/src/features/activity/queries/** - apps/web/src/features/drive-view/queries/** - apps/web/src/features/activity/primitives/** diff --git a/rules/ast-grep/tsx-feature-layers-use-context.yml b/rules/ast-grep/tsx-feature-layers-use-context.yml index 8bcf2253ee9..e104d56853c 100644 --- a/rules/ast-grep/tsx-feature-layers-use-context.yml +++ b/rules/ast-grep/tsx-feature-layers-use-context.yml @@ -18,6 +18,8 @@ files: - apps/web/src/features/property/tags/queries/** - apps/web/src/features/property/tags/primitives/** - apps/web/src/features/property/tags/views/** + - apps/web/src/features/claude-connection/components/** + - apps/web/src/features/claude-connection/primitives/** - apps/web/src/features/activity/components/** - apps/web/src/features/drive-view/components/** - apps/web/src/features/activity/queries/** diff --git a/services/agent_harness_service/Cargo.toml b/services/agent_harness_service/Cargo.toml index 812cfd1a6f9..3ad395d2e8a 100644 --- a/services/agent_harness_service/Cargo.toml +++ b/services/agent_harness_service/Cargo.toml @@ -39,6 +39,7 @@ connection_gateway_client = { path = "../../crates/connection_gateway_client" } aws-sdk-kms = { workspace = true } cursor_api_key = { path = "../../crates/cursor_api_key" } cursor_cloud_agents = { path = "../../crates/cursor_cloud_agents" } +claude_cloud_agents = { path = "../../crates/claude_cloud_agents" } database_env_vars = { path = "../../crates/database_env_vars" } entity_access = { path = "../../crates/entity_access" } futures = { workspace = true } diff --git a/services/agent_harness_service/src/api.rs b/services/agent_harness_service/src/api.rs index 6e1826fbec1..b0ab4790cb6 100644 --- a/services/agent_harness_service/src/api.rs +++ b/services/agent_harness_service/src/api.rs @@ -62,6 +62,7 @@ pub struct ApiStates { create: CreateSessionState, gateway: RuntimeGatewayState, models: AgentModelsRouterState, + claude_auth: Router, } impl ApiStates { @@ -79,8 +80,15 @@ impl ApiStates Self { + self.claude_auth = router; + self + } } /// Serve session egress and Macro Internal MCP on the existing egress listener. @@ -171,6 +179,7 @@ where .nest("/agent-sessions", agent_sessions) .merge(agent_sandbox_size_router(states.read)) .merge(agent_models_router(states.models)) + .merge(states.claude_auth) .nest("/runtime", runtime_gateway_router(states.gateway)) } diff --git a/services/agent_harness_service/src/api/swagger.rs b/services/agent_harness_service/src/api/swagger.rs index cbf4d603157..26d603168b1 100644 --- a/services/agent_harness_service/src/api/swagger.rs +++ b/services/agent_harness_service/src/api/swagger.rs @@ -14,6 +14,7 @@ use agent_session::inbound::axum_router::{ PreviewAgentSessionsRequest, PreviewAgentSessionsResponse, QueuedActionDto, RenameAgentSessionRequest, SandboxSizeBody, SessionStatusDto, WithAgentSessionId, }; +use claude_cloud_agents::inbound::auth as claude_auth; use utoipa::{ Modify, OpenApi, openapi::security::{Http, HttpAuthScheme, SecurityScheme}, @@ -37,6 +38,10 @@ impl Modify for SecurityAddon { modifiers(&SecurityAddon), info(terms_of_service = "https://macro.com/terms"), paths( + claude_auth::status, + claude_auth::start, + claude_auth::complete, + claude_auth::disconnect, axum_router::create_agent_session_handler, axum_router::get_agent_session_handler, axum_router::preview_agent_sessions_handler, @@ -53,6 +58,10 @@ impl Modify for SecurityAddon { model_load::load_agent_models_handler, ), components(schemas( + claude_auth::StatusResponse, + claude_auth::StartResponse, + claude_auth::CompleteRequest, + claude_auth::EmptyRequest, CreateAgentSessionRequest, CreateAgentSessionResponse, CreateSessionThread, diff --git a/services/agent_harness_service/src/claude_cloud.rs b/services/agent_harness_service/src/claude_cloud.rs new file mode 100644 index 00000000000..257bbdd83a4 --- /dev/null +++ b/services/agent_harness_service/src/claude_cloud.rs @@ -0,0 +1,299 @@ +//! Composition-root wiring for the demo Claude cloud provider. +use agent_harness::domain::{ + error::{HarnessError, Result}, + model::{AgentKind, SpawnContainer}, + ports::ContainerManager, + sandbox::SandboxResizeEffect, +}; +use agent_runtime_protocol::domain::{ + connection::ServerChannel, + ports::{Transport, TransportError, TransportReceiver, TransportSender}, + schema::v0::{ToRuntimeMessage, ToServerMessage}, +}; +use agent_session::domain::{ + connection::RuntimeAttachment, + model::{AgentSessionId, ExternalSession, SandboxSize}, + ports::{AgentSessionRepo, ExternalSessionRepo}, +}; +use claude_cloud_agents::{ + domain::{ + model::{Error, SessionId}, + service::Session, + }, + inbound::acp, + outbound::{credentials::FileCredentials, http::Client}, +}; +use std::sync::Arc; + +const PROVIDER: &str = "claude-cloud"; +const PENDING: &str = "claude-cloud-create-pending"; + +pub struct ClaudeModels(pub Option); +impl agent_harness::domain::model_load::ClaudeModelProbe for ClaudeModels { + fn probe<'a>( + &'a self, + caller: &'a macro_user_id::user_id::MacroUserIdStr<'static>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = std::result::Result< + agent_harness::domain::model_load::RawModelProbe, + agent_harness::domain::model_load::ModelProbeError, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + use agent_harness::domain::model_load::{ModelProbeError, RawModelProbe}; + let Some(credentials) = &self.0 else { + return Ok(RawModelProbe::Unsupported); + }; + if !credentials.contains(caller.as_ref()).await { + return Ok(RawModelProbe::Unsupported); + } + let client = Client::new(credentials.clone(), caller.as_ref().to_owned()) + .await + .map_err(|error| ModelProbeError::Failed(error.to_string()))?; + let catalog = claude_cloud_agents::domain::models::discover(&client) + .await + .map_err(|error| ModelProbeError::Failed(error.to_string()))?; + let options = serde_json::from_value(acp::model_options(&catalog, &Default::default())) + .map_err(|_| { + ModelProbeError::Failed("Invalid Claude model configuration".into()) + })?; + Ok(RawModelProbe::Options(options)) + }) + } +} + +/// Routes Claude sessions to Anthropic and all other sessions to the existing providers. +pub struct WithClaude { + base: Base, + repo: Repo, + credentials: Option, + creation: Arc>, +} +impl WithClaude { + pub fn new(base: Base, repo: Repo, credentials: Option) -> Self { + Self { + base, + repo, + credentials, + creation: Arc::new(tokio::sync::Mutex::new(())), + } + } +} +fn cloud_error(error: Error) -> HarnessError { + HarnessError::Container(error.to_string()) +} + +impl WithClaude +where + Repo: AgentSessionRepo + ExternalSessionRepo + Clone, + Base: ContainerManager, +{ + async fn is_claude(&self, id: AgentSessionId) -> Result { + let row = AgentSessionRepo::get(&self.repo, id).await?; + Ok(AgentKind::for_session(row.bot_id, &row.harness) == AgentKind::ClaudeCloud) + } + + async fn attach(&self, id: AgentSessionId) -> Result> { + let _creation = self.creation.lock().await; + let row = AgentSessionRepo::get(&self.repo, id).await?; + let credentials = self + .credentials + .clone() + .ok_or_else(|| cloud_error(Error::NotConnected))?; + let client = Client::new(credentials, row.owner_id.as_ref().to_owned()) + .await + .map_err(cloud_error)?; + let external = ExternalSessionRepo::get(&self.repo, id).await?; + let cloud_id = match external { + Some(mut external) if external.provider == PROVIDER => { + let cloud_id = SessionId::parse(&external.external_id).map_err(cloud_error)?; + if external.external_url.is_none() { + external.external_url = Some(cloud_id.web_url()); + self.repo.upsert(id, external).await?; + } + cloud_id + } + Some(_) => return Err(cloud_error(Error::UncertainCreate)), + None => { + // Write a durable intent before the non-idempotent POST. A crash or + // timeout stays visibly pending rather than minting a duplicate VM. + self.repo + .upsert( + id, + ExternalSession { + provider: PENDING.into(), + external_id: id.to_string(), + external_name: None, + external_url: None, + last_run_id: None, + }, + ) + .await?; + let cloud_id = match client + .create(row.instructions.as_deref().unwrap_or_default()) + .await + { + Ok(id) => id, + Err(error) => { + // Only a definite client rejection is safe to retry as a new create. + if matches!( + error, + Error::Authorization | Error::NotConnected | Error::Http(400..=499) + ) { + ExternalSessionRepo::delete(&self.repo, id).await?; + } + return Err(cloud_error(error)); + } + }; + self.repo + .upsert( + id, + ExternalSession { + provider: PROVIDER.into(), + external_id: cloud_id.as_str().to_owned(), + external_name: Some("Claude Cloud demo".into()), + external_url: Some(cloud_id.web_url()), + last_run_id: None, + }, + ) + .await?; + cloud_id + } + }; + Ok(RuntimeAttachment::solo(acp::attach(Session::with_model( + client, + cloud_id, + claude_cloud_agents::domain::models::Model::parse(&row.model).map_err(cloud_error)?, + )))) + } +} + +impl ContainerManager for WithClaude +where + Base: ContainerManager, + Repo: AgentSessionRepo + ExternalSessionRepo + Clone, +{ + type Transport = CloudTransport; + async fn spawn(&self, command: SpawnContainer) -> Result> { + if command.kind == AgentKind::ClaudeCloud { + self.attach(command.session_id) + .await + .map(|a| a.map_transport(CloudTransport::Claude)) + } else { + self.base + .spawn(command) + .await + .map(|a| a.map_transport(CloudTransport::Other)) + } + } + async fn resume(&self, id: AgentSessionId) -> Result> { + if self.is_claude(id).await? { + self.attach(id) + .await + .map(|a| a.map_transport(CloudTransport::Claude)) + } else { + self.base + .resume(id) + .await + .map(|a| a.map_transport(CloudTransport::Other)) + } + } + async fn session_token(&self, id: AgentSessionId) -> Result> { + if self.is_claude(id).await? { + Ok(None) + } else { + self.base.session_token(id).await + } + } + async fn teardown(&self, id: AgentSessionId) -> Result<()> { + if !self.is_claude(id).await? { + return self.base.teardown(id).await; + } + let Some(external) = ExternalSessionRepo::get(&self.repo, id).await? else { + return Ok(()); + }; + if external.provider != PROVIDER { + return Err(cloud_error(Error::UncertainCreate)); + } + let row = AgentSessionRepo::get(&self.repo, id).await?; + let credentials = self + .credentials + .clone() + .ok_or_else(|| cloud_error(Error::NotConnected))?; + Client::new(credentials, row.owner_id.as_ref().to_owned()) + .await + .map_err(cloud_error)? + .archive(&SessionId::parse(&external.external_id).map_err(cloud_error)?) + .await + .map_err(cloud_error)?; + ExternalSessionRepo::delete(&self.repo, id).await?; + Ok(()) + } + fn resize_effect(&self, from: SandboxSize, to: SandboxSize) -> SandboxResizeEffect { + self.base.resize_effect(from, to) + } + async fn resize(&self, id: AgentSessionId, size: SandboxSize) -> Result<()> { + if self.is_claude(id).await? { + return Err(HarnessError::Container( + "Claude manages its own cloud compute".into(), + )); + } + self.base.resize(id, size).await + } +} + +pub enum CloudTransport { + Other(Base), + Claude(ServerChannel), +} +pub enum CloudSender { + Other(Base), + Claude(tokio::sync::mpsc::UnboundedSender), +} +pub enum CloudReceiver { + Other(Base), + Claude(tokio::sync::mpsc::UnboundedReceiver), +} +impl> + Transport for CloudTransport +{ + type Sender = CloudSender; + type Receiver = CloudReceiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + match self { + Self::Other(base) => { + let (tx, rx) = base.split(); + (CloudSender::Other(tx), CloudReceiver::Other(rx)) + } + Self::Claude(channel) => ( + CloudSender::Claude(channel.tx), + CloudReceiver::Claude(channel.rx), + ), + } + } +} +impl> TransportSender + for CloudSender +{ + async fn send(&self, message: ToRuntimeMessage) -> std::result::Result<(), TransportError> { + match self { + Self::Other(base) => base.send(message).await, + Self::Claude(tx) => TransportSender::send(tx, message).await, + } + } +} +impl> TransportReceiver + for CloudReceiver +{ + async fn recv(&mut self) -> std::result::Result, TransportError> { + match self { + Self::Other(base) => base.recv().await, + Self::Claude(rx) => Ok(rx.recv().await), + } + } +} diff --git a/services/agent_harness_service/src/config.rs b/services/agent_harness_service/src/config.rs index 0120d04e1ba..4603ed5307c 100644 --- a/services/agent_harness_service/src/config.rs +++ b/services/agent_harness_service/src/config.rs @@ -53,6 +53,12 @@ fn default_pipedream_environment() -> String { #[derive(macro_config::MacroConfig)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct Config { + /// Private owner-keyed OAuth file for the single-replica Claude demo. + /// Local browser connections always use encrypted MacroDB storage. + /// Empty disables Claude outside the local environment. + /// Nonempty is refused in production. + #[macro_config_default(String::new())] + pub claude_cloud_credentials_path: String, /// The environment we are in. #[macro_config_default(Environment::new_or_prod())] pub environment: Environment, diff --git a/services/agent_harness_service/src/main.rs b/services/agent_harness_service/src/main.rs index bbcda93a122..4d9f0d58d07 100644 --- a/services/agent_harness_service/src/main.rs +++ b/services/agent_harness_service/src/main.rs @@ -10,6 +10,7 @@ mod agent_runtime_directory; mod api; mod bots_directory; +mod claude_cloud; mod config; mod containers; mod harness_bindings; @@ -204,6 +205,38 @@ async fn run() -> anyhow::Result<()> { .await .context("failed to connect to macrodb")?; + // Local-only demo: reuse the local Cursor KMS key, but cryptographically + // separate Claude grants by purpose and owner. Never enables hosted writes. + let claude_credentials = if matches!(config.environment, Environment::Local) { + Some( + claude_cloud_agents::domain::credentials::AccountCredentials::new( + Arc::new( + claude_cloud_agents::outbound::postgres::PgClaudeGrants::new( + pool.clone(), + AwsKmsCiphertexts::new( + aws_sdk_kms::Client::new(&aws_config), + "alias/macro-local-cursor-api-key".into(), + ), + ), + ), + Arc::new(claude_cloud_agents::outbound::credentials::ClaudeRefresh::new()?), + ), + ) + } else if !config.claude_cloud_credentials_path.is_empty() { + anyhow::ensure!( + !matches!(config.environment, Environment::Production), + "Claude Cloud demo credentials are forbidden in production" + ); + Some( + claude_cloud_agents::outbound::credentials::FileCredentials::open( + std::path::PathBuf::from(&config.claude_cloud_credentials_path), + ) + .await?, + ) + } else { + None + }; + // Built before the sessions rather than beside the other channel plumbing // below: this service owns the live actors, so it is where a session's // frames are streamed from. @@ -598,6 +631,8 @@ async fn run() -> anyhow::Result<()> { codex_manager, session_repo.clone(), ); + let containers = + claude_cloud::WithClaude::new(containers, session_repo.clone(), claude_credentials.clone()); let contacts_ingress = Arc::new(contacts::domain::service::SqsContactsIngress { queue: contacts::outbound::ingress::SqsContactsQueue::new( @@ -765,13 +800,18 @@ async fn run() -> anyhow::Result<()> { .with_harness_authorizer(PgHarnessAuthorizer::new(PgHarnessAuthorizationRepo::new( pool.clone(), ))); - let model_service = Arc::new(AgentModelsServiceImpl::new( - VisibleHarnessAccess::new(PgHarnessRepo::new(pool.clone())), - InMemoryModels::new(Some(inmem_model_engine), config.inmem_model.clone()), - CursorModels::new(cursor_keys, cursor_api_base_url()), - macrod_models, - model_probe_timeout, - )); + let model_service = Arc::new( + AgentModelsServiceImpl::new( + VisibleHarnessAccess::new(PgHarnessRepo::new(pool.clone())), + InMemoryModels::new(Some(inmem_model_engine), config.inmem_model.clone()), + CursorModels::new(cursor_keys, cursor_api_base_url()), + macrod_models, + model_probe_timeout, + ) + .with_claude(Arc::new(claude_cloud::ClaudeModels( + claude_credentials.clone(), + ))), + ); let model_state = AgentModelsRouterState::new( model_service, MacroAuthorizationState::new(Arc::new(authorization_service.clone())), @@ -805,6 +845,20 @@ async fn run() -> anyhow::Result<()> { MacroAuthorizationState::new(Arc::new(authorization_service.clone())), ); let http_runtime_commands_readiness = runtime_commands_readiness.clone(); + let claude_auth = claude_cloud_agents::inbound::auth::router( + claude_cloud_agents::inbound::auth::ClaudeAuthState::new( + Arc::new(claude_cloud_agents::domain::auth::AuthService::new( + claude_cloud_agents::outbound::oauth::ClaudeOAuth::new()?, + if matches!(config.environment, Environment::Local) { + claude_credentials + } else { + None + }, + false, + )), + MacroAuthorizationState::new(Arc::new(authorization_service.clone())), + ), + ); let http_port = config.port; let http = tokio::spawn(async move { if let Err(error) = api::setup_and_serve( @@ -814,7 +868,8 @@ async fn run() -> anyhow::Result<()> { create_state, gateway_state, model_state, - ), + ) + .with_claude_auth(claude_auth), http_runtime_commands_readiness, http_port, shutdown_signal(), diff --git a/tooling/claude-cloud/browser-smoke.mjs b/tooling/claude-cloud/browser-smoke.mjs new file mode 100644 index 00000000000..64b1dc70cf4 --- /dev/null +++ b/tooling/claude-cloud/browser-smoke.mjs @@ -0,0 +1,180 @@ +// Local UI smoke only: creates a disposable local Macro identity, never authorizes Claude. +import { createRequire } from 'node:module'; +import { parseArgs } from 'node:util'; +const require = createRequire( + new URL('../../apps/web/package.json', import.meta.url) +); +const { chromium } = require('@playwright/test'); +const { values } = parseArgs({ + options: { origin: { type: 'string', default: 'http://localhost:25510' } }, +}); +const origin = new URL(values.origin); +if (origin.hostname !== 'localhost' || origin.protocol !== 'http:') + throw new Error('Localhost only'); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1280, height: 960 }, + }); + await page.goto(`${origin}/app`); + await page + .getByRole('button', { name: 'Continue with email' }) + .click({ timeout: 60000 }); + await page + .getByPlaceholder('you@company.com') + .fill('claude-ui-demo@example.com'); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.waitForURL(/\/app\/(component|settings)/, { timeout: 60000 }); + await page.goto(`${origin}/app/settings/harness`); + const card = page.getByRole('region', { name: 'Claude Cloud connection' }); + await card.waitFor({ timeout: 60000 }); + console.log('PASS: Claude connection card is visible before authorization.'); + await card.getByLabel('Anthropic').waitFor(); + const order = await page.getByRole('heading', { level: 2 }).allTextContents(); + if (order.indexOf('Claude Cloud (demo)') >= order.indexOf('Cursor')) + throw new Error('Claude must appear above Cursor'); + // Reloading after the backend build is a manual step; this script fails visibly if stale. + const starting = page.waitForResponse( + (response) => + response.url().endsWith('/claude-auth/start') && + response.request().method() === 'POST' + ); + await card + .getByRole('button', { name: 'Connect Claude', exact: true }) + .click({ timeout: 30000 }); + const start = await starting; + if (start.status() !== 200 || start.headers()['cache-control'] !== 'no-store') + throw new Error('Start response must be successful and non-cacheable'); + const attempt = await start.json(); + const consent = card.getByRole('link', { name: /Open Claude sign-in/ }); + await consent.waitFor({ timeout: 15000 }); + const url = new URL(await consent.getAttribute('href')); + if ( + url.origin !== 'https://claude.com' || + url.pathname !== '/cai/oauth/authorize' || + url.searchParams.get('code_challenge_method') !== 'S256' || + url.searchParams.get('redirect_uri') !== + 'https://platform.claude.com/oauth/code/callback' + ) + throw new Error('Incorrect consent URL'); + if ( + (await card.getByLabel(/Paste the complete/).getAttribute('type')) !== + 'password' + ) + throw new Error('Code input is not masked'); + await page.screenshot({ + path: '/tmp/claude-auth-settings.png', + fullPage: true, + }); + console.log( + 'PASS: authenticated start returned the registered Claude PKCE consent URL and masked code entry.' + ); + const completeUrl = start.url().replace(/\/start$/, '/complete'); + const wrongState = await page.request.post(completeUrl, { + data: { attemptId: attempt.attemptId, code: 'dummy-code#wrong-state' }, + }); + if (wrongState.status() !== 400) + throw new Error('Mismatched OAuth state was not rejected'); + const ownerInjection = await page.request.post(completeUrl, { + data: { + attemptId: attempt.attemptId, + code: 'dummy-code#wrong-state', + owner: 'macro|someone-else@example.com', + }, + }); + if (ownerInjection.status() !== 422) + throw new Error('Caller-selected owner was not rejected'); + console.log( + 'PASS: wrong state and caller-supplied ownership are rejected before provider exchange.' + ); + await card.getByRole('button', { name: 'Cancel', exact: true }).click(); + await card + .getByRole('button', { name: 'Connect Claude', exact: true }) + .waitFor(); + console.log( + 'PASS: cancel forgets the pending connection. No Claude grant or subscription turn was used.' + ); + const replay = await page.request.post(completeUrl, { + data: { + attemptId: attempt.attemptId, + code: `dummy-code#${url.searchParams.get('state')}`, + }, + }); + if (replay.status() !== 409) + throw new Error('Canceled attempt was not rejected'); + console.log('PASS: a canceled attempt cannot be exchanged.'); + await page.goto(`${origin}/app/settings/agents`); + await page + .getByRole('heading', { name: 'Agents', exact: true }) + .waitFor({ timeout: 30000 }); + if ( + await page.getByRole('region', { name: 'Claude Cloud connection' }).count() + ) + throw new Error('Connection belongs in Harness, not Agents'); + console.log( + 'PASS: Claude connection appears only in Harness settings, above Cursor, with the Anthropic logo.' + ); + // Controlled read-only session fixture: exercise the real toolbar without + // creating a cloud session or touching another user's conversation. + const fixtureId = '00000000-0000-7000-8000-000000000123'; + const cloudUrl = 'https://claude.ai/code/cse_browser_fixture'; + await page.route(`**/agent-sessions/${fixtureId}**`, async (route) => { + const path = new URL(route.request().url()).pathname; + const bot = { + id: '00000000-0000-7000-8000-000000000124', + name: 'Claude demo', + handle: 'claude-demo', + }; + const body = path.endsWith('/log') + ? { bot, entries: [] } + : path.endsWith('/queue') + ? [] + : { + id: fixtureId, + botId: bot.id, + ownerId: 'macro|claude-ui-demo@example.com', + canEdit: false, + name: 'Claude cloud link check', + harness: 'claude-cloud', + model: 'claude-default', + workspace: '/', + sandboxSize: 'small', + status: { kind: 'disconnected' }, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + external: { provider: 'claude-cloud', url: cloudUrl }, + }; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + }); + await page + .context() + .route('https://claude.ai/**', (route) => + route.fulfill({ + body: 'Claude link target intercepted by local smoke test', + }) + ); + await page.goto(`${origin}/app/agent/${fixtureId}`); + const openClaude = page.getByRole('button', { + name: 'Open in Claude', + exact: true, + }); + await openClaude.waitFor({ timeout: 60000 }); + const opened = page.waitForEvent('popup'); + await openClaude.click(); + const popup = await opened; + await popup.waitForURL(cloudUrl); + await popup.close(); + await page.screenshot({ + path: '/tmp/claude-session-link.png', + fullPage: true, + }); + console.log( + 'PASS: the real session toolbar opens the Claude URL in a new tab (controlled session fixture).' + ); +} finally { + await browser.close(); +} diff --git a/tooling/claude-cloud/connect.mjs b/tooling/claude-cloud/connect.mjs new file mode 100644 index 00000000000..2cd085d0716 --- /dev/null +++ b/tooling/claude-cloud/connect.mjs @@ -0,0 +1,215 @@ +// Local demo provisioning only. The browser never receives an access/refresh token. +import http from 'node:http'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { mkdir, readFile, writeFile, lstat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { spawn } from 'node:child_process'; + +process.umask(0o077); +const { values } = parseArgs({ + options: { + owner: { type: 'string' }, + output: { type: 'string', default: '.claude-cloud/credentials.json' }, + 'oauth-file': { type: 'string' }, + environment: { type: 'string' }, + }, +}); +if (!values.owner || !/^[^\s@|]+@[^\s@|]+$/.test(values.owner)) { + throw new Error( + 'Use --owner [--output private/path.json] [--oauth-file existing-demo-grant.json]' + ); +} +const output = resolve(values.output); +await mkdir(dirname(output), { recursive: true, mode: 0o700 }); +const parent = await lstat(dirname(output)); +if (!parent.isDirectory() || parent.isSymbolicLink() || parent.mode & 0o077) + throw new Error('Output directory must be private (0700).'); +try { + await lstat(output); + throw new Error( + 'Output exists; refusing to replace credentials. Use a new output path.' + ); +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +const clientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const scope = 'user:profile user:inference user:sessions:claude_code'; +async function jsonRequest(url, options) { + const response = await fetch(url, { + ...options, + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) + throw new Error( + `Claude request failed (HTTP ${response.status}); response omitted.` + ); + return response.json(); +} + +async function provision(tokens) { + if ( + typeof tokens.access_token !== 'string' || + !tokens.scope?.split(' ').includes('user:sessions:claude_code') + ) + throw new Error('Grant lacks the Claude Code sessions scope.'); + const headers = { + Authorization: `Bearer ${tokens.access_token}`, + 'anthropic-version': '2023-06-01', + }; + const profile = await jsonRequest( + 'https://api.anthropic.com/api/oauth/profile', + { headers } + ); + const organization = profile.organization?.uuid; + if (typeof organization !== 'string') + throw new Error('No Claude organization returned.'); + const environments = await jsonRequest( + 'https://api.anthropic.com/v1/environments', + { + headers: { + ...headers, + 'x-organization-uuid': organization, + 'anthropic-beta': + 'environments-2026-03-01,environments-package-support-2025-12-09', + }, + } + ); + const candidates = environments.data.filter( + (e) => + e.config?.type === 'cloud' && + e.state === 'active' && + !e.archived_at && + (!values.environment || values.environment === e.id) + ); + if (candidates.length !== 1) + throw new Error( + 'Need exactly one active cloud environment; specify --environment if multiple exist.' + ); + const acquired = tokens.acquired_at ?? Date.now(); + const expiresAt = Math.floor(acquired / 1000) + tokens.expires_in; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now() / 1000) + throw new Error('Expired grant; sign in again.'); + const credentials = { + users: { + [`macro|${values.owner}`]: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + expires_at: expiresAt, + organization_id: organization, + environment_id: candidates[0].id, + }, + }, + }; + await writeFile(output, JSON.stringify(credentials, null, 2), { + mode: 0o600, + flag: 'wx', + }); + console.log( + 'Claude connection saved privately. No cloud session has been created.' + ); +} + +if (values['oauth-file']) { + const source = await lstat(values['oauth-file']); + if (!source.isFile() || source.isSymbolicLink() || source.mode & 0o077) + throw new Error('OAuth source must be a private regular file (0600).'); + await provision(JSON.parse(await readFile(values['oauth-file'], 'utf8'))); +} else { + const state = randomBytes(32).toString('base64url'); + const verifier = randomBytes(32).toString('base64url'); + let used = false; + let redirect; + let auth; + const server = http.createServer(async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader( + 'Content-Security-Policy', + "default-src 'none'; frame-ancestors 'none'" + ); + if (req.headers.host !== new URL(redirect).host || req.method !== 'GET') { + res.writeHead(403).end(); + return; + } + const url = new URL(req.url, redirect); + if (url.pathname === '/' && !used) { + res.writeHead(302, { Location: auth.href }).end(); + return; + } + if (url.pathname !== '/callback' || used) { + res.writeHead(404).end(); + return; + } + const returned = Buffer.from(url.searchParams.get('state') ?? ''); + if ( + returned.length !== state.length || + !timingSafeEqual(returned, Buffer.from(state)) + ) { + res.writeHead(400).end('Invalid state'); + return; + } + used = true; + try { + const code = url.searchParams.get('code'); + if (!code || url.searchParams.has('error')) + throw new Error('Consent not granted.'); + const tokens = await jsonRequest( + 'https://platform.claude.com/v1/oauth/token', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + client_id: clientId, + redirect_uri: redirect, + state, + code_verifier: verifier, + }), + } + ); + await provision({ ...tokens, acquired_at: Date.now() }); + res + .writeHead(200, { 'Content-Type': 'text/plain' }) + .end('Claude connected for the Macro demo. You can close this tab.'); + } catch { + process.exitCode = 1; + console.error( + 'Claude connection failed. No credentials were printed; check consent and cloud environment access.' + ); + res.writeHead(400).end('Connection failed. Return to the terminal.'); + } finally { + clearTimeout(timeout); + server.close(); + } + }); + const timeout = setTimeout(() => { + process.exitCode = 1; + server.close(); + console.error('Sign-in timed out.'); + }, 20 * 60_000); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + redirect = `http://localhost:${port}/callback`; + auth = new URL('https://claude.com/cai/oauth/authorize'); + auth.search = new URLSearchParams({ + code: 'true', + client_id: clientId, + response_type: 'code', + redirect_uri: redirect, + scope, + state, + code_challenge: createHash('sha256').update(verifier).digest('base64url'), + code_challenge_method: 'S256', + }).toString(); + console.log(`Complete Claude sign-in at http://localhost:${port}/`); + if (process.platform === 'darwin') + spawn('open', [`http://localhost:${port}/`], { stdio: 'ignore' }).on( + 'error', + () => {} + ); + }); +} From 895d6de1f7ffcbf23981f2f7d8cffc2b7dffa20f Mon Sep 17 00:00:00 2001 From: Eric Hayes Date: Wed, 16 Sep 2026 14:40:51 -0400 Subject: [PATCH 2/5] feat(agents): gate Claude Cloud behind feature flag --- .../web/src/features/settings/Agents.test.tsx | 32 +++++++++ apps/web/src/features/settings/Agents.tsx | 67 ++++++++++--------- .../src/features/settings/Harness.test.tsx | 42 +++++++++--- apps/web/src/features/settings/Harness.tsx | 7 +- .../web/src/lib/core/constant/featureFlags.ts | 7 ++ .../src/lib/queries/agents/models.test.tsx | 28 ++++++-- apps/web/src/lib/queries/agents/models.ts | 9 ++- docs/AGENT_GUIDE/navigation.md | 3 +- docs/CLAUDE_CLOUD_DEMO.md | 9 ++- tooling/claude-cloud/browser-smoke.mjs | 59 ++++++++++++++-- 10 files changed, 204 insertions(+), 59 deletions(-) diff --git a/apps/web/src/features/settings/Agents.test.tsx b/apps/web/src/features/settings/Agents.test.tsx index e360062589b..a420849e4f8 100644 --- a/apps/web/src/features/settings/Agents.test.tsx +++ b/apps/web/src/features/settings/Agents.test.tsx @@ -22,6 +22,14 @@ import { createStore } from 'solid-js/store'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Agents } from './Agents'; +const claudeFlag = vi.hoisted(() => ({ enabled: true })); +vi.mock('@app/lib/analytics/posthog', () => ({ + useFeatureFlag: (flag: { key: string }) => { + expect(flag.key).toBe('claude-cloud'); + return () => ({ enabled: claudeFlag.enabled }); + }, +})); + vi.mock('@queries/claude-auth/connection', () => ({ useClaudeConnectionSource: () => ({ status: () => ({ enabled: true, connected: false, ephemeral: true }), @@ -302,6 +310,30 @@ const MACROD_HARNESS = { }; describe('Agents', () => { + it.each([false, true])( + 'gates Claude harness selection and discovery when enabled=%s', + (enabled) => { + claudeFlag.enabled = enabled; + modelMocks.queries['claude-cloud:'] = successfulModels([ + { id: 'claude-default', name: 'Subscription default' }, + ]); + try { + render(() => ); + fireEvent.click(screen.getByRole('button', { name: 'Create agent' })); + const option = screen.queryByRole('option', { + name: 'Claude Cloud (demo)', + }); + expect(Boolean(option)).toBe(enabled); + const targets = vi.mocked(useAgentModelsQueries).mock.lastCall?.[0](); + expect( + targets?.some((target) => target.harness === 'claude-cloud') + ).toBe(enabled); + } finally { + claudeFlag.enabled = true; + } + } + ); + it('opens the new-agent form from a link and clears the action on cancel', () => { updateSearchParams({ createAgent: 'true' }); render(() => ); diff --git a/apps/web/src/features/settings/Agents.tsx b/apps/web/src/features/settings/Agents.tsx index e70bfa18071..0d06fe57d6c 100644 --- a/apps/web/src/features/settings/Agents.tsx +++ b/apps/web/src/features/settings/Agents.tsx @@ -1,7 +1,9 @@ +import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { ModelCatalogPicker } from '@core/component/AI/component/input/ModelCatalogPicker'; import { isLargeModelCatalog } from '@core/component/AI/component/input/modelCatalog'; import { MODEL_PRETTYNAME, Model } from '@core/component/AI/constant/model'; import { toast } from '@core/component/Toast/Toast'; +import { claudeCloud } from '@core/constant/featureFlags'; import { MACRO_AGENT_BOT_ID } from '@core/constant/macroAgent'; import { useChannelsContext } from '@core/context/channels'; import { useUserId } from '@core/context/user'; @@ -93,6 +95,7 @@ const MACRO_AGENT: AgentSummary = { /** Settings page for viewing and creating persistent agents. */ export function Agents() { + const claudeCloudFlag = useFeatureFlag(claudeCloud); const [creating, setCreating] = createSignal(false); const [searchParams, setSearchParams] = useSearchParams(); const creatingFromLink = () => searchParams.createAgent === 'true'; @@ -118,41 +121,43 @@ export function Agents() { const harnessesQuery = useHarnessesQuery(); const connectedHarnesses = (): readonly ConnectedHarness[] => { const harnesses = harnessesQuery.isSuccess ? harnessesQuery.data : []; - return buildAgentModelTargets(cursorConnected(), harnesses).map( - (target) => { - if (target.harness === 'in-memory') return IN_MEMORY_HARNESS; - if (target.harness === 'claude-cloud') { - return { - id: 'claude-cloud', - name: 'Claude Cloud (demo)', - kind: 'builtin', - target, - }; - } - if (target.harness === 'cursor') { - return { - id: 'cursor', - name: 'Cursor', - kind: 'builtin', - target, - }; - } - - const harness = harnesses.find( - (candidate) => candidate.id === target.harnessId - ); + return buildAgentModelTargets( + cursorConnected(), + harnesses, + claudeCloudFlag().enabled + ).map((target) => { + if (target.harness === 'in-memory') return IN_MEMORY_HARNESS; + if (target.harness === 'claude-cloud') { return { - id: target.harnessId ?? '', - name: - harness?.owner.type === 'team' - ? `${harness.name} · Team` - : (harness?.name ?? 'macrod'), - kind: 'macrod', + id: 'claude-cloud', + name: 'Claude Cloud (demo)', + kind: 'builtin', target, - connected: harness?.connected, }; } - ); + if (target.harness === 'cursor') { + return { + id: 'cursor', + name: 'Cursor', + kind: 'builtin', + target, + }; + } + + const harness = harnesses.find( + (candidate) => candidate.id === target.harnessId + ); + return { + id: target.harnessId ?? '', + name: + harness?.owner.type === 'team' + ? `${harness.name} · Team` + : (harness?.name ?? 'macrod'), + kind: 'macrod', + target, + connected: harness?.connected, + }; + }); }; const channelOptions = createMemo(() => botAssignableChannelOptions(channelsContext.channels()) diff --git a/apps/web/src/features/settings/Harness.test.tsx b/apps/web/src/features/settings/Harness.test.tsx index 61070591885..26f7934a805 100644 --- a/apps/web/src/features/settings/Harness.test.tsx +++ b/apps/web/src/features/settings/Harness.test.tsx @@ -29,18 +29,29 @@ vi.mock('./codex/views/CodexHarness', () => ({ CodexHarness: () =>
, })); +const claudeFlag = vi.hoisted(() => ({ enabled: true, source: vi.fn() })); +vi.mock('@app/lib/analytics/posthog', () => ({ + useFeatureFlag: (flag: { key: string }) => { + expect(flag.key).toBe('claude-cloud'); + return () => ({ enabled: claudeFlag.enabled }); + }, +})); + vi.mock('@core/context/user', () => ({ useUserId: () => () => 'macro|demo@example.com', })); vi.mock('@queries/claude-auth/connection', () => ({ - useClaudeConnectionSource: () => ({ - status: () => ({ enabled: true, connected: false, ephemeral: true }), - failed: () => false, - begin: vi.fn(), - complete: vi.fn(), - disconnect: vi.fn(), - refresh: vi.fn(), - }), + useClaudeConnectionSource: () => { + claudeFlag.source(); + return { + status: () => ({ enabled: true, connected: false, ephemeral: true }), + failed: () => false, + begin: vi.fn(), + complete: vi.fn(), + disconnect: vi.fn(), + refresh: vi.fn(), + }; + }, })); const mocks = vi.hoisted(() => ({ @@ -216,6 +227,21 @@ describe('Harness', () => { } ); + it('does not mount Claude connection or fetch its status when the flag is off', () => { + claudeFlag.enabled = false; + claudeFlag.source.mockClear(); + try { + render(() => ); + expect( + screen.queryByRole('region', { name: 'Claude Cloud connection' }) + ).toBeNull(); + expect(claudeFlag.source).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: 'Cursor' })).toBeTruthy(); + } finally { + claudeFlag.enabled = true; + } + }); + it.each(['success', 'error'] as const)( 'keeps settings visible while Cursor models load and after %s', async (outcome) => { diff --git a/apps/web/src/features/settings/Harness.tsx b/apps/web/src/features/settings/Harness.tsx index e07b7f6c022..d9f09afbcac 100644 --- a/apps/web/src/features/settings/Harness.tsx +++ b/apps/web/src/features/settings/Harness.tsx @@ -1,7 +1,9 @@ import { useCodexAgentsAccess } from '@core/codex/flag'; +import { useFeatureFlag } from '@app/lib/analytics/posthog'; import { ModelCatalogPicker } from '@core/component/AI/component/input/ModelCatalogPicker'; import { isLargeModelCatalog } from '@core/component/AI/component/input/modelCatalog'; import { toast } from '@core/component/Toast/Toast'; +import { claudeCloud } from '@core/constant/featureFlags'; import { ThrownResultError } from '@core/util/result'; import CursorIcon from '@icon/wide-cursor-ide.svg'; import ArrowUpRightIcon from '@phosphor/arrow-up-right.svg'; @@ -44,6 +46,7 @@ function lastConnectedText(harness: RegisteredHarness): string { /** Settings UI for choosing and configuring the available agent harnesses. */ export function Harness() { const canUseCodex = useCodexAgentsAccess(); + const claudeCloudFlag = useFeatureFlag(claudeCloud); const [cursorApiKey, setCursorApiKey] = createSignal(''); const cursorStatus = useCursorApiKeyStatusQuery(); const saveCursorApiKey = useSaveCursorApiKey(); @@ -178,7 +181,9 @@ export function Harness() {
- + + +
diff --git a/apps/web/src/lib/core/constant/featureFlags.ts b/apps/web/src/lib/core/constant/featureFlags.ts index 1d9f0b4f2c5..c83eb7aa85b 100644 --- a/apps/web/src/lib/core/constant/featureFlags.ts +++ b/apps/web/src/lib/core/constant/featureFlags.ts @@ -126,6 +126,13 @@ export const PROD_MODE_ENV = import.meta.env.MODE === 'production'; const onInDev = DEV_MODE_ENV || undefined; +// Claude Cloud demo onboarding and harness/model discovery. Off until PostHog +// enables it, including in dev; override locally with VITE_CLAUDE_CLOUD. +export const claudeCloud = defineFlag({ + key: 'claude-cloud', + env: 'CLAUDE_CLOUD', +}); + export const ENABLE_PDF_MODIFICATION_DATA_AUTOSAVE = defineFlag({ env: 'ENABLE_PDF_MODIFICATION_DATA_AUTOSAVE', default: true, diff --git a/apps/web/src/lib/queries/agents/models.test.tsx b/apps/web/src/lib/queries/agents/models.test.tsx index cf0cd87ba55..f7286b6ef0f 100644 --- a/apps/web/src/lib/queries/agents/models.test.tsx +++ b/apps/web/src/lib/queries/agents/models.test.tsx @@ -47,10 +47,11 @@ afterEach(() => { describe('agent model discovery', () => { it('constructs every available target in parallel without waiting for another target', async () => { - const targets = buildAgentModelTargets(true, [ - { id: 'harness-a' }, - { id: 'harness-b' }, - ]); + const targets = buildAgentModelTargets( + true, + [{ id: 'harness-a' }, { id: 'harness-b' }], + true + ); const pending = new Promise(() => {}); vi.mocked(agentHarnessServiceClient.loadAgentModels).mockReturnValue( pending @@ -77,10 +78,27 @@ describe('agent model discovery', () => { }); it('omits Cursor when it is not registered', () => { - expect(buildAgentModelTargets(false, [{ id: 'harness-a' }])).toEqual([ + expect(buildAgentModelTargets(false, [{ id: 'harness-a' }], true)).toEqual([ { harness: 'in-memory' }, { harness: 'claude-cloud' }, { harness: 'macrod', harnessId: 'harness-a' }, ]); }); + + it('does not query Claude when its feature flag is off', async () => { + vi.mocked(agentHarnessServiceClient.loadAgentModels).mockReturnValue( + new Promise(() => {}) + ); + renderHook(() => + useAgentModelsQueries(() => buildAgentModelTargets(true, [], false)) + ); + await vi.waitFor(() => + expect(agentHarnessServiceClient.loadAgentModels).toHaveBeenCalledTimes(2) + ); + expect( + vi + .mocked(agentHarnessServiceClient.loadAgentModels) + .mock.calls.map(([request]) => request) + ).toEqual([{ harness: 'in-memory' }, { harness: 'cursor' }]); + }); }); diff --git a/apps/web/src/lib/queries/agents/models.ts b/apps/web/src/lib/queries/agents/models.ts index 900d2f7bf48..7c14c533ada 100644 --- a/apps/web/src/lib/queries/agents/models.ts +++ b/apps/web/src/lib/queries/agents/models.ts @@ -12,12 +12,11 @@ export type AgentModelTarget = LoadAgentModelsRequest; /** Every model provider available to the agent dialog, in display order. */ export function buildAgentModelTargets( cursorRegistered: boolean, - harnesses: readonly Pick[] + harnesses: readonly Pick[], + claudeCloudEnabled: boolean ): AgentModelTarget[] { - const targets: AgentModelTarget[] = [ - { harness: 'in-memory' }, - { harness: 'claude-cloud' }, - ]; + const targets: AgentModelTarget[] = [{ harness: 'in-memory' }]; + if (claudeCloudEnabled) targets.push({ harness: 'claude-cloud' }); if (cursorRegistered) targets.push({ harness: 'cursor' }); targets.push( ...harnesses.map( diff --git a/docs/AGENT_GUIDE/navigation.md b/docs/AGENT_GUIDE/navigation.md index 0423ca814f4..0144423fcf8 100644 --- a/docs/AGENT_GUIDE/navigation.md +++ b/docs/AGENT_GUIDE/navigation.md @@ -245,7 +245,8 @@ Settings → Agents and Settings → Harness render while their requests are pen A pending Cursor model catalog shows `Loading models…` beside a disabled model picker; a failed catalog shows an inline error. The rest of settings stays usable. -Claude Cloud connection setup is in Settings → Harness, above Cursor, with the +With the `claude-cloud` feature flag enabled, Claude Cloud connection setup is in +Settings → Harness, above Cursor, with the Anthropic logo. Settings → Agents selects an agent's harness but does not host Claude's connection form. Claude's model picker uses provider-reported IDs, names, descriptions, and order. Settings discovers from recent account sessions; diff --git a/docs/CLAUDE_CLOUD_DEMO.md b/docs/CLAUDE_CLOUD_DEMO.md index e470fb992e9..5714d9609a8 100644 --- a/docs/CLAUDE_CLOUD_DEMO.md +++ b/docs/CLAUDE_CLOUD_DEMO.md @@ -4,6 +4,13 @@ ## Connect in the local UI (recommended) +The frontend entry points require the PostHog flag **`claude-cloud`**. It is +off by default everywhere (including dev); for a local demo, set +`VITE_CLAUDE_CLOUD=true` when starting/building the frontend. With the flag off, +the connection card never mounts and agent settings neither offer Claude Cloud +nor request its model catalog. This is a UI rollout flag, not a backend +authorization boundary or a kill switch for existing agents/sessions. + Rebuild the local backend from this workspace (`r` in the running `just run_local` terminal) and refresh the browser. In **Settings → Harness**, the **Claude Cloud (demo)** row has the Anthropic logo and appears above Cursor, @@ -22,7 +29,7 @@ The Macro user comes from authenticated request identity, never an email field. Attempts expire after ten minutes, are one-use, and are canceled on disconnect. Starting again invalidates the prior attempt. A failed exchange requires starting again. -**No credential file, container mount, or environment variable is needed locally.** +**No credential file, container mount, or credential environment variable is needed locally.** With `ENVIRONMENT=local`, grants are stored in MacroDB's `claude_oauth_grants` table. The serialized grant is KMS-encrypted, bound to its exact Macro owner and the `claude-cloud-oauth` purpose. The local stack's existing Cursor KMS key is diff --git a/tooling/claude-cloud/browser-smoke.mjs b/tooling/claude-cloud/browser-smoke.mjs index 64b1dc70cf4..eebf5c588ec 100644 --- a/tooling/claude-cloud/browser-smoke.mjs +++ b/tooling/claude-cloud/browser-smoke.mjs @@ -25,8 +25,55 @@ try { .fill('claude-ui-demo@example.com'); await page.getByRole('button', { name: 'Continue', exact: true }).click(); await page.waitForURL(/\/app\/(component|settings)/, { timeout: 60000 }); + // Vite-only local smoke: simulate the local env override in this browser. + // Local dev intentionally does not initialize PostHog. Keep the real flag + // definition/readers, and never alter the running server's environment. + let claudeEnabled = false; + await page.route( + '**/src/lib/core/constant/featureFlags.ts*', + async (route) => { + const response = await route.fetch(); + const body = await response.text(); + const definition = 'export const claudeCloud = defineFlag({'; + if (!body.includes(definition)) + throw new Error('Claude flag definition not found'); + await route.fulfill({ + response, + body: body.replace( + definition, + `import.meta.env.VITE_CLAUDE_CLOUD = '${claudeEnabled}';\n${definition}` + ), + }); + } + ); + const claudeRequests = []; + page.on('request', (request) => { + if (!['fetch', 'xhr'].includes(request.resourceType())) return; + if ( + request.url().includes('/claude-auth/') || + (request.url().includes('/models') && + request.postData()?.includes('claude-cloud')) + ) + claudeRequests.push(request.url()); + }); await page.goto(`${origin}/app/settings/harness`); + await page.getByRole('heading', { name: 'Cursor', exact: true }).waitFor(); const card = page.getByRole('region', { name: 'Claude Cloud connection' }); + if (await card.count()) + throw new Error('Claude connection visible with flag off'); + await page.goto(`${origin}/app/settings/agents?createAgent=true`); + await page.getByRole('dialog').waitFor(); + if (await page.getByRole('option', { name: 'Claude Cloud (demo)' }).count()) + throw new Error('Claude harness visible with flag off'); + if (claudeRequests.length) + throw new Error('Claude data fetched with flag off'); + console.log( + 'PASS: flag off hides onboarding and harness selection without Claude requests.' + ); + await page.goto(`${origin}/app/settings/harness`); + await page.getByRole('heading', { name: 'Cursor', exact: true }).waitFor(); + claudeEnabled = true; + await page.reload(); await card.waitFor({ timeout: 60000 }); console.log('PASS: Claude connection card is visible before authorization.'); await card.getByLabel('Anthropic').waitFor(); @@ -150,13 +197,11 @@ try { body: JSON.stringify(body), }); }); - await page - .context() - .route('https://claude.ai/**', (route) => - route.fulfill({ - body: 'Claude link target intercepted by local smoke test', - }) - ); + await page.context().route('https://claude.ai/**', (route) => + route.fulfill({ + body: 'Claude link target intercepted by local smoke test', + }) + ); await page.goto(`${origin}/app/agent/${fixtureId}`); const openClaude = page.getByRole('button', { name: 'Open in Claude', From e07795dfcc9ab770b7a92abdbea4004929664930 Mon Sep 17 00:00:00 2001 From: Eric Hayes Date: Wed, 16 Sep 2026 15:22:10 -0400 Subject: [PATCH 3/5] fix(agents): open Claude sign-in on the first click --- .../claude-connection/claude-connection.tsx | 4 +- .../components/connection-card.tsx | 4 +- .../claude-connection/core/connection.ts | 6 ++ .../claude-connection/open-sign-in.test.ts | 51 ++++++++++++++ .../claude-connection/open-sign-in.ts | 32 +++++++++ .../primitives/connection.test.ts | 66 ++++++++++++++++++- .../primitives/connection.ts | 20 +++++- apps/web/src/features/settings/Harness.tsx | 2 +- docs/AGENT_GUIDE/navigation.md | 4 +- docs/CLAUDE_CLOUD_DEMO.md | 3 +- tooling/claude-cloud/browser-smoke.mjs | 32 +++++++++ 11 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/features/claude-connection/open-sign-in.test.ts create mode 100644 apps/web/src/features/claude-connection/open-sign-in.ts diff --git a/apps/web/src/features/claude-connection/claude-connection.tsx b/apps/web/src/features/claude-connection/claude-connection.tsx index 2ce00af50b7..a2ac596dfc3 100644 --- a/apps/web/src/features/claude-connection/claude-connection.tsx +++ b/apps/web/src/features/claude-connection/claude-connection.tsx @@ -2,11 +2,13 @@ import { useUserId } from '@core/context/user'; import { useClaudeConnectionSource } from '@queries/claude-auth/connection'; import { Suspense } from 'solid-js'; import { ConnectionCard } from './components/connection-card'; +import { openClaudeSignIn } from './open-sign-in'; import { createClaudeConnection } from './primitives/connection'; function ConnectedCard() { const connection = createClaudeConnection( - useClaudeConnectionSource(useUserId()) + useClaudeConnectionSource(useUserId()), + openClaudeSignIn ); return ( - 1. Open Claude sign-in ↗ + Didn't open? Open Claude sign-in ↗ void; + close: () => void; +}; + /** Narrow source contract; the controller does not know the query or transport library. */ export type ClaudeConnectionSource = { status: () => ClaudeConnectionStatus | undefined; diff --git a/apps/web/src/features/claude-connection/open-sign-in.test.ts b/apps/web/src/features/claude-connection/open-sign-in.test.ts new file mode 100644 index 00000000000..0f4100e6431 --- /dev/null +++ b/apps/web/src/features/claude-connection/open-sign-in.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { isTauri } from '@core/util/platform'; +import { openExternalUrl } from '@core/util/url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { openClaudeSignIn } from './open-sign-in'; + +vi.mock('@core/util/platform', () => ({ isTauri: vi.fn(() => false) })); +vi.mock('@core/util/url', () => ({ openExternalUrl: vi.fn() })); +afterEach(() => vi.restoreAllMocks()); + +describe('Claude sign-in navigation', () => { + it('reserves an isolated tab with no referrer and navigates it once ready', () => { + const popup = { + opener: window, + document: document.implementation.createHTMLDocument(), + closed: false, + location: { replace: vi.fn() }, + close: vi.fn(), + }; + const open = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as Window); + const signIn = openClaudeSignIn(); + expect(open).toHaveBeenCalledWith('about:blank', '_blank'); + expect(popup.opener).toBeNull(); + expect( + popup.document + .querySelector('meta[name="referrer"]') + ?.getAttribute('content') + ).toBe('no-referrer'); + signIn?.navigate('https://claude.com/consent'); + expect(popup.location.replace).toHaveBeenCalledWith( + 'https://claude.com/consent' + ); + signIn?.close(); + expect(popup.close).toHaveBeenCalledOnce(); + }); + it('returns no reserved tab when blocked', () => { + vi.spyOn(window, 'open').mockReturnValue(null); + expect(openClaudeSignIn()).toBeUndefined(); + }); + it('uses the native external browser without opening a blank tab', () => { + vi.mocked(isTauri).mockReturnValueOnce(true); + const open = vi.spyOn(window, 'open'); + const signIn = openClaudeSignIn(); + expect(open).not.toHaveBeenCalled(); + signIn?.navigate('https://claude.com/consent'); + expect(openExternalUrl).toHaveBeenCalledWith('https://claude.com/consent'); + }); +}); diff --git a/apps/web/src/features/claude-connection/open-sign-in.ts b/apps/web/src/features/claude-connection/open-sign-in.ts new file mode 100644 index 00000000000..39b0b32a37c --- /dev/null +++ b/apps/web/src/features/claude-connection/open-sign-in.ts @@ -0,0 +1,32 @@ +import { isTauri } from '@core/util/platform'; +import { openExternalUrl } from '@core/util/url'; +import type { ClaudeSignIn } from './core/connection'; + +/** Reserve a browser tab before awaiting auth; native shells use the system browser. */ +export function openClaudeSignIn(): ClaudeSignIn | undefined { + if (isTauri()) return { navigate: openExternalUrl, close: () => {} }; + + let popup: Window | null = null; + try { + popup = window.open('about:blank', '_blank'); + if (!popup) return; + popup.opener = null; + const referrer = popup.document.createElement('meta'); + referrer.name = 'referrer'; + referrer.content = 'no-referrer'; + popup.document.head.append(referrer); + popup.document.title = 'Connecting to Claude…'; + popup.document.body.textContent = 'Preparing Claude sign-in…'; + const tab = popup; + return { + navigate: (url) => { + if (!tab.closed) tab.location.replace(url); + }, + close: () => tab.close(), + }; + } catch { + popup?.close(); + // The settings form retains a normal link when opening a tab is blocked. + return; + } +} diff --git a/apps/web/src/features/claude-connection/primitives/connection.test.ts b/apps/web/src/features/claude-connection/primitives/connection.test.ts index 1ace51df83e..c0c935b643d 100644 --- a/apps/web/src/features/claude-connection/primitives/connection.test.ts +++ b/apps/web/src/features/claude-connection/primitives/connection.test.ts @@ -30,10 +30,74 @@ function setup() { }), refresh: vi.fn(async () => {}), }; - return { state: createClaudeConnection(source), source, dispose }; + const signIn = { navigate: vi.fn(), close: vi.fn() }; + const openSignIn = vi.fn(() => signIn as typeof signIn | undefined); + return { + state: createClaudeConnection(source, openSignIn), + source, + dispose, + signIn, + openSignIn, + }; }); } describe('Claude connection controller', () => { + it('reserves a tab synchronously and navigates after authorization starts', async () => { + const { state, source, dispose, signIn, openSignIn } = setup(); + const pending = state.begin(); + expect(openSignIn).toHaveBeenCalledOnce(); + expect(openSignIn.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(source.begin).mock.invocationCallOrder[0] + ); + expect(signIn.navigate).not.toHaveBeenCalled(); + await state.begin(); + await pending; + expect(openSignIn).toHaveBeenCalledOnce(); + expect(signIn.navigate).toHaveBeenCalledWith('https://claude.com/consent'); + dispose(); + expect(signIn.close).not.toHaveBeenCalled(); + }); + it('keeps the sign-in link available when a popup is blocked', async () => { + const { state, dispose, openSignIn } = setup(); + openSignIn.mockReturnValue(undefined); + await state.begin(); + expect(state.login()?.authorizationUrl).toBe('https://claude.com/consent'); + expect(state.error()).toBe(''); + dispose(); + }); + it('closes the reserved tab when authorization setup fails', async () => { + const { state, source, dispose, signIn } = setup(); + vi.mocked(source.begin).mockRejectedValueOnce( + new Error('Could not start sign-in') + ); + await state.begin(); + expect(signIn.close).toHaveBeenCalledOnce(); + expect(signIn.navigate).not.toHaveBeenCalled(); + expect(state.error()).toBe('Could not start sign-in'); + expect(state.busy()).toBe(false); + dispose(); + }); + it('closes a pending tab on unmount and ignores a late authorization URL', async () => { + const { state, source, dispose, signIn } = setup(); + let resolve!: ( + login: Awaited> + ) => void; + vi.mocked(source.begin).mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }) + ); + const pending = state.begin(); + dispose(); + expect(signIn.close).toHaveBeenCalledOnce(); + resolve({ + attemptId: 'late', + authorizationUrl: 'https://claude.com/consent', + expiresIn: 600, + }); + await pending; + expect(signIn.navigate).not.toHaveBeenCalled(); + }); it('connects and immediately clears the one-time code, then disconnects', async () => { const { state, source, dispose } = setup(); await state.begin(); diff --git a/apps/web/src/features/claude-connection/primitives/connection.ts b/apps/web/src/features/claude-connection/primitives/connection.ts index c8df5efae25..50238603cf0 100644 --- a/apps/web/src/features/claude-connection/primitives/connection.ts +++ b/apps/web/src/features/claude-connection/primitives/connection.ts @@ -1,13 +1,21 @@ import { createSignal, onCleanup } from 'solid-js'; -import type { ClaudeConnectionSource, ClaudeLogin } from '../core/connection'; +import type { + ClaudeConnectionSource, + ClaudeLogin, + ClaudeSignIn, +} from '../core/connection'; /** Owns consent UI state; the source owns transport and cache invalidation. */ -export function createClaudeConnection(source: ClaudeConnectionSource) { +export function createClaudeConnection( + source: ClaudeConnectionSource, + openSignIn: () => ClaudeSignIn | undefined +) { const [login, setLogin] = createSignal(); const [code, setCode] = createSignal(''); const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal(''); let disposed = false; + let pendingSignIn: ClaudeSignIn | undefined; let expiry: ReturnType | undefined; const clear = () => { clearTimeout(expiry); @@ -16,6 +24,8 @@ export function createClaudeConnection(source: ClaudeConnectionSource) { }; onCleanup(() => { disposed = true; + pendingSignIn?.close(); + pendingSignIn = undefined; clearTimeout(expiry); setCode(''); }); @@ -32,6 +42,8 @@ export function createClaudeConnection(source: ClaudeConnectionSource) { setBusy(true); setError(''); try { + // Reserve the tab before the first await to retain the user's click activation. + pendingSignIn = openSignIn(); const next = await source.begin(); if (disposed) return; setLogin(next); @@ -39,7 +51,11 @@ export function createClaudeConnection(source: ClaudeConnectionSource) { clear(); setError('Sign-in expired. Start Connect Claude again.'); }, next.expiresIn * 1000); + pendingSignIn?.navigate(next.authorizationUrl); + pendingSignIn = undefined; } catch (error) { + pendingSignIn?.close(); + pendingSignIn = undefined; if (!disposed) report(error); } finally { if (!disposed) setBusy(false); diff --git a/apps/web/src/features/settings/Harness.tsx b/apps/web/src/features/settings/Harness.tsx index d9f09afbcac..694cb4ca06b 100644 --- a/apps/web/src/features/settings/Harness.tsx +++ b/apps/web/src/features/settings/Harness.tsx @@ -1,5 +1,5 @@ -import { useCodexAgentsAccess } from '@core/codex/flag'; import { useFeatureFlag } from '@app/lib/analytics/posthog'; +import { useCodexAgentsAccess } from '@core/codex/flag'; import { ModelCatalogPicker } from '@core/component/AI/component/input/ModelCatalogPicker'; import { isLargeModelCatalog } from '@core/component/AI/component/input/modelCatalog'; import { toast } from '@core/component/Toast/Toast'; diff --git a/docs/AGENT_GUIDE/navigation.md b/docs/AGENT_GUIDE/navigation.md index 0144423fcf8..721fa918428 100644 --- a/docs/AGENT_GUIDE/navigation.md +++ b/docs/AGENT_GUIDE/navigation.md @@ -248,7 +248,9 @@ picker; a failed catalog shows an inline error. The rest of settings stays usabl With the `claude-cloud` feature flag enabled, Claude Cloud connection setup is in Settings → Harness, above Cursor, with the Anthropic logo. Settings → Agents selects an agent's harness but does not host -Claude's connection form. Claude's model picker uses provider-reported IDs, +Claude's connection form. **Connect Claude** starts authorization and opens sign-in +on the first click; a fallback link remains if the browser blocks the tab. +Claude's model picker uses provider-reported IDs, names, descriptions, and order. Settings discovers from recent account sessions; session catalogs update through replay, polling, and streaming. Before any catalog is available, only subscription default is shown with an explanation. It saves the next-turn preference diff --git a/docs/CLAUDE_CLOUD_DEMO.md b/docs/CLAUDE_CLOUD_DEMO.md index 5714d9609a8..3296fa8a9f7 100644 --- a/docs/CLAUDE_CLOUD_DEMO.md +++ b/docs/CLAUDE_CLOUD_DEMO.md @@ -16,7 +16,8 @@ terminal) and refresh the browser. In **Settings → Harness**, the **Claude Cloud (demo)** row has the Anthropic logo and appears above Cursor, even before an account is connected. Connection setup is not in Settings → Agents: -1. Click **Connect Claude**, then **Open Claude sign-in**. +1. Click **Connect Claude** to open Claude sign-in in a new tab automatically. + If the browser blocks it, use the **Didn't open? Open Claude sign-in** link. 2. Sign in and approve on Claude's own page. Macro never asks for your password. 3. Copy the entire one-time `code#state` displayed by Claude, paste it into Macro, and click **Finish connecting**. diff --git a/tooling/claude-cloud/browser-smoke.mjs b/tooling/claude-cloud/browser-smoke.mjs index eebf5c588ec..c6cb45fdfb7 100644 --- a/tooling/claude-cloud/browser-smoke.mjs +++ b/tooling/claude-cloud/browser-smoke.mjs @@ -81,6 +81,12 @@ try { if (order.indexOf('Claude Cloud (demo)') >= order.indexOf('Cursor')) throw new Error('Claude must appear above Cursor'); // Reloading after the backend build is a manual step; this script fails visibly if stale. + await page + .context() + .route('https://claude.com/**', (route) => + route.fulfill({ body: 'Claude sign-in intercepted by local smoke test' }) + ); + const signInOpening = page.waitForEvent('popup'); const starting = page.waitForResponse( (response) => response.url().endsWith('/claude-auth/start') && @@ -96,6 +102,14 @@ try { const consent = card.getByRole('link', { name: /Open Claude sign-in/ }); await consent.waitFor({ timeout: 15000 }); const url = new URL(await consent.getAttribute('href')); + const signIn = await signInOpening; + await signIn.waitForURL(url.href); + if (!(await signIn.evaluate(() => window.opener === null))) + throw new Error('Claude sign-in tab must not retain an opener'); + await signIn.close(); + console.log( + 'PASS: the first Connect Claude click opens sign-in with no opener (provider intercepted).' + ); if ( url.origin !== 'https://claude.com' || url.pathname !== '/cai/oauth/authorize' || @@ -150,6 +164,24 @@ try { if (replay.status() !== 409) throw new Error('Canceled attempt was not rejected'); console.log('PASS: a canceled attempt cannot be exchanged.'); + await page.evaluate(() => { + const original = window.open; + window.open = () => { + window.open = original; + return null; + }; + }); + await card + .getByRole('button', { name: 'Connect Claude', exact: true }) + .click(); + await card + .getByRole('link', { name: /Didn't open\? Open Claude sign-in/ }) + .waitFor(); + await card.getByLabel(/Paste the complete/).waitFor(); + await card.getByRole('button', { name: 'Cancel', exact: true }).click(); + console.log( + 'PASS: a blocked popup retains the fallback sign-in link and code entry.' + ); await page.goto(`${origin}/app/settings/agents`); await page .getByRole('heading', { name: 'Agents', exact: true }) From 6272ae9e191521e34ed88a652fc29ce943ae55e2 Mon Sep 17 00:00:00 2001 From: Eric Hayes Date: Wed, 16 Sep 2026 15:56:21 -0400 Subject: [PATCH 4/5] fix(agents): regenerate Claude Cloud clients and dependency metadata --- .github/workspace-dep-closures.json | 21 + Cargo.lock | 1 + Cargo.toml | 1 + .../service-agent-harness/generated/client.ts | 198 +++++++++ .../generated/schemas/completeRequest.ts | 16 + .../generated/schemas/emptyRequest.ts | 13 + .../generated/schemas/index.ts | 4 + .../generated/schemas/startResponse.ts | 21 + .../generated/schemas/statusResponse.ts | 18 + .../service-agent-harness/openapi.json | 393 +++++++++--------- crates/claude_cloud_agents/Cargo.toml | 7 +- crates/cursor_cloud_agents/Cargo.toml | 2 +- packages/sdk/generated/agent-harness/index.ts | 2 +- .../sdk/generated/agent-harness/sdk.gen.ts | 58 ++- .../sdk/generated/agent-harness/types.gen.ts | 157 ++++++- packages/sdk/specs/agent-harness.json | 393 +++++++++--------- packages/sdk/src/coverage/skipped.ts | 5 + 17 files changed, 891 insertions(+), 419 deletions(-) create mode 100644 apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts create mode 100644 apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts create mode 100644 apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts create mode 100644 apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index d2aecade593..059eaa0f641 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -275,6 +275,7 @@ "crates/channel_sender", "crates/channels", "crates/chat", + "crates/claude_cloud_agents", "crates/codex_cloud_agents", "crates/codex_connection", "crates/comms_db_client", @@ -1833,6 +1834,26 @@ "crates/share_permission_db_utils", "crates/workspace-hack" ], + "claude_cloud_agents": [ + "crates/agent_runtime_protocol", + "crates/bot_id", + "crates/bot_token", + "crates/claude_cloud_agents", + "crates/cowlike", + "crates/cursor_api_key", + "crates/harness_id", + "crates/harness_token", + "crates/macro_auth", + "crates/macro_authorization", + "crates/macro_env", + "crates/macro_env_var", + "crates/macro_user_id", + "crates/macro_uuid", + "crates/model-error-response", + "crates/model_user", + "crates/remote_env_var", + "crates/workspace-hack" + ], "codex_cloud_agents": [ "crates/activity", "crates/agent", diff --git a/Cargo.lock b/Cargo.lock index 09ddda80224..5f9d9ad985b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3641,6 +3641,7 @@ dependencies = [ "tokio", "utoipa", "uuid", + "workspace-hack", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 34b1134511b..9ff9ee45628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -303,6 +303,7 @@ sqlx = { version = "0.8.6", features = [ "uuid", ] } sqlx-core = { version = "0.8.6" } +sse-core = { version = "0.2.3", default-features = false, features = ["std"] } strum = { version = "0.27.1", features = ["derive"] } tempfile = "3" thiserror = "2.0.0" diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts index f36f0e1c3b5..79d3ab2e5ef 100644 --- a/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts @@ -8,17 +8,21 @@ import type { AgentSessionLogResponse, AgentSessionQueueResponse, AgentSessionResponse, + CompleteRequest, ControlRequest, ControlResponse, CreateAgentSessionRequest, CreateAgentSessionResponse, EditQueuedActionRequest, + EmptyRequest, LoadAgentModelsRequest, LoadAgentModelsResponse, PreviewAgentSessionsRequest, PreviewAgentSessionsResponse, RenameAgentSessionRequest, SandboxSizeBody, + StartResponse, + StatusResponse, } from './schemas'; /** @@ -990,3 +994,197 @@ export const putAgentSessionSandboxSize = async ( headers: res.headers, } as putAgentSessionSandboxSizeResponse; }; + +/** + * @summary Read connection status for the authenticated user only. + */ +export type statusResponse200 = { + data: StatusResponse; + status: 200; +}; + +export type statusResponse401 = { + data: void; + status: 401; +}; + +export type statusResponseSuccess = statusResponse200 & { + headers: Headers; +}; +export type statusResponseError = statusResponse401 & { + headers: Headers; +}; + +export type statusResponse = statusResponseSuccess | statusResponseError; + +export const getStatusUrl = () => { + return `/claude-auth`; +}; + +export const status = async ( + options?: RequestInit +): Promise => { + const res = await fetch(getStatusUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: statusResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as statusResponse; +}; + +/** + * @summary Forget only the authenticated user's grant and cancel pending consent. + */ +export type disconnectResponse204 = { + data: void; + status: 204; +}; + +export type disconnectResponse403 = { + data: void; + status: 403; +}; + +export type disconnectResponseSuccess = disconnectResponse204 & { + headers: Headers; +}; +export type disconnectResponseError = disconnectResponse403 & { + headers: Headers; +}; + +export type disconnectResponse = + | disconnectResponseSuccess + | disconnectResponseError; + +export const getDisconnectUrl = () => { + return `/claude-auth`; +}; + +export const disconnect = async ( + emptyRequest: EmptyRequest, + options?: RequestInit +): Promise => { + const res = await fetch(getDisconnectUrl(), { + ...options, + method: 'DELETE', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(emptyRequest), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: disconnectResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as disconnectResponse; +}; + +/** + * @summary Exchange one code; never return access or refresh tokens. + */ +export type completeResponse204 = { + data: void; + status: 204; +}; + +export type completeResponse400 = { + data: void; + status: 400; +}; + +export type completeResponse409 = { + data: void; + status: 409; +}; + +export type completeResponse502 = { + data: void; + status: 502; +}; + +export type completeResponseSuccess = completeResponse204 & { + headers: Headers; +}; +export type completeResponseError = ( + | completeResponse400 + | completeResponse409 + | completeResponse502 +) & { + headers: Headers; +}; + +export type completeResponse = completeResponseSuccess | completeResponseError; + +export const getCompleteUrl = () => { + return `/claude-auth/complete`; +}; + +export const complete = async ( + completeRequest: CompleteRequest, + options?: RequestInit +): Promise => { + const res = await fetch(getCompleteUrl(), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(completeRequest), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: completeResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as completeResponse; +}; + +/** + * @summary Create an expiring PKCE challenge for the authenticated user. + */ +export type startResponse200 = { + data: StartResponse; + status: 200; +}; + +export type startResponse403 = { + data: void; + status: 403; +}; + +export type startResponse429 = { + data: void; + status: 429; +}; + +export type startResponseSuccess = startResponse200 & { + headers: Headers; +}; +export type startResponseError = (startResponse403 | startResponse429) & { + headers: Headers; +}; + +export type startResponse = startResponseSuccess | startResponseError; + +export const getStartUrl = () => { + return `/claude-auth/start`; +}; + +export const start = async ( + emptyRequest: EmptyRequest, + options?: RequestInit +): Promise => { + const res = await fetch(getStartUrl(), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(emptyRequest), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: startResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as startResponse; +}; diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts new file mode 100644 index 00000000000..9e1e141d0ef --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * agent_harness_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * One-time manual code. Deliberately does not implement Debug. + */ +export interface CompleteRequest { + /** Server-issued handle; never an owner selected by the caller. */ + attemptId: string; + /** Claude's complete code#state string, not an access token. */ + code: string; +} diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts new file mode 100644 index 00000000000..722dd6e1749 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * agent_harness_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * A JSON body is required on writes, including start/disconnect (no form-based CSRF). + */ +export interface EmptyRequest { + [key: string]: unknown; +} diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts index 07912c86c3e..d6094747679 100644 --- a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts @@ -55,6 +55,7 @@ export * from './agentSessionResponseThreadChannelId'; export * from './agentSessionResponseThreadId'; export * from './agentSetModelAction'; export * from './botId'; +export * from './completeRequest'; export * from './controlRequest'; export * from './controlResponse'; export * from './controlStatusDto'; @@ -81,6 +82,7 @@ export * from './elicitationAnswerOneOfSeven'; export * from './elicitationAnswerOneOfSevenAction'; export * from './elicitationContentValue'; export * from './elicitationRequestId'; +export * from './emptyRequest'; export * from './externalSessionResponse'; export * from './externalSessionResponseName'; export * from './externalSessionResponseUrl'; @@ -109,4 +111,6 @@ export * from './sessionStatusDtoOneOfFiveKind'; export * from './sessionStatusDtoOneOfKind'; export * from './sessionStatusDtoOneOfThree'; export * from './sessionStatusDtoOneOfThreeKind'; +export * from './startResponse'; +export * from './statusResponse'; export * from './withAgentSessionId'; diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts new file mode 100644 index 00000000000..3548c268b08 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * agent_harness_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * Public PKCE challenge and attempt handle; contains no verifier or provider tokens. + */ +export interface StartResponse { + /** Opaque owner-bound attempt handle. */ + attemptId: string; + /** Claude-hosted consent page. */ + authorizationUrl: string; + /** + * Attempt lifetime in seconds. + * @minimum 0 + */ + expiresIn: number; +} diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts new file mode 100644 index 00000000000..19a9af1c856 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * agent_harness_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * Safe connection metadata. + */ +export interface StatusResponse { + /** Whether the authenticated Macro user has connected. */ + connected: boolean; + /** Whether this deployment supports browser connection. */ + enabled: boolean; + /** Whether reconnecting after service restart is required. */ + ephemeral: boolean; +} diff --git a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json index b7314f3abae..f362574a52a 100644 --- a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json +++ b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json @@ -10,145 +10,6 @@ "version": "0.1.0" }, "paths": { - "/claude-auth": { - "get": { - "tags": [ - "claude-auth" - ], - "summary": "Read connection status for the authenticated user only.", - "operationId": "status", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - } - }, - "401": { - "description": "Unauthenticated" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "claude-auth" - ], - "summary": "Forget only the authenticated user's grant and cancel pending consent.", - "operationId": "disconnect", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmptyRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Disconnected" - }, - "403": { - "description": "Disabled" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/claude-auth/complete": { - "post": { - "tags": [ - "claude-auth" - ], - "summary": "Exchange one code; never return access or refresh tokens.", - "operationId": "complete", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompleteRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Connected" - }, - "400": { - "description": "Invalid code" - }, - "409": { - "description": "Expired or replayed" - }, - "502": { - "description": "Provider failed" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/claude-auth/start": { - "post": { - "tags": [ - "claude-auth" - ], - "summary": "Create an expiring PKCE challenge for the authenticated user.", - "operationId": "start", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmptyRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartResponse" - } - } - } - }, - "403": { - "description": "Disabled" - }, - "429": { - "description": "Too many attempts" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "/agent-models/load": { "post": { "tags": ["agent-models"], @@ -1055,82 +916,141 @@ } } } - } - }, - "components": { - "schemas": { - "StatusResponse": { - "type": "object", - "description": "Safe connection metadata.", - "required": [ - "enabled", - "connected", - "ephemeral" - ], - "properties": { - "connected": { - "type": "boolean", - "description": "Whether the authenticated Macro user has connected." - }, - "enabled": { - "type": "boolean", - "description": "Whether this deployment supports browser connection." + }, + "/claude-auth": { + "get": { + "tags": ["claude-auth"], + "summary": "Read connection status for the authenticated user only.", + "operationId": "status", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } }, - "ephemeral": { - "type": "boolean", - "description": "Whether reconnecting after service restart is required." + "401": { + "description": "Unauthenticated" } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] }, - "StartResponse": { - "type": "object", - "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", - "required": [ - "attemptId", - "authorizationUrl", - "expiresIn" - ], - "properties": { - "attemptId": { - "type": "string", - "description": "Opaque owner-bound attempt handle." + "delete": { + "tags": ["claude-auth"], + "summary": "Forget only the authenticated user's grant and cancel pending consent.", + "operationId": "disconnect", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } }, - "authorizationUrl": { - "type": "string", - "description": "Claude-hosted consent page." + "required": true + }, + "responses": { + "204": { + "description": "Disconnected" }, - "expiresIn": { - "type": "integer", - "format": "int64", - "description": "Attempt lifetime in seconds.", - "minimum": 0 + "403": { + "description": "Disabled" } - } - }, - "CompleteRequest": { - "type": "object", - "description": "One-time manual code. Deliberately does not implement Debug.", - "required": [ - "attemptId", - "code" - ], - "properties": { - "attemptId": { - "type": "string", - "description": "Server-issued handle; never an owner selected by the caller." + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/complete": { + "post": { + "tags": ["claude-auth"], + "summary": "Exchange one code; never return access or refresh tokens.", + "operationId": "complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + } }, - "code": { - "type": "string", - "description": "Claude's complete code#state string, not an access token." + "required": true + }, + "responses": { + "204": { + "description": "Connected" + }, + "400": { + "description": "Invalid code" + }, + "409": { + "description": "Expired or replayed" + }, + "502": { + "description": "Provider failed" } }, - "additionalProperties": false - }, - "EmptyRequest": { - "type": "object", - "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", - "additionalProperties": false - }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/start": { + "post": { + "tags": ["claude-auth"], + "summary": "Create an expiring PKCE challenge for the authenticated user.", + "operationId": "start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartResponse" + } + } + } + }, + "403": { + "description": "Disabled" + }, + "429": { + "description": "Too many attempts" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { "AgentAction": { "oneOf": [ { @@ -1587,6 +1507,22 @@ "BotId": { "type": "string" }, + "CompleteRequest": { + "type": "object", + "description": "One-time manual code. Deliberately does not implement Debug.", + "required": ["attemptId", "code"], + "properties": { + "attemptId": { + "type": "string", + "description": "Server-issued handle; never an owner selected by the caller." + }, + "code": { + "type": "string", + "description": "Claude's complete code#state string, not an access token." + } + }, + "additionalProperties": false + }, "ControlRequest": { "allOf": [ { @@ -1799,6 +1735,11 @@ ], "description": "The JSON-RPC id of an agent's `elicitation/create` request, carried whole\nso the answer echoes exactly what the agent sent.\n\nAgents pick these, not us: Claude Code counts from `0`, others use\nstrings. `null` is not a legal id for a request that expects a response,\nso it is not representable here." }, + "EmptyRequest": { + "type": "object", + "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", + "additionalProperties": false + }, "ExternalSessionResponse": { "type": "object", "description": "The provider-side identity of an externally-served session.", @@ -2030,6 +1971,46 @@ ], "description": "Transport representation of a session's status, mirroring\n[`SessionStatus`]." }, + "StartResponse": { + "type": "object", + "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", + "required": ["attemptId", "authorizationUrl", "expiresIn"], + "properties": { + "attemptId": { + "type": "string", + "description": "Opaque owner-bound attempt handle." + }, + "authorizationUrl": { + "type": "string", + "description": "Claude-hosted consent page." + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Attempt lifetime in seconds.", + "minimum": 0 + } + } + }, + "StatusResponse": { + "type": "object", + "description": "Safe connection metadata.", + "required": ["enabled", "connected", "ephemeral"], + "properties": { + "connected": { + "type": "boolean", + "description": "Whether the authenticated Macro user has connected." + }, + "enabled": { + "type": "boolean", + "description": "Whether this deployment supports browser connection." + }, + "ephemeral": { + "type": "boolean", + "description": "Whether reconnecting after service restart is required." + } + } + }, "WithAgentSessionId": { "type": "object", "description": "Just a session id, for the preview variants that carry nothing else.\n\nClients deserialize this, so both derives are used.", diff --git a/crates/claude_cloud_agents/Cargo.toml b/crates/claude_cloud_agents/Cargo.toml index dfe2f14d484..1a00f10143a 100644 --- a/crates/claude_cloud_agents/Cargo.toml +++ b/crates/claude_cloud_agents/Cargo.toml @@ -13,20 +13,21 @@ base64 = { workspace = true } macro_authorization = { path = "../macro_authorization", features = ["axum"] } rand = { workspace = true } sha2 = { workspace = true } -subtle = "2" +subtle = { workspace = true } utoipa = { workspace = true } agent-runtime-protocol = { package = "agent_runtime_protocol", path = "../agent_runtime_protocol", default-features = false, features = ["transport"] } futures = { workspace = true } reqwest = { workspace = true, features = ["stream", "query"] } serde = { workspace = true } serde_json = { workspace = true } -sse-core = { version = "0.2.3", default-features = false, features = ["std"] } +sse-core = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "sync", "time", "macros"] } uuid = { workspace = true } zeroize = { workspace = true } +workspace-hack = { version = "0.1", path = "../workspace-hack" } [dev-dependencies] macro_db_migrator = { path = "../macro_db_migrator" } -tempfile = "3" +tempfile = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/crates/cursor_cloud_agents/Cargo.toml b/crates/cursor_cloud_agents/Cargo.toml index c06aa6a644c..7b1ff8a7955 100644 --- a/crates/cursor_cloud_agents/Cargo.toml +++ b/crates/cursor_cloud_agents/Cargo.toml @@ -32,7 +32,7 @@ rootcause = { workspace = true } # Strictly spec-compliant SSE decoding. `default-features = false` drops the # retry/jitter and async-stream halves: this crate drives the decoder itself, # synchronously, so all it needs is the state machine. -sse-core = { version = "0.2.3", default-features = false, features = ["std"] } +sse-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/packages/sdk/generated/agent-harness/index.ts b/packages/sdk/generated/agent-harness/index.ts index f90098c643a..a38a3672927 100644 --- a/packages/sdk/generated/agent-harness/index.ts +++ b/packages/sdk/generated/agent-harness/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { type Options, Sdk } from './sdk.gen'; -export type { AgentAction, AgentActionId, AgentModelDto, AgentModelsStatusDto, AgentPromptAction, AgentRespondElicitationAction, AgentSessionLogEntryDto, AgentSessionLogResponse, AgentSessionPreviewData, AgentSessionPreviewDto, AgentSessionQueueResponse, AgentSessionResponse, AgentSetModelAction, BotId, ClientOptions, ControlAgentSessionData, ControlAgentSessionError, ControlAgentSessionErrors, ControlAgentSessionResponse, ControlAgentSessionResponses, ControlRequest, ControlResponse, ControlStatusDto, CreateAgentSessionData, CreateAgentSessionError, CreateAgentSessionErrors, CreateAgentSessionRequest, CreateAgentSessionResponse, CreateAgentSessionResponse2, CreateAgentSessionResponses, CreateSessionThread, DeleteAgentSessionData, DeleteAgentSessionError, DeleteAgentSessionErrors, DeleteAgentSessionResponses, EditQueuedActionData, EditQueuedActionError, EditQueuedActionErrors, EditQueuedActionRequest, EditQueuedActionResponse, EditQueuedActionResponses, ElicitationAnswer, ElicitationContentValue, ElicitationRequestId, ExternalSessionResponse, GetAgentSandboxSizeData, GetAgentSandboxSizeError, GetAgentSandboxSizeErrors, GetAgentSandboxSizeResponse, GetAgentSandboxSizeResponses, GetAgentSessionData, GetAgentSessionError, GetAgentSessionErrors, GetAgentSessionLogData, GetAgentSessionLogError, GetAgentSessionLogErrors, GetAgentSessionLogResponse, GetAgentSessionLogResponses, GetAgentSessionQueueData, GetAgentSessionQueueError, GetAgentSessionQueueErrors, GetAgentSessionQueueResponse, GetAgentSessionQueueResponses, GetAgentSessionResponse, GetAgentSessionResponses, LoadAgentModelsHandlerData, LoadAgentModelsHandlerErrors, LoadAgentModelsHandlerResponse, LoadAgentModelsHandlerResponses, LoadAgentModelsRequest, LoadAgentModelsResponse, LogDirectionDto, LogFrameDto, ModelHarnessDto, PreviewAgentSessionsData, PreviewAgentSessionsError, PreviewAgentSessionsErrors, PreviewAgentSessionsRequest, PreviewAgentSessionsResponse, PreviewAgentSessionsResponse2, PreviewAgentSessionsResponses, PutAgentSandboxSizeData, PutAgentSandboxSizeError, PutAgentSandboxSizeErrors, PutAgentSandboxSizeResponse, PutAgentSandboxSizeResponses, PutAgentSessionSandboxSizeData, PutAgentSessionSandboxSizeError, PutAgentSessionSandboxSizeErrors, PutAgentSessionSandboxSizeResponse, PutAgentSessionSandboxSizeResponses, QueuedActionDto, RemoveQueuedActionData, RemoveQueuedActionError, RemoveQueuedActionErrors, RemoveQueuedActionResponse, RemoveQueuedActionResponses, RenameAgentSessionData, RenameAgentSessionError, RenameAgentSessionErrors, RenameAgentSessionRequest, RenameAgentSessionResponse, RenameAgentSessionResponses, SandboxSize, SandboxSizeBody, SessionBot, SessionStatusDto, WithAgentSessionId } from './types.gen'; +export type { AgentAction, AgentActionId, AgentModelDto, AgentModelsStatusDto, AgentPromptAction, AgentRespondElicitationAction, AgentSessionLogEntryDto, AgentSessionLogResponse, AgentSessionPreviewData, AgentSessionPreviewDto, AgentSessionQueueResponse, AgentSessionResponse, AgentSetModelAction, BotId, ClientOptions, CompleteData, CompleteErrors, CompleteRequest, CompleteResponse, CompleteResponses, ControlAgentSessionData, ControlAgentSessionError, ControlAgentSessionErrors, ControlAgentSessionResponse, ControlAgentSessionResponses, ControlRequest, ControlResponse, ControlStatusDto, CreateAgentSessionData, CreateAgentSessionError, CreateAgentSessionErrors, CreateAgentSessionRequest, CreateAgentSessionResponse, CreateAgentSessionResponse2, CreateAgentSessionResponses, CreateSessionThread, DeleteAgentSessionData, DeleteAgentSessionError, DeleteAgentSessionErrors, DeleteAgentSessionResponses, DisconnectData, DisconnectErrors, DisconnectResponse, DisconnectResponses, EditQueuedActionData, EditQueuedActionError, EditQueuedActionErrors, EditQueuedActionRequest, EditQueuedActionResponse, EditQueuedActionResponses, ElicitationAnswer, ElicitationContentValue, ElicitationRequestId, EmptyRequest, ExternalSessionResponse, GetAgentSandboxSizeData, GetAgentSandboxSizeError, GetAgentSandboxSizeErrors, GetAgentSandboxSizeResponse, GetAgentSandboxSizeResponses, GetAgentSessionData, GetAgentSessionError, GetAgentSessionErrors, GetAgentSessionLogData, GetAgentSessionLogError, GetAgentSessionLogErrors, GetAgentSessionLogResponse, GetAgentSessionLogResponses, GetAgentSessionQueueData, GetAgentSessionQueueError, GetAgentSessionQueueErrors, GetAgentSessionQueueResponse, GetAgentSessionQueueResponses, GetAgentSessionResponse, GetAgentSessionResponses, LoadAgentModelsHandlerData, LoadAgentModelsHandlerErrors, LoadAgentModelsHandlerResponse, LoadAgentModelsHandlerResponses, LoadAgentModelsRequest, LoadAgentModelsResponse, LogDirectionDto, LogFrameDto, ModelHarnessDto, PreviewAgentSessionsData, PreviewAgentSessionsError, PreviewAgentSessionsErrors, PreviewAgentSessionsRequest, PreviewAgentSessionsResponse, PreviewAgentSessionsResponse2, PreviewAgentSessionsResponses, PutAgentSandboxSizeData, PutAgentSandboxSizeError, PutAgentSandboxSizeErrors, PutAgentSandboxSizeResponse, PutAgentSandboxSizeResponses, PutAgentSessionSandboxSizeData, PutAgentSessionSandboxSizeError, PutAgentSessionSandboxSizeErrors, PutAgentSessionSandboxSizeResponse, PutAgentSessionSandboxSizeResponses, QueuedActionDto, RemoveQueuedActionData, RemoveQueuedActionError, RemoveQueuedActionErrors, RemoveQueuedActionResponse, RemoveQueuedActionResponses, RenameAgentSessionData, RenameAgentSessionError, RenameAgentSessionErrors, RenameAgentSessionRequest, RenameAgentSessionResponse, RenameAgentSessionResponses, SandboxSize, SandboxSizeBody, SessionBot, SessionStatusDto, StartData, StartErrors, StartResponse, StartResponse2, StartResponses, StatusData, StatusErrors, StatusResponse, StatusResponse2, StatusResponses, WithAgentSessionId } from './types.gen'; diff --git a/packages/sdk/generated/agent-harness/sdk.gen.ts b/packages/sdk/generated/agent-harness/sdk.gen.ts index f3de384bb1e..637ee47cbfa 100644 --- a/packages/sdk/generated/agent-harness/sdk.gen.ts +++ b/packages/sdk/generated/agent-harness/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { ControlAgentSessionData, ControlAgentSessionErrors, ControlAgentSessionResponses, CreateAgentSessionData, CreateAgentSessionErrors, CreateAgentSessionResponses, DeleteAgentSessionData, DeleteAgentSessionErrors, DeleteAgentSessionResponses, EditQueuedActionData, EditQueuedActionErrors, EditQueuedActionResponses, GetAgentSandboxSizeData, GetAgentSandboxSizeErrors, GetAgentSandboxSizeResponses, GetAgentSessionData, GetAgentSessionErrors, GetAgentSessionLogData, GetAgentSessionLogErrors, GetAgentSessionLogResponses, GetAgentSessionQueueData, GetAgentSessionQueueErrors, GetAgentSessionQueueResponses, GetAgentSessionResponses, LoadAgentModelsHandlerData, LoadAgentModelsHandlerErrors, LoadAgentModelsHandlerResponses, PreviewAgentSessionsData, PreviewAgentSessionsErrors, PreviewAgentSessionsResponses, PutAgentSandboxSizeData, PutAgentSandboxSizeErrors, PutAgentSandboxSizeResponses, PutAgentSessionSandboxSizeData, PutAgentSessionSandboxSizeErrors, PutAgentSessionSandboxSizeResponses, RemoveQueuedActionData, RemoveQueuedActionErrors, RemoveQueuedActionResponses, RenameAgentSessionData, RenameAgentSessionErrors, RenameAgentSessionResponses } from './types.gen'; +import type { CompleteData, CompleteErrors, CompleteResponses, ControlAgentSessionData, ControlAgentSessionErrors, ControlAgentSessionResponses, CreateAgentSessionData, CreateAgentSessionErrors, CreateAgentSessionResponses, DeleteAgentSessionData, DeleteAgentSessionErrors, DeleteAgentSessionResponses, DisconnectData, DisconnectErrors, DisconnectResponses, EditQueuedActionData, EditQueuedActionErrors, EditQueuedActionResponses, GetAgentSandboxSizeData, GetAgentSandboxSizeErrors, GetAgentSandboxSizeResponses, GetAgentSessionData, GetAgentSessionErrors, GetAgentSessionLogData, GetAgentSessionLogErrors, GetAgentSessionLogResponses, GetAgentSessionQueueData, GetAgentSessionQueueErrors, GetAgentSessionQueueResponses, GetAgentSessionResponses, LoadAgentModelsHandlerData, LoadAgentModelsHandlerErrors, LoadAgentModelsHandlerResponses, PreviewAgentSessionsData, PreviewAgentSessionsErrors, PreviewAgentSessionsResponses, PutAgentSandboxSizeData, PutAgentSandboxSizeErrors, PutAgentSandboxSizeResponses, PutAgentSessionSandboxSizeData, PutAgentSessionSandboxSizeErrors, PutAgentSessionSandboxSizeResponses, RemoveQueuedActionData, RemoveQueuedActionErrors, RemoveQueuedActionResponses, RenameAgentSessionData, RenameAgentSessionErrors, RenameAgentSessionResponses, StartData, StartErrors, StartResponses, StatusData, StatusErrors, StatusResponses } from './types.gen'; export type Options = Options2 & { /** @@ -233,4 +233,60 @@ export class Sdk extends HeyApiClient { } }); } + + /** + * Forget only the authenticated user's grant and cancel pending consent. + */ + public disconnect(options: Options): RequestResult { + return (options.client ?? this.client).delete({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/claude-auth', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Read connection status for the authenticated user only. + */ + public status(options?: Options): RequestResult { + return (options?.client ?? this.client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/claude-auth', + ...options + }); + } + + /** + * Exchange one code; never return access or refresh tokens. + */ + public complete(options: Options): RequestResult { + return (options.client ?? this.client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/claude-auth/complete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Create an expiring PKCE challenge for the authenticated user. + */ + public start(options: Options): RequestResult { + return (options.client ?? this.client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/claude-auth/start', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } } diff --git a/packages/sdk/generated/agent-harness/types.gen.ts b/packages/sdk/generated/agent-harness/types.gen.ts index 04bb26307fd..02753a1f7da 100644 --- a/packages/sdk/generated/agent-harness/types.gen.ts +++ b/packages/sdk/generated/agent-harness/types.gen.ts @@ -313,6 +313,20 @@ export type AgentSetModelAction = { export type BotId = string; +/** + * One-time manual code. Deliberately does not implement Debug. + */ +export type CompleteRequest = { + /** + * Server-issued handle; never an owner selected by the caller. + */ + attemptId: string; + /** + * Claude's complete code#state string, not an access token. + */ + code: string; +}; + /** * The operation to perform. */ @@ -492,6 +506,13 @@ export type ElicitationContentValue = string | boolean | number | number | Array */ export type ElicitationRequestId = number | string; +/** + * A JSON body is required on writes, including start/disconnect (no form-based CSRF). + */ +export type EmptyRequest = { + [key: string]: never; +}; + /** * The provider-side identity of an externally-served session. */ @@ -573,7 +594,7 @@ export type LogFrameDto = { /** * Harness names accepted by the model discovery endpoint. */ -export type ModelHarnessDto = 'in-memory' | 'cursor' | 'macrod'; +export type ModelHarnessDto = 'in-memory' | 'cursor' | 'claude-cloud' | 'macrod'; /** * Request body for `POST /agent-sessions/preview`. @@ -698,6 +719,42 @@ export type SessionStatusDto = { kind: 'disconnected'; }; +/** + * Public PKCE challenge and attempt handle; contains no verifier or provider tokens. + */ +export type StartResponse = { + /** + * Opaque owner-bound attempt handle. + */ + attemptId: string; + /** + * Claude-hosted consent page. + */ + authorizationUrl: string; + /** + * Attempt lifetime in seconds. + */ + expiresIn: number; +}; + +/** + * Safe connection metadata. + */ +export type StatusResponse = { + /** + * Whether the authenticated Macro user has connected. + */ + connected: boolean; + /** + * Whether this deployment supports browser connection. + */ + enabled: boolean; + /** + * Whether reconnecting after service restart is required. + */ + ephemeral: boolean; +}; + /** * Just a session id, for the preview variants that carry nothing else. * @@ -1096,3 +1153,101 @@ export type PutAgentSessionSandboxSizeResponses = { }; export type PutAgentSessionSandboxSizeResponse = PutAgentSessionSandboxSizeResponses[keyof PutAgentSessionSandboxSizeResponses]; + +export type DisconnectData = { + body: EmptyRequest; + path?: never; + query?: never; + url: '/claude-auth'; +}; + +export type DisconnectErrors = { + /** + * Disabled + */ + 403: unknown; +}; + +export type DisconnectResponses = { + /** + * Disconnected + */ + 204: void; +}; + +export type DisconnectResponse = DisconnectResponses[keyof DisconnectResponses]; + +export type StatusData = { + body?: never; + path?: never; + query?: never; + url: '/claude-auth'; +}; + +export type StatusErrors = { + /** + * Unauthenticated + */ + 401: unknown; +}; + +export type StatusResponses = { + 200: StatusResponse; +}; + +export type StatusResponse2 = StatusResponses[keyof StatusResponses]; + +export type CompleteData = { + body: CompleteRequest; + path?: never; + query?: never; + url: '/claude-auth/complete'; +}; + +export type CompleteErrors = { + /** + * Invalid code + */ + 400: unknown; + /** + * Expired or replayed + */ + 409: unknown; + /** + * Provider failed + */ + 502: unknown; +}; + +export type CompleteResponses = { + /** + * Connected + */ + 204: void; +}; + +export type CompleteResponse = CompleteResponses[keyof CompleteResponses]; + +export type StartData = { + body: EmptyRequest; + path?: never; + query?: never; + url: '/claude-auth/start'; +}; + +export type StartErrors = { + /** + * Disabled + */ + 403: unknown; + /** + * Too many attempts + */ + 429: unknown; +}; + +export type StartResponses = { + 200: StartResponse; +}; + +export type StartResponse2 = StartResponses[keyof StartResponses]; diff --git a/packages/sdk/specs/agent-harness.json b/packages/sdk/specs/agent-harness.json index b7314f3abae..f362574a52a 100644 --- a/packages/sdk/specs/agent-harness.json +++ b/packages/sdk/specs/agent-harness.json @@ -10,145 +10,6 @@ "version": "0.1.0" }, "paths": { - "/claude-auth": { - "get": { - "tags": [ - "claude-auth" - ], - "summary": "Read connection status for the authenticated user only.", - "operationId": "status", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - } - }, - "401": { - "description": "Unauthenticated" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "claude-auth" - ], - "summary": "Forget only the authenticated user's grant and cancel pending consent.", - "operationId": "disconnect", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmptyRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Disconnected" - }, - "403": { - "description": "Disabled" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/claude-auth/complete": { - "post": { - "tags": [ - "claude-auth" - ], - "summary": "Exchange one code; never return access or refresh tokens.", - "operationId": "complete", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompleteRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Connected" - }, - "400": { - "description": "Invalid code" - }, - "409": { - "description": "Expired or replayed" - }, - "502": { - "description": "Provider failed" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/claude-auth/start": { - "post": { - "tags": [ - "claude-auth" - ], - "summary": "Create an expiring PKCE challenge for the authenticated user.", - "operationId": "start", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmptyRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartResponse" - } - } - } - }, - "403": { - "description": "Disabled" - }, - "429": { - "description": "Too many attempts" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "/agent-models/load": { "post": { "tags": ["agent-models"], @@ -1055,82 +916,141 @@ } } } - } - }, - "components": { - "schemas": { - "StatusResponse": { - "type": "object", - "description": "Safe connection metadata.", - "required": [ - "enabled", - "connected", - "ephemeral" - ], - "properties": { - "connected": { - "type": "boolean", - "description": "Whether the authenticated Macro user has connected." - }, - "enabled": { - "type": "boolean", - "description": "Whether this deployment supports browser connection." + }, + "/claude-auth": { + "get": { + "tags": ["claude-auth"], + "summary": "Read connection status for the authenticated user only.", + "operationId": "status", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } }, - "ephemeral": { - "type": "boolean", - "description": "Whether reconnecting after service restart is required." + "401": { + "description": "Unauthenticated" } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] }, - "StartResponse": { - "type": "object", - "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", - "required": [ - "attemptId", - "authorizationUrl", - "expiresIn" - ], - "properties": { - "attemptId": { - "type": "string", - "description": "Opaque owner-bound attempt handle." + "delete": { + "tags": ["claude-auth"], + "summary": "Forget only the authenticated user's grant and cancel pending consent.", + "operationId": "disconnect", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } }, - "authorizationUrl": { - "type": "string", - "description": "Claude-hosted consent page." + "required": true + }, + "responses": { + "204": { + "description": "Disconnected" }, - "expiresIn": { - "type": "integer", - "format": "int64", - "description": "Attempt lifetime in seconds.", - "minimum": 0 + "403": { + "description": "Disabled" } - } - }, - "CompleteRequest": { - "type": "object", - "description": "One-time manual code. Deliberately does not implement Debug.", - "required": [ - "attemptId", - "code" - ], - "properties": { - "attemptId": { - "type": "string", - "description": "Server-issued handle; never an owner selected by the caller." + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/complete": { + "post": { + "tags": ["claude-auth"], + "summary": "Exchange one code; never return access or refresh tokens.", + "operationId": "complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteRequest" + } + } }, - "code": { - "type": "string", - "description": "Claude's complete code#state string, not an access token." + "required": true + }, + "responses": { + "204": { + "description": "Connected" + }, + "400": { + "description": "Invalid code" + }, + "409": { + "description": "Expired or replayed" + }, + "502": { + "description": "Provider failed" } }, - "additionalProperties": false - }, - "EmptyRequest": { - "type": "object", - "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", - "additionalProperties": false - }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/claude-auth/start": { + "post": { + "tags": ["claude-auth"], + "summary": "Create an expiring PKCE challenge for the authenticated user.", + "operationId": "start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartResponse" + } + } + } + }, + "403": { + "description": "Disabled" + }, + "429": { + "description": "Too many attempts" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { "AgentAction": { "oneOf": [ { @@ -1587,6 +1507,22 @@ "BotId": { "type": "string" }, + "CompleteRequest": { + "type": "object", + "description": "One-time manual code. Deliberately does not implement Debug.", + "required": ["attemptId", "code"], + "properties": { + "attemptId": { + "type": "string", + "description": "Server-issued handle; never an owner selected by the caller." + }, + "code": { + "type": "string", + "description": "Claude's complete code#state string, not an access token." + } + }, + "additionalProperties": false + }, "ControlRequest": { "allOf": [ { @@ -1799,6 +1735,11 @@ ], "description": "The JSON-RPC id of an agent's `elicitation/create` request, carried whole\nso the answer echoes exactly what the agent sent.\n\nAgents pick these, not us: Claude Code counts from `0`, others use\nstrings. `null` is not a legal id for a request that expects a response,\nso it is not representable here." }, + "EmptyRequest": { + "type": "object", + "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).", + "additionalProperties": false + }, "ExternalSessionResponse": { "type": "object", "description": "The provider-side identity of an externally-served session.", @@ -2030,6 +1971,46 @@ ], "description": "Transport representation of a session's status, mirroring\n[`SessionStatus`]." }, + "StartResponse": { + "type": "object", + "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.", + "required": ["attemptId", "authorizationUrl", "expiresIn"], + "properties": { + "attemptId": { + "type": "string", + "description": "Opaque owner-bound attempt handle." + }, + "authorizationUrl": { + "type": "string", + "description": "Claude-hosted consent page." + }, + "expiresIn": { + "type": "integer", + "format": "int64", + "description": "Attempt lifetime in seconds.", + "minimum": 0 + } + } + }, + "StatusResponse": { + "type": "object", + "description": "Safe connection metadata.", + "required": ["enabled", "connected", "ephemeral"], + "properties": { + "connected": { + "type": "boolean", + "description": "Whether the authenticated Macro user has connected." + }, + "enabled": { + "type": "boolean", + "description": "Whether this deployment supports browser connection." + }, + "ephemeral": { + "type": "boolean", + "description": "Whether reconnecting after service restart is required." + } + } + }, "WithAgentSessionId": { "type": "object", "description": "Just a session id, for the preview variants that carry nothing else.\n\nClients deserialize this, so both derives are used.", diff --git a/packages/sdk/src/coverage/skipped.ts b/packages/sdk/src/coverage/skipped.ts index ecb67d0e15b..c5fd9afc19b 100644 --- a/packages/sdk/src/coverage/skipped.ts +++ b/packages/sdk/src/coverage/skipped.ts @@ -24,6 +24,11 @@ import type { Sdk as StorageSdk } from '../../generated/storage/sdk.gen'; import type { Sdk as UnfurlSdk } from '../../generated/unfurl/sdk.gen'; export const agentHarnessExcluded = [ + // Claude Cloud sign-in is an app-internal auth flow, not SDK surface. + 'complete', + 'disconnect', + 'start', + 'status', 'loadAgentModelsHandler', 'previewAgentSessions', ] as const satisfies readonly (keyof AgentHarnessSdk)[]; From e408ac9918084e5fc52370eeeee5a71009968c3d Mon Sep 17 00:00:00 2001 From: Eric Hayes Date: Wed, 16 Sep 2026 19:15:05 -0400 Subject: [PATCH 5/5] refactor(agents): integrate Claude Cloud with shared harness ports --- .github/workspace-dep-closures.json | 1 + Cargo.lock | 2 + crates/agent_harness/Cargo.toml | 1 + crates/agent_harness/src/domain/claude.rs | 155 +++++++++ .../agent_harness/src/domain/claude/test.rs | 215 +++++++++++++ crates/agent_harness/src/domain/mod.rs | 3 + crates/agent_harness/src/outbound/claude.rs | 125 ++++++++ crates/agent_harness/src/outbound/mod.rs | 3 + crates/agent_harness/src/outbound/routing.rs | 88 ++++-- .../src/outbound/routing/test.rs | 49 ++- crates/claude_cloud_agents/Cargo.toml | 1 + crates/claude_cloud_agents/src/domain/mcp.rs | 28 ++ crates/claude_cloud_agents/src/domain/mod.rs | 1 + .../claude_cloud_agents/src/domain/model.rs | 5 + .../claude_cloud_agents/src/domain/ports.rs | 35 ++ .../claude_cloud_agents/src/domain/service.rs | 89 +++++- crates/claude_cloud_agents/src/inbound/acp.rs | 34 +- .../src/inbound/acp/mcp.rs | 46 +++ .../src/inbound/acp/permissions.rs | 77 +++++ .../src/inbound/acp/test.rs | 2 + .../src/inbound/acp/test/mcp.rs | 233 ++++++++++++++ .../claude_cloud_agents/src/outbound/http.rs | 29 +- docs/AGENT_GUIDE/navigation.md | 6 + docs/CLAUDE_CLOUD_DEMO.md | 29 +- .../agent_harness_service/src/claude_cloud.rs | 299 ------------------ services/agent_harness_service/src/main.rs | 20 +- 26 files changed, 1233 insertions(+), 343 deletions(-) create mode 100644 crates/agent_harness/src/domain/claude.rs create mode 100644 crates/agent_harness/src/domain/claude/test.rs create mode 100644 crates/agent_harness/src/outbound/claude.rs create mode 100644 crates/claude_cloud_agents/src/domain/mcp.rs create mode 100644 crates/claude_cloud_agents/src/inbound/acp/mcp.rs create mode 100644 crates/claude_cloud_agents/src/inbound/acp/permissions.rs create mode 100644 crates/claude_cloud_agents/src/inbound/acp/test/mcp.rs delete mode 100644 services/agent_harness_service/src/claude_cloud.rs diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index 059eaa0f641..9515820d779 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -164,6 +164,7 @@ "crates/broadcast", "crates/channel_sender", "crates/channels", + "crates/claude_cloud_agents", "crates/codex_cloud_agents", "crates/codex_connection", "crates/comms_db_client", diff --git a/Cargo.lock b/Cargo.lock index 5f9d9ad985b..1d397123b4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,7 @@ dependencies = [ "channel_sender", "channels", "chrono", + "claude_cloud_agents", "codex_cloud_agents", "codex_connection", "cursor_api_key", @@ -3619,6 +3620,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "claude_cloud_agents" version = "0.1.0" dependencies = [ + "agent-client-protocol", "agent_runtime_protocol", "async-trait", "axum", diff --git a/crates/agent_harness/Cargo.toml b/crates/agent_harness/Cargo.toml index ba42d0510fc..bbfed59cb47 100644 --- a/crates/agent_harness/Cargo.toml +++ b/crates/agent_harness/Cargo.toml @@ -5,6 +5,7 @@ publish = false version = "0.1.0" [dependencies] +claude_cloud_agents = { path = "../claude_cloud_agents" } async-trait = { workspace = true } codex_cloud_agents = { path = "../codex_cloud_agents", features = ["postgres"] } codex_connection = { path = "../codex_connection" } diff --git a/crates/agent_harness/src/domain/claude.rs b/crates/agent_harness/src/domain/claude.rs new file mode 100644 index 00000000000..5157ff55805 --- /dev/null +++ b/crates/agent_harness/src/domain/claude.rs @@ -0,0 +1,155 @@ +//! Claude conversation lifecycle through owner-bound provider and session ports. +use crate::domain::error::{HarnessError, Result}; +use agent_session::domain::{ + model::{AgentSessionId, ExternalSession}, + ports::{AgentSessionRepo, ExternalSessionRepo}, +}; +use claude_cloud_agents::domain::{ + model::{Error, SessionId}, + ports::{CloudLifecycle, CloudProvider}, + service::Session, +}; +use std::sync::Arc; + +/// Provider identity in the shared external-session store. +pub const PROVIDER: &str = "claude-cloud"; +const PENDING: &str = "claude-cloud-create-pending"; + +fn cloud_error(error: Error) -> HarnessError { + HarnessError::Container(error.to_string()) +} + +/// Lifecycle policy shared by every agent configured with the Claude harness. +pub struct ClaudeSessions { + provider: Arc

, + repo: Repo, + external: External, + creation: tokio::sync::Mutex<()>, +} + +impl + ClaudeSessions +{ + /// Mint a fresh egress credential on reattach; persist only its hash, as for + /// other external runtimes. The saved agent selection remains authoritative. + pub async fn refresh_egress( + &self, + id: AgentSessionId, + provisioner: &impl crate::domain::ports::SandboxEgressProvisioner, + ) -> Result { + let row = self.repo.get(id).await?; + let egress = provisioner + .provision(id, &row.owner_id, &row.mcp_servers) + .await?; + self.repo + .set_egress_token_hash(id, &egress.session_token_hash) + .await?; + Ok(egress.sandbox.session_token) + } + + /// Wire the provider and the owning session repository at the composition root. + pub fn new(provider: Arc

, repo: Repo, external: External) -> Self { + Self { + provider, + repo, + external, + creation: tokio::sync::Mutex::new(()), + } + } + + /// Archive the owner's remote conversation before removing its mapping. + pub async fn archive(&self, id: AgentSessionId) -> Result<()> { + let _creation = self.creation.lock().await; + let Some(external) = ExternalSessionRepo::get(&self.external, id).await? else { + return Ok(()); + }; + if external.provider != PROVIDER { + return Err(cloud_error(Error::UncertainCreate)); + } + let row = AgentSessionRepo::get(&self.repo, id).await?; + self.provider + .connect(row.owner_id.as_ref()) + .await + .map_err(cloud_error)? + .archive(&SessionId::parse(&external.external_id).map_err(cloud_error)?) + .await + .map_err(cloud_error)?; + ExternalSessionRepo::delete(&self.external, id).await?; + Ok(()) + } + /// Resolve or create a durable conversation for the session owner. + pub async fn attach(&self, id: AgentSessionId) -> Result>> { + let _creation = self.creation.lock().await; + let row = AgentSessionRepo::get(&self.repo, id).await?; + let client = self + .provider + .connect(row.owner_id.as_ref()) + .await + .map_err(cloud_error)?; + let external = ExternalSessionRepo::get(&self.external, id).await?; + let cloud_id = match external { + Some(mut external) if external.provider == PROVIDER => { + let cloud_id = SessionId::parse(&external.external_id).map_err(cloud_error)?; + if external.external_url.is_none() { + external.external_url = Some(cloud_id.web_url()); + self.external.upsert(id, external).await?; + } + cloud_id + } + Some(_) => return Err(cloud_error(Error::UncertainCreate)), + None => { + // Write a durable intent before the non-idempotent POST. A crash or + // timeout stays visibly pending rather than minting a duplicate VM. + self.external + .upsert( + id, + ExternalSession { + provider: PENDING.into(), + external_id: id.to_string(), + external_name: None, + external_url: None, + last_run_id: None, + }, + ) + .await?; + let cloud_id = match client + .create(row.instructions.as_deref().unwrap_or_default()) + .await + { + Ok(id) => id, + Err(error) => { + // Only a definite client rejection is safe to retry as a new create. + if matches!( + error, + Error::Authorization | Error::NotConnected | Error::Http(400..=499) + ) { + ExternalSessionRepo::delete(&self.external, id).await?; + } + return Err(cloud_error(error)); + } + }; + self.external + .upsert( + id, + ExternalSession { + provider: PROVIDER.into(), + external_id: cloud_id.as_str().to_owned(), + external_name: Some("Claude Cloud".into()), + external_url: Some(cloud_id.web_url()), + last_run_id: None, + }, + ) + .await?; + cloud_id + } + }; + Ok(Session::with_model( + client, + cloud_id, + claude_cloud_agents::domain::models::Model::parse(&row.model).map_err(cloud_error)?, + )) + } +} + +#[cfg(test)] +mod test; diff --git a/crates/agent_harness/src/domain/claude/test.rs b/crates/agent_harness/src/domain/claude/test.rs new file mode 100644 index 00000000000..b5720828a1d --- /dev/null +++ b/crates/agent_harness/src/domain/claude/test.rs @@ -0,0 +1,215 @@ +use super::*; +use agent_session::domain::{ + model::{AgentMcpServers, CreateAgentSessionParams, SandboxSize}, + ports::MockExternalSessionRepo, +}; +use agent_session::testing::InMemoryAgentSessionRepo; +use claude_cloud_agents::domain::{ + model::Event, + ports::{Cloud, Events}, +}; +use std::collections::HashMap; +use std::sync::Mutex; + +#[derive(Default)] +struct Provider { + calls: Arc>>, + uncertain: bool, +} +#[derive(Clone)] +struct Client { + owner: String, + calls: Arc>>, + uncertain: bool, +} +impl CloudProvider for Provider { + type Client = Client; + async fn connect(&self, owner: &str) -> claude_cloud_agents::domain::model::Result { + Ok(Client { + owner: owner.into(), + calls: self.calls.clone(), + uncertain: self.uncertain, + }) + } +} +impl CloudLifecycle for Client { + async fn create( + &self, + instructions: &str, + ) -> claude_cloud_agents::domain::model::Result { + self.calls + .lock() + .unwrap() + .push((self.owner.clone(), instructions.into())); + if self.uncertain { + return Err(Error::UncertainCreate); + } + SessionId::parse(&format!("cse_{}", self.calls.lock().unwrap().len())) + } + async fn archive(&self, _: &SessionId) -> claude_cloud_agents::domain::model::Result<()> { + Ok(()) + } +} +impl Cloud for Client { + async fn recent_sessions(&self) -> claude_cloud_agents::domain::model::Result> { + Ok(vec![]) + } + async fn history( + &self, + _: &SessionId, + ) -> claude_cloud_agents::domain::model::Result> { + Ok(vec![]) + } + async fn send( + &self, + _: &SessionId, + _: serde_json::Value, + ) -> claude_cloud_agents::domain::model::Result<()> { + unreachable!() + } + async fn send_batch( + &self, + _: &SessionId, + _: Vec, + ) -> claude_cloud_agents::domain::model::Result<()> { + unreachable!() + } + async fn stream( + &self, + _: &SessionId, + _: Option, + ) -> claude_cloud_agents::domain::model::Result { + unreachable!() + } +} + +fn mappings() -> MockExternalSessionRepo { + let data = Arc::new(Mutex::new(HashMap::::new())); + let mut repo = MockExternalSessionRepo::new(); + let records = data.clone(); + repo.expect_get().returning(move |id| { + let found = records.lock().unwrap().get(&id).cloned(); + Box::pin(async move { Ok(found) }) + }); + let records = data.clone(); + repo.expect_upsert().returning(move |id, external| { + records.lock().unwrap().insert(id, external); + Box::pin(async { Ok(()) }) + }); + repo.expect_delete().returning(move |id| { + data.lock().unwrap().remove(&id); + Box::pin(async { Ok(()) }) + }); + repo +} + +async fn seed( + repo: &InMemoryAgentSessionRepo, + bot: bot_id::BotId, + owner: &str, + instructions: &str, +) -> AgentSessionId { + let id = AgentSessionId::new(); + repo.create(CreateAgentSessionParams { + id, + bot_id: bot, + owner_id: macro_user_id::user_id::MacroUserIdStr::try_from(owner.to_owned()).unwrap(), + thread_id: None, + originating_message_id: None, + model: "claude-default".into(), + harness: "claude-cloud".into(), + repo_url: None, + workspace: "/".into(), + sandbox_size: SandboxSize::Default, + instructions: Some(instructions.into()), + mcp_servers: AgentMcpServers::OwnerConnections, + egress_token_hash: None, + }) + .await + .unwrap(); + id +} + +#[tokio::test] +async fn distinct_agents_keep_their_owner_instructions_model_and_remote_session() { + let repo = InMemoryAgentSessionRepo::new(); + let first = seed( + &repo, + bot_id::BotId::TEST_A, + "macro|one@example.com", + "Review code", + ) + .await; + let second = seed( + &repo, + bot_id::BotId::TEST_B, + "macro|two@example.com", + "Fix tests", + ) + .await; + let provider = Arc::new(Provider::default()); + let sessions = ClaudeSessions::new(provider.clone(), repo, mappings()); + let one = sessions.attach(first).await.unwrap(); + let two = sessions.attach(second).await.unwrap(); + assert_ne!(one.id(), two.id()); + assert_eq!(one.model().await.id(), "claude-default"); + assert_eq!(sessions.attach(first).await.unwrap().id(), one.id()); + assert_eq!( + *provider.calls.lock().unwrap(), + vec![ + ("macro|one@example.com".into(), "Review code".into()), + ("macro|two@example.com".into(), "Fix tests".into()), + ] + ); +} + +#[tokio::test] +async fn uncertain_create_remains_pending_and_is_never_retried() { + let repo = InMemoryAgentSessionRepo::new(); + let id = seed( + &repo, + bot_id::BotId::TEST_A, + "macro|one@example.com", + "Review code", + ) + .await; + let provider = Arc::new(Provider { + uncertain: true, + ..Default::default() + }); + let sessions = ClaudeSessions::new(provider.clone(), repo, mappings()); + assert!(sessions.attach(id).await.is_err()); + assert!(sessions.attach(id).await.is_err()); + assert_eq!(provider.calls.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn reattach_restores_egress_with_the_saved_agent_selection() { + use crate::testing::helpers::egress::EgressProvisionerMock; + let repo = InMemoryAgentSessionRepo::new(); + let id = seed( + &repo, + bot_id::BotId::TEST_A, + "macro|one@example.com", + "Review code", + ) + .await; + let sessions = ClaudeSessions::new(Arc::new(Provider::default()), repo.clone(), mappings()); + let egress = EgressProvisionerMock::default(); + let token = sessions.refresh_egress(id, &egress).await.unwrap(); + assert_eq!(token, "test-session-token"); + assert_eq!( + egress.provisioned(), + vec![( + id, + "macro|one@example.com".into(), + AgentMcpServers::OwnerConnections + )] + ); + assert!( + repo.find_by_egress_token_hash("test-token-hash") + .await + .unwrap() + .is_some() + ); +} diff --git a/crates/agent_harness/src/domain/mod.rs b/crates/agent_harness/src/domain/mod.rs index 3f0d2d41d9c..f78a6eaa8f7 100644 --- a/crates/agent_harness/src/domain/mod.rs +++ b/crates/agent_harness/src/domain/mod.rs @@ -19,3 +19,6 @@ pub mod trigger_router; /// Per-owner hosted Codex runtime authorization. pub mod codex; + +/// Owner-bound Claude conversation lifecycle. +pub mod claude; diff --git a/crates/agent_harness/src/outbound/claude.rs b/crates/agent_harness/src/outbound/claude.rs new file mode 100644 index 00000000000..c6aa4820945 --- /dev/null +++ b/crates/agent_harness/src/outbound/claude.rs @@ -0,0 +1,125 @@ +//! Claude implementations of the shared container and model-discovery ports. +use crate::domain::{ + claude::ClaudeSessions, + error::{HarnessError, Result}, + model::SpawnContainer, + model_load::{ClaudeModelProbe, ModelProbeError, RawModelProbe}, + ports::{ContainerManager, SandboxEgressProvisioner}, + sandbox::SandboxResizeEffect, +}; +use agent_runtime_protocol::domain::connection::ServerChannel; +use agent_session::domain::{ + connection::RuntimeAttachment, + model::{AgentSessionId, SandboxSize}, + ports::{AgentSessionRepo, ExternalSessionRepo}, +}; +use claude_cloud_agents::domain::{model::Error, ports::CloudProvider, service::Session}; +use std::sync::Arc; + +/// Adapts the Claude lifecycle service to the same provisioning port as other providers. +pub struct ClaudeContainerManager { + sessions: ClaudeSessions, + egress: Egress, + attach: Attach, + tokens: tokio::sync::RwLock>, +} + +impl ClaudeContainerManager { + /// Supply the ACP adapter from the composition root, keeping adapter imports local. + pub fn new( + sessions: ClaudeSessions, + egress: Egress, + attach: Attach, + ) -> Self { + Self { + sessions, + egress, + attach, + tokens: Default::default(), + } + } +} + +impl ContainerManager + for ClaudeContainerManager +where + P: CloudProvider, + Repo: AgentSessionRepo, + External: ExternalSessionRepo, + Egress: SandboxEgressProvisioner, + Attach: Fn(Arc>) -> ServerChannel + Send + Sync + 'static, +{ + type Transport = ServerChannel; + + async fn spawn(&self, command: SpawnContainer) -> Result> { + let session = self.sessions.attach(command.session_id).await?; + self.tokens + .write() + .await + .insert(command.session_id, command.egress.session_token); + Ok(RuntimeAttachment::solo((self.attach)(session))) + } + + async fn resume(&self, id: AgentSessionId) -> Result> { + let session = self.sessions.attach(id).await?; + let token = self.sessions.refresh_egress(id, &self.egress).await?; + self.tokens.write().await.insert(id, token); + Ok(RuntimeAttachment::solo((self.attach)(session))) + } + + async fn session_token(&self, id: AgentSessionId) -> Result> { + Ok(self.tokens.read().await.get(&id).cloned()) + } + + async fn teardown(&self, id: AgentSessionId) -> Result<()> { + self.sessions.archive(id).await?; + self.tokens.write().await.remove(&id); + Ok(()) + } + + fn resize_effect(&self, _: SandboxSize, _: SandboxSize) -> SandboxResizeEffect { + SandboxResizeEffect::Unsupported + } + + async fn resize(&self, _: AgentSessionId, _: SandboxSize) -> Result<()> { + Err(HarnessError::Container( + "Claude manages its own cloud compute".into(), + )) + } +} + +/// Read-only discovery through the same account-bound provider as execution. +pub struct ClaudeModels

(pub Arc

); + +impl ClaudeModelProbe for ClaudeModels

{ + fn probe<'a>( + &'a self, + caller: &'a macro_user_id::user_id::MacroUserIdStr<'static>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + let client = match self.0.connect(caller.as_ref()).await { + Ok(client) => client, + Err(Error::NotConnected) => return Ok(RawModelProbe::Unsupported), + Err(error) => return Err(ModelProbeError::Failed(error.to_string())), + }; + let catalog = claude_cloud_agents::domain::models::discover(&client) + .await + .map_err(|error| ModelProbeError::Failed(error.to_string()))?; + let options: Vec<_> = catalog.options().iter().map(|option| serde_json::json!({ + "value": option.model.id(), "name": option.name, "description": option.description + })).collect(); + let options = serde_json::from_value(serde_json::json!([{ + "id":"model", "name":"Model", "category":"model", "type":"select", + "currentValue":"claude-default", "options": options + }])) + .map_err(|_| ModelProbeError::Failed("Invalid Claude model configuration".into()))?; + Ok(RawModelProbe::Options(options)) + }) + } +} diff --git a/crates/agent_harness/src/outbound/mod.rs b/crates/agent_harness/src/outbound/mod.rs index 0c48201206c..02c4d5c8e60 100644 --- a/crates/agent_harness/src/outbound/mod.rs +++ b/crates/agent_harness/src/outbound/mod.rs @@ -24,3 +24,6 @@ pub mod sidecar; pub mod acp_pipe; /// Per-owner Codex cloud runtime. pub mod codex; + +/// Claude cloud container and model adapters. +pub mod claude; diff --git a/crates/agent_harness/src/outbound/routing.rs b/crates/agent_harness/src/outbound/routing.rs index c1536bc1c86..f387a32c2cb 100644 --- a/crates/agent_harness/src/outbound/routing.rs +++ b/crates/agent_harness/src/outbound/routing.rs @@ -28,34 +28,46 @@ mod test; /// Dispatches each session to the provider its bot is served by. #[derive(Clone)] -pub struct RoutedContainerManager { +pub struct RoutedContainerManager { sandbox: Sandbox, cursor: Cursor, codex: Codex, + claude: Claude, sessions: Sessions, } -impl RoutedContainerManager { +impl + RoutedContainerManager +{ /// Wire the router over its providers. - pub fn new(sandbox: Sandbox, cursor: Cursor, codex: Codex, sessions: Sessions) -> Self { + pub fn new( + sandbox: Sandbox, + cursor: Cursor, + codex: Codex, + claude: Claude, + sessions: Sessions, + ) -> Self { Self { sandbox, cursor, codex, + claude, sessions, } } } -impl ContainerManager - for RoutedContainerManager +impl ContainerManager + for RoutedContainerManager where Sandbox: ContainerManager, Cursor: ContainerManager, Codex: ContainerManager, + Claude: ContainerManager, Sessions: AgentSessionRepo + Clone, { - type Transport = RoutedTransport; + type Transport = + RoutedTransport; async fn spawn( &self, @@ -67,6 +79,11 @@ where .spawn(command) .await? .map_transport(RoutedTransport::Codex)), + AgentKind::ClaudeCloud => Ok(self + .claude + .spawn(command) + .await? + .map_transport(RoutedTransport::Claude)), AgentKind::Cursor => Ok(self .cursor .spawn(command) @@ -77,7 +94,7 @@ where .spawn(command) .await? .map_transport(RoutedTransport::Sandbox)), - AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), + AgentKind::External => Err(external_is_unroutable()), } } @@ -92,6 +109,11 @@ where .resume(session) .await? .map_transport(RoutedTransport::Codex)), + AgentKind::ClaudeCloud => Ok(self + .claude + .resume(session) + .await? + .map_transport(RoutedTransport::Claude)), AgentKind::Cursor => Ok(self .cursor .resume(session) @@ -102,7 +124,7 @@ where .resume(session) .await? .map_transport(RoutedTransport::Sandbox)), - AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), + AgentKind::External => Err(external_is_unroutable()), } } @@ -110,11 +132,12 @@ where let row = self.sessions.get(session).await?; match AgentKind::for_session(row.bot_id, &row.harness) { AgentKind::CodexCloud => self.codex.session_token(session).await, + AgentKind::ClaudeCloud => self.claude.session_token(session).await, AgentKind::Cursor => self.cursor.session_token(session).await, AgentKind::SandboxedCoder | AgentKind::InMemory => { self.sandbox.session_token(session).await } - AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), + AgentKind::External => Err(external_is_unroutable()), } } @@ -122,9 +145,10 @@ where let row = self.sessions.get(session).await?; match AgentKind::for_session(row.bot_id, &row.harness) { AgentKind::CodexCloud => self.codex.teardown(session).await, + AgentKind::ClaudeCloud => self.claude.teardown(session).await, AgentKind::Cursor => self.cursor.teardown(session).await, AgentKind::SandboxedCoder | AgentKind::InMemory => self.sandbox.teardown(session).await, - AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), + AgentKind::External => Err(external_is_unroutable()), } } @@ -142,13 +166,14 @@ where AgentKind::CodexCloud => Err(HarnessError::Container( "a codex session has no sandbox to resize".to_owned(), )), + AgentKind::ClaudeCloud => self.claude.resize(session, size).await, AgentKind::Cursor => Err(HarnessError::Container( "a cursor session has no sandbox to resize".to_owned(), )), AgentKind::InMemory => Err(HarnessError::Container( "an in-memory session has no sandbox to resize".to_owned(), )), - AgentKind::External | AgentKind::ClaudeCloud => Err(external_is_unroutable()), + AgentKind::External => Err(external_is_unroutable()), } } } @@ -164,44 +189,52 @@ fn external_is_unroutable() -> HarnessError { } /// A transport that is one provider's or the other's, decided per session. -pub enum RoutedTransport { +pub enum RoutedTransport { /// A sandbox-provider transport. Sandbox(Sandbox), /// A Cursor-provider transport. Cursor(Cursor), /// A Codex cloud provider transport half. Codex(Codex), + /// A Claude cloud provider transport half. + Claude(Claude), } /// The sending half of a [`RoutedTransport`]. -pub enum RoutedSender { +pub enum RoutedSender { /// A sandbox-provider sender. Sandbox(Sandbox), /// A Cursor-provider sender. Cursor(Cursor), /// A Codex cloud provider transport half. Codex(Codex), + /// A Claude cloud provider transport half. + Claude(Claude), } /// The receiving half of a [`RoutedTransport`]. -pub enum RoutedReceiver { +pub enum RoutedReceiver { /// A sandbox-provider receiver. Sandbox(Sandbox), /// A Cursor-provider receiver. Cursor(Cursor), /// A Codex cloud provider transport half. Codex(Codex), + /// A Claude cloud provider transport half. + Claude(Claude), } -impl Transport - for RoutedTransport +impl Transport + for RoutedTransport where Sandbox: Transport, Cursor: Transport, Codex: Transport, + Claude: Transport, { - type Sender = RoutedSender; - type Receiver = RoutedReceiver; + type Sender = RoutedSender; + type Receiver = + RoutedReceiver; fn split(self) -> (Self::Sender, Self::Receiver) { match self { @@ -216,6 +249,13 @@ where let (sender, receiver) = transport.split(); (RoutedSender::Codex(sender), RoutedReceiver::Codex(receiver)) } + Self::Claude(transport) => { + let (sender, receiver) = transport.split(); + ( + RoutedSender::Claude(sender), + RoutedReceiver::Claude(receiver), + ) + } Self::Cursor(transport) => { let (sender, receiver) = transport.split(); ( @@ -227,34 +267,38 @@ where } } -impl TransportSender - for RoutedSender +impl TransportSender + for RoutedSender where Sandbox: TransportSender, Cursor: TransportSender, Codex: TransportSender, + Claude: TransportSender, { async fn send(&self, message: ToRuntimeMessage) -> std::result::Result<(), TransportError> { match self { Self::Sandbox(sender) => sender.send(message).await, Self::Cursor(sender) => sender.send(message).await, Self::Codex(sender) => sender.send(message).await, + Self::Claude(sender) => sender.send(message).await, } } } -impl TransportReceiver - for RoutedReceiver +impl TransportReceiver + for RoutedReceiver where Sandbox: TransportReceiver, Cursor: TransportReceiver, Codex: TransportReceiver, + Claude: TransportReceiver, { async fn recv(&mut self) -> std::result::Result, TransportError> { match self { Self::Sandbox(receiver) => receiver.recv().await, Self::Cursor(receiver) => receiver.recv().await, Self::Codex(receiver) => receiver.recv().await, + Self::Claude(receiver) => receiver.recv().await, } } } diff --git a/crates/agent_harness/src/outbound/routing/test.rs b/crates/agent_harness/src/outbound/routing/test.rs index e869383a185..47a6687ba52 100644 --- a/crates/agent_harness/src/outbound/routing/test.rs +++ b/crates/agent_harness/src/outbound/routing/test.rs @@ -99,6 +99,8 @@ impl ContainerManager for TaggedManager { #[derive(Clone)] struct FixedBotSessions(BotId); +const CLAUDE_TEST_BOT: BotId = BotId::TEST_B; + impl AgentSessionRepo for FixedBotSessions { async fn create(&self, _params: CreateAgentSessionParams) -> SessionResult { unimplemented!("the router never creates sessions") @@ -130,7 +132,12 @@ impl AgentSessionRepo for FixedBotSessions { originating_message_id: None, bot_id: self.0, model: "auto".to_owned(), - harness: "cursor".to_owned(), + harness: if self.0 == CLAUDE_TEST_BOT { + "claude-cloud" + } else { + "cursor" + } + .to_owned(), repo_url: None, workspace: "/workspace".to_owned(), name: DEFAULT_AGENT_SESSION_NAME.to_owned(), @@ -245,6 +252,7 @@ async fn the_cursor_bot_routes_to_cursor_and_everything_else_to_the_sandbox() { sandbox.clone(), cursor.clone(), TaggedManager::new("codex"), + TaggedManager::new("claude"), FixedBotSessions(bot_id::CURSOR_BOT_ID), ); @@ -272,6 +280,7 @@ async fn resume_and_teardown_route_by_the_stored_bot() { sandbox.clone(), cursor.clone(), TaggedManager::new("codex"), + TaggedManager::new("claude"), FixedBotSessions(bot_id::CURSOR_BOT_ID), ); @@ -290,6 +299,7 @@ async fn a_database_backed_cursor_agent_routes_by_its_stored_harness() { sandbox.clone(), cursor.clone(), TaggedManager::new("codex"), + TaggedManager::new("claude"), FixedBotSessions(BotId::TEST_A), ); @@ -313,6 +323,7 @@ async fn codex_routes_all_lifecycle_operations_and_rejects_resize() { sandbox.clone(), cursor.clone(), codex.clone(), + TaggedManager::new("claude"), FixedBotSessions(bot_id::CODEX_BOT_ID), ); router @@ -341,3 +352,39 @@ async fn codex_routes_all_lifecycle_operations_and_rejects_resize() { AgentKind::CodexCloud ); } + +#[tokio::test] +async fn claude_routes_by_harness_through_the_shared_provider_router() { + let sandbox = TaggedManager::new("sandbox"); + let cursor = TaggedManager::new("cursor"); + let codex = TaggedManager::new("codex"); + let claude = TaggedManager::new("claude"); + let router = RoutedContainerManager::new( + sandbox.clone(), + cursor.clone(), + codex.clone(), + claude.clone(), + FixedBotSessions(CLAUDE_TEST_BOT), + ); + router + .spawn(spawn_for(AgentKind::ClaudeCloud)) + .await + .unwrap() + .map_transport(|transport| assert!(matches!(transport, RoutedTransport::Claude(_)))); + let session = AgentSessionId::new(); + router.resume(session).await.unwrap(); + router.session_token(session).await.unwrap(); + router.teardown(session).await.unwrap(); + assert_eq!( + claude.calls(), + [ + "claude:spawn", + "claude:resume", + "claude:session_token", + "claude:teardown" + ] + ); + assert!(sandbox.calls().is_empty()); + assert!(cursor.calls().is_empty()); + assert!(codex.calls().is_empty()); +} diff --git a/crates/claude_cloud_agents/Cargo.toml b/crates/claude_cloud_agents/Cargo.toml index 1a00f10143a..9f19bbc8062 100644 --- a/crates/claude_cloud_agents/Cargo.toml +++ b/crates/claude_cloud_agents/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" publish = false [dependencies] +agent-client-protocol = { workspace = true } async-trait = { workspace = true } sqlx = { workspace = true } cursor_api_key = { path = "../cursor_api_key" } diff --git a/crates/claude_cloud_agents/src/domain/mcp.rs b/crates/claude_cloud_agents/src/domain/mcp.rs new file mode 100644 index 00000000000..583c26af3d4 --- /dev/null +++ b/crates/claude_cloud_agents/src/domain/mcp.rs @@ -0,0 +1,28 @@ +//! Remote MCP configuration carried by an agent's session handshake. +use serde::Serialize; +use std::collections::BTreeMap; + +/// Remote transports supported by the cloud worker. +#[derive(Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum McpTransport { + /// Streamable HTTP. + Http, + /// HTTP with server-sent events. + Sse, +} + +/// One server. Deliberately not Debug: headers contain session credentials. +#[derive(Clone, Serialize)] +pub struct McpServer { + /// Remote transport kind. + #[serde(rename = "type")] + pub transport: McpTransport, + /// The URL supplied by the session's egress provisioner. + pub url: String, + /// Session-scoped authorization and other supplied headers. + pub headers: BTreeMap, +} + +/// Servers keyed by their stable ACP names. +pub type McpServers = BTreeMap; diff --git a/crates/claude_cloud_agents/src/domain/mod.rs b/crates/claude_cloud_agents/src/domain/mod.rs index b79470285ff..1359c043c8b 100644 --- a/crates/claude_cloud_agents/src/domain/mod.rs +++ b/crates/claude_cloud_agents/src/domain/mod.rs @@ -2,6 +2,7 @@ /// Owner-bound interactive OAuth connection use cases. pub mod auth; pub mod credentials; +pub mod mcp; pub mod model; pub mod models; pub mod ports; diff --git a/crates/claude_cloud_agents/src/domain/model.rs b/crates/claude_cloud_agents/src/domain/model.rs index 0cc9aafb53a..9f4c6aaadfb 100644 --- a/crates/claude_cloud_agents/src/domain/model.rs +++ b/crates/claude_cloud_agents/src/domain/model.rs @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize}; /// The deliberately bounded failures exposed to Macro. Never includes HTTP bodies. #[derive(Debug, thiserror::Error)] pub enum Error { + /// MCP setup failed or the worker did not acknowledge it before prompting. + #[error( + "Claude could not connect the session's MCP servers; check the harness connection and retry" + )] + McpConfiguration, /// Invalid choice or a provider-rejected switch; does not expose provider bodies. #[error( "Claude could not select that model. Choose another model available on your subscription." diff --git a/crates/claude_cloud_agents/src/domain/ports.rs b/crates/claude_cloud_agents/src/domain/ports.rs index a3a4fac0c84..2a40493fd5f 100644 --- a/crates/claude_cloud_agents/src/domain/ports.rs +++ b/crates/claude_cloud_agents/src/domain/ports.rs @@ -6,6 +6,41 @@ use std::{future::Future, pin::Pin}; /// A bounded provider event stream. pub type Events = Pin> + Send>>; +/// Resolve a provider client for exactly one Macro account. +pub trait CloudProvider: Send + Sync + 'static { + /// Owner-bound conversation and lifecycle operations. + type Client: Cloud + CloudLifecycle; + /// A missing connection must never fall back to another account. + fn connect(&self, owner: &str) -> impl Future> + Send; +} + +/// Lifecycle operations kept separate from an attached conversation. +pub trait CloudLifecycle: Cloud { + /// Create a conversation with this agent's instructions. + fn create(&self, instructions: &str) -> impl Future> + Send; + /// Archive the owner's conversation reversibly. + fn archive(&self, session: &SessionId) -> impl Future> + Send; +} + +/// The host's tool permission policy, exposed by the ACP client in Macro. +pub trait ToolPermissions: Send + Sync { + /// Ask the host whether this specific tool invocation may proceed. + fn allow( + &self, + tool_id: &str, + name: &str, + input: &serde_json::Value, + ) -> impl Future> + Send; +} + +/// Standalone callers without a permission client refuse approval requests. +pub struct DenyToolPermissions; +impl ToolPermissions for DenyToolPermissions { + async fn allow(&self, _: &str, _: &str, _: &serde_json::Value) -> Result { + Ok(false) + } +} + /// Account-scoped provider operations. Implementors must never choose another user's credential. pub trait Cloud: Clone + Send + Sync + 'static { /// At most five recent sessions visible through this account's credential. diff --git a/crates/claude_cloud_agents/src/domain/service.rs b/crates/claude_cloud_agents/src/domain/service.rs index 0d5ef740ebc..5e3939bacba 100644 --- a/crates/claude_cloud_agents/src/domain/service.rs +++ b/crates/claude_cloud_agents/src/domain/service.rs @@ -1,8 +1,9 @@ //! Session orchestration: single-turn admission, replay, and bounded stream reconnect. use super::{ + mcp::McpServers, model::{Error, Result, SessionId}, models::{Catalog, Model}, - ports::Cloud, + ports::{Cloud, DenyToolPermissions, ToolPermissions}, translate::{Translator, Update}, }; use futures::StreamExt; @@ -20,6 +21,7 @@ pub struct Session { translator: Mutex, model: Mutex, catalog: Mutex, + mcp_servers: Mutex>, } #[cfg(test)] @@ -41,6 +43,7 @@ impl Session { translator: Mutex::new(Translator::default()), model: Mutex::new(model), catalog: Mutex::new(Catalog::Unknown), + mcp_servers: Mutex::new(None), }) } /// Saved preference for the next turn, not the worker's current running model. @@ -103,6 +106,17 @@ impl Session { &self.id } + /// Retain the host's MCP configuration until the next user-requested turn. + /// An empty restore preserves the cloud worker's existing configuration when + /// the host no longer has the original plaintext egress token. + pub async fn configure_mcp(&self, servers: McpServers) -> Result<()> { + let _turn = self.turn.try_lock().map_err(|_| Error::Busy)?; + if !servers.is_empty() { + *self.mcp_servers.lock().await = Some(servers); + } + Ok(()) + } + /// Replay durable transcript for Macro's normal ACP load flow. pub async fn load(&self) -> Result> { let _turn = self.turn.try_lock().map_err(|_| Error::Busy)?; @@ -158,6 +172,17 @@ impl Session { &self, text: String, emit: impl Fn(Update) -> Result<()> + Send, + ) -> Result<()> { + self.prompt_with_permissions(text, emit, &DenyToolPermissions) + .await + } + + /// Run a turn using the host's existing permission policy. + pub async fn prompt_with_permissions( + &self, + text: String, + emit: impl Fn(Update) -> Result<()> + Send, + permissions: &impl ToolPermissions, ) -> Result<()> { // Wait behind a short idle poll, but never admit two Macro prompts. let _admission = self.prompt_admission.try_lock().map_err(|_| Error::Busy)?; @@ -223,6 +248,14 @@ impl Session { *self.cursor.lock().await = cursor; } let mut stream = self.cloud.stream(&self.id, cursor).await?; + let mcp_request = self.mcp_servers.lock().await.as_ref().map(|servers| { + let id = uuid::Uuid::now_v7().to_string(); + let request = json!({"type":"control_request", "request_id":id, + "request":{"subtype":"mcp_set_servers", "servers":servers}}); + (id, request) + }); + let mcp_request_id = mcp_request.as_ref().map(|(id, _)| id.clone()); + let mut mcp_ready = mcp_request_id.is_none(); let message_id = uuid::Uuid::now_v7().to_string(); let (model_request_id, model_event) = model_request(self.model().await); let mut batch = Vec::new(); @@ -231,6 +264,11 @@ impl Session { // never by creating or waking a session during settings discovery. batch.push(json!({"type":"control_request","request_id":uuid::Uuid::now_v7().to_string(),"request":{"subtype":"initialize"}})); } + // An idle cloud worker wakes for the user event. Configure its tools in + // the same ordered batch before that event, as with the model choice. + if let Some((_, request)) = mcp_request { + batch.push(request); + } batch.push(model_event); batch.push( json!({"type":"user", "uuid":message_id, "session_id":self.id.as_str(), @@ -254,6 +292,22 @@ impl Session { } let payload = &event.data["payload"]; self.learn_catalog(&event, &emit).await?; + if payload["type"] == "control_response" + && mcp_request_id.as_deref().is_some_and(|id| { + payload["response"]["request_id"].as_str() == Some(id) + }) + { + let response = &payload["response"]; + if response["subtype"] != "success" + || !response["response"]["errors"] + .as_object() + .is_some_and(|errors| errors.is_empty()) + { + let _ = self.cancel().await; + return Err(Error::McpConfiguration); + } + mcp_ready = true; + } if payload["type"] == "control_response" && payload["response"]["request_id"].as_str() == Some(&model_request_id) && payload["response"]["subtype"] == "error" @@ -263,21 +317,42 @@ impl Session { let _ = self.cancel().await; return Err(Error::ModelUnavailable); } - // The demo never grants permissions automatically. Respond through the - // actual control protocol so a worker cannot hang waiting for approval. if payload["type"] == "control_request" && payload["request"]["subtype"] == "can_use_tool" { - self.cloud.send(&self.id, json!({"type":"control_response", "response":{ - "subtype":"success", "request_id":payload["request_id"], - "response":{"behavior":"deny","message":"Tool approval is not enabled in the Macro Claude demo"} - }})).await?; + let request = &payload["request"]; + let allowed = mcp_ready + && permissions + .allow( + request["tool_use_id"].as_str().ok_or(Error::Protocol)?, + request["tool_name"].as_str().ok_or(Error::Protocol)?, + &request["input"], + ) + .await?; + let decision = if allowed { + json!({"behavior":"allow", "updatedInput":request["input"]}) + } else { + json!({"behavior":"deny", "message":"Tool use was declined by the session host"}) + }; + self.cloud + .send( + &self.id, + json!({"type":"control_response", "response":{ + "subtype":"success", "request_id":payload["request_id"], + "response":decision + }}), + ) + .await?; } let own_result = payload["type"] == "result" && (payload["user_message_uuid"].as_str() == Some(&message_id) || payload["user_message_uuids"].as_array().is_some_and(|ids| { ids.iter().any(|id| id.as_str() == Some(&message_id)) })); + if own_result && !mcp_ready { + let _ = self.cancel().await; + return Err(Error::McpConfiguration); + } for update in translator.accept(&event, false)? { if matches!(update, Update::Finished { .. }) && !own_result { continue; diff --git a/crates/claude_cloud_agents/src/inbound/acp.rs b/crates/claude_cloud_agents/src/inbound/acp.rs index 65b51ecbc11..3ef4f8d3416 100644 --- a/crates/claude_cloud_agents/src/inbound/acp.rs +++ b/crates/claude_cloud_agents/src/inbound/acp.rs @@ -14,11 +14,16 @@ use serde_json::{Value, json}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc::UnboundedSender; +mod mcp; +mod permissions; + /// Attach a Claude conversation to Macro's existing runtime protocol. pub fn attach(session: Arc>) -> ServerChannel { let (server, mut runtime) = ServerChannel::duplex(); tokio::spawn(async move { let tx = runtime.tx; + let permissions = + permissions::Permissions::new(tx.clone(), session.id().as_str().to_owned()); let _ = tx.send(ToServerMessage::Event { event: SystemEvent::AcpReady, }); @@ -54,6 +59,10 @@ pub fn attach(session: Arc>) -> ServerChannel { incoming = runtime.rx.recv() => { let Some(ToRuntimeMessage::Acp(AcpMessage(raw))) = incoming else { break; }; let Ok(frame) = serde_json::to_value(raw) else { break; }; + if frame.get("method").is_none() { + permissions.respond(&frame); + continue; + } let id = frame.get("id").cloned(); let params = &frame["params"]; let method = frame["method"].as_str().unwrap_or_default(); @@ -63,10 +72,18 @@ pub fn attach(session: Arc>) -> ServerChannel { } match method { "initialize" => { if let Some(id) = id { let _ = reply(&tx, id, json!({ - "protocolVersion":1, "agentCapabilities":{"loadSession":true}, - "agentInfo":{"name":"claude-cloud","title":"Claude Cloud (demo)","version":"0.1.0"}, "authMethods":[] + "protocolVersion":1, "agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true}}, + "agentInfo":{"name":"claude-cloud","title":"Claude Cloud","version":"0.1.0"}, "authMethods":[] })); } } "session/new" => { + let setup = match mcp::servers(params) { + Ok(servers) => session.configure_mcp(servers).await, + Err(error) => Err(error), + }; + if let Err(error) = setup { + if let Some(id) = id { let _ = failure(&tx, id, &error.to_string()); } + continue; + } if let Err(error) = session.refresh_catalog().await { if let Some(id) = id { let _ = failure(&tx, id, &error.to_string()); } continue; @@ -77,6 +94,14 @@ pub fn attach(session: Arc>) -> ServerChannel { } } "session/load" => { + let setup = match mcp::servers(params) { + Ok(servers) => session.configure_mcp(servers).await, + Err(error) => Err(error), + }; + if let Err(error) = setup { + if let Some(id) = id { let _ = failure(&tx, id, &error.to_string()); } + continue; + } mirrors.abort_all(); while mirrors.join_next().await.is_some() {} if let Some(id) = id { @@ -103,15 +128,16 @@ pub fn attach(session: Arc>) -> ServerChannel { let session = session.clone(); let tx = tx.clone(); let usage_totals = usage_totals.clone(); + let permissions = permissions.clone(); turns.spawn(async move { let final_result = Arc::new(Mutex::new(None)); let observed = final_result.clone(); - let outcome = session.prompt(text, |update| { + let outcome = session.prompt_with_permissions(text, |update| { if let Update::Finished { failed, cancelled, usage } = update { *observed.lock().map_err(|_| Error::Protocol)? = Some((failed, cancelled, usage)); Ok(()) } else { notification(&tx, session.id().as_str(), update) } - }).await; + }, &permissions).await; match outcome { Err(error) => { let _ = failure(&tx, id, &error.to_string()); } Ok(()) => { diff --git a/crates/claude_cloud_agents/src/inbound/acp/mcp.rs b/crates/claude_cloud_agents/src/inbound/acp/mcp.rs new file mode 100644 index 00000000000..ee4031a0e1a --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/acp/mcp.rs @@ -0,0 +1,46 @@ +//! Translate the standard ACP server list into the provider's remote server model. +use crate::domain::{ + mcp::{McpServer, McpServers, McpTransport}, + model::{Error, Result}, +}; +use agent_client_protocol::schema::v1::McpServer as AcpMcpServer; + +pub(super) fn servers(params: &serde_json::Value) -> Result { + let servers: Vec = serde_json::from_value( + params + .get("mcpServers") + .cloned() + .unwrap_or_else(|| serde_json::json!([])), + ) + .map_err(|_| Error::McpConfiguration)?; + let mut result = McpServers::new(); + for server in servers { + let (name, transport, url, headers) = match server { + AcpMcpServer::Http(server) => { + (server.name, McpTransport::Http, server.url, server.headers) + } + AcpMcpServer::Sse(server) => { + (server.name, McpTransport::Sse, server.url, server.headers) + } + _ => return Err(Error::McpConfiguration), + }; + if name.is_empty() || result.contains_key(&name) { + return Err(Error::McpConfiguration); + } + let mut mapped_headers = std::collections::BTreeMap::new(); + for header in headers { + if mapped_headers.insert(header.name, header.value).is_some() { + return Err(Error::McpConfiguration); + } + } + result.insert( + name, + McpServer { + transport, + url, + headers: mapped_headers, + }, + ); + } + Ok(result) +} diff --git a/crates/claude_cloud_agents/src/inbound/acp/permissions.rs b/crates/claude_cloud_agents/src/inbound/acp/permissions.rs new file mode 100644 index 00000000000..b6fc27b8a78 --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/acp/permissions.rs @@ -0,0 +1,77 @@ +//! Correlates provider permission requests with the existing ACP host policy. +use super::*; +use crate::domain::ports::ToolPermissions; +use std::collections::HashMap; +use tokio::sync::oneshot; + +#[derive(Clone)] +pub(super) struct Permissions { + tx: UnboundedSender, + session: String, + pending: Arc>>>, +} + +impl Permissions { + pub(super) fn new(tx: UnboundedSender, session: String) -> Self { + Self { + tx, + session, + pending: Arc::default(), + } + } + + pub(super) fn respond(&self, frame: &Value) { + let Some(id) = frame["id"].as_str() else { + return; + }; + let sender = self + .pending + .lock() + .ok() + .and_then(|mut pending| pending.remove(id)); + if let Some(sender) = sender { + let outcome = &frame["result"]["outcome"]; + let allowed = outcome["outcome"] == "selected" && outcome["optionId"] == "allow_once"; + let _ = sender.send(allowed); + } + } +} + +impl ToolPermissions for Permissions { + async fn allow(&self, tool_id: &str, name: &str, input: &Value) -> Result { + let id = format!("claude-permission-{}", uuid::Uuid::now_v7()); + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .map_err(|_| Error::Protocol)? + .insert(id.clone(), sender); + let sent = send( + &self.tx, + json!({ + "jsonrpc":"2.0", "id":id, "method":"session/request_permission", + "params":{ + "sessionId":self.session, + "toolCall":{"toolCallId":tool_id,"title":name,"status":"pending","rawInput":input}, + "options":[ + {"optionId":"allow_once","name":"Allow once","kind":"allow_once"}, + {"optionId":"reject_once","name":"Reject","kind":"reject_once"} + ] + } + }), + ); + let allowed = if sent.is_ok() { + tokio::time::timeout(std::time::Duration::from_secs(60), receiver) + .await + .ok() + .and_then(|result| result.ok()) + .unwrap_or(false) + } else { + false + }; + self.pending + .lock() + .map_err(|_| Error::Protocol)? + .remove(&id); + Ok(allowed) + } +} diff --git a/crates/claude_cloud_agents/src/inbound/acp/test.rs b/crates/claude_cloud_agents/src/inbound/acp/test.rs index b81773026dd..1178703290a 100644 --- a/crates/claude_cloud_agents/src/inbound/acp/test.rs +++ b/crates/claude_cloud_agents/src/inbound/acp/test.rs @@ -229,3 +229,5 @@ async fn idle_cloud_turn_is_polled_into_acp_and_completed_without_a_macro_prompt .is_err() ); } + +mod mcp; diff --git a/crates/claude_cloud_agents/src/inbound/acp/test/mcp.rs b/crates/claude_cloud_agents/src/inbound/acp/test/mcp.rs new file mode 100644 index 00000000000..0f4951c3163 --- /dev/null +++ b/crates/claude_cloud_agents/src/inbound/acp/test/mcp.rs @@ -0,0 +1,233 @@ +use super::*; + +#[derive(Clone, Default)] +struct McpCloud { + submitted: Arc>>, + events: Arc>>>>, + prompt: Arc>, + fail_mcp: bool, + omit_mcp_ack: bool, + require_permission: bool, +} + +impl McpCloud { + fn emit(&self, payload: Value) { + self.events + .lock() + .unwrap() + .as_ref() + .unwrap() + .send(Ok(Event { + kind: "client_event".into(), + sequence: None, + data: json!({"payload":payload}), + })) + .unwrap(); + } + fn finish(&self) { + self.emit( + json!({"type":"result", "uuid":"result", "subtype":"success", "is_error":false, + "user_message_uuid":self.prompt.lock().unwrap()["uuid"], "usage":{}}), + ); + } +} +impl Cloud for McpCloud { + async fn recent_sessions(&self) -> Result> { + Ok(vec![]) + } + async fn history(&self, _: &SessionId) -> Result> { + Ok(vec![]) + } + async fn stream(&self, _: &SessionId, _: Option) -> Result { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + *self.events.lock().unwrap() = Some(tx); + Ok(futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|event| (event, rx)) + }) + .boxed()) + } + async fn send_batch(&self, id: &SessionId, payloads: Vec) -> Result<()> { + for payload in payloads { + self.send(id, payload).await?; + } + Ok(()) + } + async fn send(&self, _: &SessionId, payload: Value) -> Result<()> { + self.submitted.lock().unwrap().push(payload.clone()); + if payload["request"]["subtype"] == "mcp_set_servers" && !self.omit_mcp_ack { + self.emit(json!({"type":"control_response", "response":{ + "subtype":"success", "request_id":payload["request_id"], + "response":{"added":["macro"], "removed":[], "errors":if self.fail_mcp {json!({"macro":"unreachable"})} else {json!({})}} + }})); + } + if payload["type"] == "user" { + *self.prompt.lock().unwrap() = payload; + if self.require_permission { + self.emit(json!({"type":"control_request", "request_id":"tool-permission", "request":{ + "subtype":"can_use_tool", "tool_use_id":"tool-1", "tool_name":"mcp__macro__search", "input":{"query":"hello"} + }})); + } else { + self.finish(); + } + } else if payload["type"] == "control_response" { + self.finish(); + } + Ok(()) + } +} + +fn remote_servers() -> Value { + json!([{"type":"http", "name":"macro", "url":"https://egress.example/mcp-macro", + "headers":[{"name":"Authorization", "value":"Bearer session-test"}]}]) +} + +async fn open(cloud: McpCloud, method: &str, servers: Value) -> ServerChannel { + let mut channel = attach(Session::new(cloud, SessionId::parse("cse_test").unwrap())); + channel + .tx + .send(frame(json!({"jsonrpc":"2.0", "id":1, "method":method, + "params":{"sessionId":"cse_test", "cwd":"/", "mcpServers":servers}}))) + .unwrap(); + assert!(read(&mut channel).await.get("result").is_some()); + channel +} +fn prompt(channel: &ServerChannel) { + channel + .tx + .send(frame( + json!({"jsonrpc":"2.0", "id":2, "method":"session/prompt", + "params":{"sessionId":"cse_test", "prompt":[{"type":"text", "text":"hello"}]}}), + )) + .unwrap(); +} + +#[tokio::test] +async fn new_and_resumed_sessions_forward_authenticated_mcp_before_prompt() { + for method in ["session/new", "session/load"] { + let cloud = McpCloud::default(); + let mut channel = open(cloud.clone(), method, remote_servers()).await; + assert!( + cloud.submitted.lock().unwrap().is_empty(), + "opening must not wake the worker" + ); + prompt(&channel); + let result = read(&mut channel).await; + assert_eq!(result["result"]["stopReason"], "end_turn"); + let sent = cloud.submitted.lock().unwrap(); + assert_eq!(sent[1]["request"]["subtype"], "mcp_set_servers"); + let server = &sent[1]["request"]["servers"]["macro"]; + assert_eq!(server["type"], "http"); + assert_eq!(server["url"], "https://egress.example/mcp-macro"); + assert_eq!(server["headers"]["Authorization"], "Bearer session-test"); + assert_eq!(sent.last().unwrap()["type"], "user"); + } +} + +#[tokio::test] +async fn failed_mcp_setup_fails_the_turn_and_requests_interruption() { + let cloud = McpCloud { + fail_mcp: true, + ..Default::default() + }; + let mut channel = open(cloud.clone(), "session/new", remote_servers()).await; + prompt(&channel); + assert!(read(&mut channel).await.get("error").is_some()); + assert!( + cloud + .submitted + .lock() + .unwrap() + .iter() + .any(|event| event["request"]["subtype"] == "interrupt") + ); +} + +#[tokio::test] +async fn tool_permissions_roundtrip_through_the_acp_host() { + for choice in ["allow_once", "reject_once"] { + let cloud = McpCloud { + require_permission: true, + ..Default::default() + }; + let mut channel = open(cloud.clone(), "session/new", remote_servers()).await; + prompt(&channel); + let request = read(&mut channel).await; + assert_eq!(request["method"], "session/request_permission"); + assert_eq!(request["params"]["toolCall"]["toolCallId"], "tool-1"); + channel + .tx + .send(frame(json!({"jsonrpc":"2.0", "id":request["id"], + "result":{"outcome":{"outcome":"selected", "optionId":choice}}}))) + .unwrap(); + assert_eq!(read(&mut channel).await["result"]["stopReason"], "end_turn"); + let sent = cloud.submitted.lock().unwrap(); + let response = sent + .iter() + .find(|event| event["type"] == "control_response") + .unwrap(); + assert_eq!(response["response"]["request_id"], "tool-permission"); + assert_eq!( + response["response"]["response"]["behavior"], + if choice == "allow_once" { + "allow" + } else { + "deny" + } + ); + } +} + +#[tokio::test] +async fn unsupported_stdio_and_duplicate_names_fail_the_handshake() { + for servers in [ + json!([{"name":"local", "command":"sh", "args":[], "env":[]}]), + json!([remote_servers()[0], remote_servers()[0]]), + ] { + let cloud = McpCloud::default(); + let mut channel = attach(Session::new( + cloud.clone(), + SessionId::parse("cse_test").unwrap(), + )); + channel + .tx + .send(frame( + json!({"jsonrpc":"2.0", "id":1, "method":"session/new", + "params":{"cwd":"/", "mcpServers":servers}}), + )) + .unwrap(); + assert!(read(&mut channel).await.get("error").is_some()); + assert!(cloud.submitted.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn a_missing_mcp_acknowledgment_cannot_report_a_successful_turn() { + let cloud = McpCloud { + omit_mcp_ack: true, + ..Default::default() + }; + let mut channel = open(cloud.clone(), "session/new", remote_servers()).await; + prompt(&channel); + assert!(read(&mut channel).await.get("error").is_some()); + assert!( + cloud + .submitted + .lock() + .unwrap() + .iter() + .any(|event| event["request"]["subtype"] == "interrupt") + ); +} + +#[test] +fn sse_servers_preserve_their_transport_and_auth_headers() { + let mut servers = remote_servers(); + servers[0]["type"] = json!("sse"); + let mapped = super::super::mcp::servers(&json!({"mcpServers":servers})).unwrap(); + let wire = serde_json::to_value(mapped).unwrap(); + assert_eq!(wire["macro"]["type"], "sse"); + assert_eq!( + wire["macro"]["headers"]["Authorization"], + "Bearer session-test" + ); +} diff --git a/crates/claude_cloud_agents/src/outbound/http.rs b/crates/claude_cloud_agents/src/outbound/http.rs index dc202a9d16a..88523c70eef 100644 --- a/crates/claude_cloud_agents/src/outbound/http.rs +++ b/crates/claude_cloud_agents/src/outbound/http.rs @@ -1,8 +1,8 @@ //! The experimentally verified OAuth API origin, not the cookie-authenticated web origin. -use super::credentials::FileCredentials; use crate::domain::{ + credentials::AccountCredentials, model::{Error, Event, Result, SessionId}, - ports::{Cloud, Events}, + ports::{Cloud, CloudLifecycle, CloudProvider, Events}, }; use futures::StreamExt; use serde_json::{Value, json}; @@ -14,13 +14,13 @@ const BETA: &str = "ccr-byoc-2025-07-29"; /// HTTP client pinned to one Macro account and the official Anthropic origin. #[derive(Clone)] pub struct Client { - credentials: FileCredentials, + credentials: AccountCredentials, owner: String, http: reqwest::Client, } impl Client { /// Resolve account allowlisting before attaching a runtime. - pub async fn new(credentials: FileCredentials, owner: String) -> Result { + pub async fn new(credentials: AccountCredentials, owner: String) -> Result { credentials.resolve(&owner).await?; let http = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) @@ -116,6 +116,27 @@ impl Client { } } +/// Factory using the shared account credential service supplied by the host. +pub struct Provider(pub Option); + +impl CloudProvider for Provider { + type Client = Client; + + async fn connect(&self, owner: &str) -> Result { + Client::new(self.0.clone().ok_or(Error::NotConnected)?, owner.to_owned()).await + } +} + +impl CloudLifecycle for Client { + async fn create(&self, instructions: &str) -> Result { + Client::create(self, instructions).await + } + + async fn archive(&self, session: &SessionId) -> Result<()> { + Client::archive(self, session).await + } +} + impl Cloud for Client { async fn recent_sessions(&self) -> Result> { let response: Value = self diff --git a/docs/AGENT_GUIDE/navigation.md b/docs/AGENT_GUIDE/navigation.md index 721fa918428..ebdd58b85c6 100644 --- a/docs/AGENT_GUIDE/navigation.md +++ b/docs/AGENT_GUIDE/navigation.md @@ -255,6 +255,12 @@ names, descriptions, and order. Settings discovers from recent account sessions; session catalogs update through replay, polling, and streaming. Before any catalog is available, only subscription default is shown with an explanation. It saves the next-turn preference without waking an idle worker; provider model rejections surface during the turn. +Claude agents use their saved MCP selection through the shared authenticated +session egress path. Remote HTTP/SSE servers are supported; stdio servers are +rejected. Tool permission requests use Macro's standard session policy. MCP setup +failures surface as turn errors and request interruption; the first prompt also +wakes the cloud worker, so setup failure may occur after submission. Reconnecting +refreshes the session credential and restores the saved selection. Claude sessions expose **Open in Claude** in the header toolbar (or its overflow menu). With a live runtime, messages sent in Claude are polled into Macro about every two seconds; disconnected runtimes must resume first. diff --git a/docs/CLAUDE_CLOUD_DEMO.md b/docs/CLAUDE_CLOUD_DEMO.md index 3296fa8a9f7..ed00202ded4 100644 --- a/docs/CLAUDE_CLOUD_DEMO.md +++ b/docs/CLAUDE_CLOUD_DEMO.md @@ -91,9 +91,36 @@ The standalone frontend against the existing dev backend cannot exercise this ne Only the session owner may send/control prompts or edit/remove queued input for this provider. Sharing visibility does not grant permission to spend the owner's subscription. Existing Macro access checks still apply. +## Harness integration + +Claude implements the shared `ContainerManager` and `ClaudeModelProbe` ports and +is dispatched by `RoutedContainerManager` alongside Cursor and Codex. The +composition root supplies the account-scoped `CloudProvider`, session repository, +existing egress provisioner, and ACP adapter. Each saved agent retains its own +instructions, model, and MCP selection. Provider lifecycle policy lives in +`agent_harness::domain::claude`; OAuth credentials stay behind the account service. + +ACP `session/new` and `session/load` carry Macro's existing authenticated HTTP/SSE +MCP server list. Claude translates that list into the SDK's `mcp_set_servers` +control request, ordered before the model and user message in the same batch. +An idle worker needs the user event to wake, so an acknowledgment cannot be awaited +before submitting that batch. A rejected or missing setup acknowledgment fails the +turn; rejection also requests interruption. As with model selection, this cannot +guarantee zero inference before a rejection arrives. Tool permission requests go +through Macro's standard ACP `session/request_permission` policy. Unsupported +stdio servers and duplicate names fail the handshake instead of being dropped. +On reattach the existing egress provisioner mints a fresh session token, persists +its hash, and reconstructs the saved selection, including after a host restart. + +The control request and response match +[Anthropic's Agent SDK types](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.220/sdk.d.ts). +Automated tests exercise protocol translation and host permission decisions using +fake provider ports; they do not establish live Claude Cloud MCP connectivity. +The cloud worker must be able to reach the configured egress URL. + ## Demo boundary -- Text prompts only; no attachments, repository selection, or Macro/Pipedream MCP connectors are passed to Claude yet. Built-in cloud tools may run according to the provider's normal policy. Additional permission requests are denied rather than silently approved. +- Text prompts only; attachments and repository selection are not forwarded yet. Macro and configured Pipedream MCP servers use the shared session egress path. Built-in cloud tools retain provider policy; explicit permission requests are delegated to the Macro host. - Model discovery is read-only and bounded to five recent account sessions. No catalog is shared across credential owners. New/removed model IDs require no code change; choices absent from the current catalog are rejected. Provider errors stay visible rather than silently substituting a fixed list. No local filesystem. Agent instructions are forwarded as `append_system_prompt`. - Access tokens refresh shortly before expiry; rotated refresh tokens are encrypted and saved before provider use. If saving fails, the process retains the rotated grant and retries persistence rather than reusing the old refresh token. A revoked grant produces a reconnect error. No API-key fallback or quota bypass. - Prompt sends and session creation are not blindly retried. A durable `claude-cloud-create-pending` mapping prevents duplicate creation after an uncertain request or crash. An operator must inspect the Claude account before clearing a stuck intent. diff --git a/services/agent_harness_service/src/claude_cloud.rs b/services/agent_harness_service/src/claude_cloud.rs deleted file mode 100644 index 257bbdd83a4..00000000000 --- a/services/agent_harness_service/src/claude_cloud.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Composition-root wiring for the demo Claude cloud provider. -use agent_harness::domain::{ - error::{HarnessError, Result}, - model::{AgentKind, SpawnContainer}, - ports::ContainerManager, - sandbox::SandboxResizeEffect, -}; -use agent_runtime_protocol::domain::{ - connection::ServerChannel, - ports::{Transport, TransportError, TransportReceiver, TransportSender}, - schema::v0::{ToRuntimeMessage, ToServerMessage}, -}; -use agent_session::domain::{ - connection::RuntimeAttachment, - model::{AgentSessionId, ExternalSession, SandboxSize}, - ports::{AgentSessionRepo, ExternalSessionRepo}, -}; -use claude_cloud_agents::{ - domain::{ - model::{Error, SessionId}, - service::Session, - }, - inbound::acp, - outbound::{credentials::FileCredentials, http::Client}, -}; -use std::sync::Arc; - -const PROVIDER: &str = "claude-cloud"; -const PENDING: &str = "claude-cloud-create-pending"; - -pub struct ClaudeModels(pub Option); -impl agent_harness::domain::model_load::ClaudeModelProbe for ClaudeModels { - fn probe<'a>( - &'a self, - caller: &'a macro_user_id::user_id::MacroUserIdStr<'static>, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = std::result::Result< - agent_harness::domain::model_load::RawModelProbe, - agent_harness::domain::model_load::ModelProbeError, - >, - > + Send - + 'a, - >, - > { - Box::pin(async move { - use agent_harness::domain::model_load::{ModelProbeError, RawModelProbe}; - let Some(credentials) = &self.0 else { - return Ok(RawModelProbe::Unsupported); - }; - if !credentials.contains(caller.as_ref()).await { - return Ok(RawModelProbe::Unsupported); - } - let client = Client::new(credentials.clone(), caller.as_ref().to_owned()) - .await - .map_err(|error| ModelProbeError::Failed(error.to_string()))?; - let catalog = claude_cloud_agents::domain::models::discover(&client) - .await - .map_err(|error| ModelProbeError::Failed(error.to_string()))?; - let options = serde_json::from_value(acp::model_options(&catalog, &Default::default())) - .map_err(|_| { - ModelProbeError::Failed("Invalid Claude model configuration".into()) - })?; - Ok(RawModelProbe::Options(options)) - }) - } -} - -/// Routes Claude sessions to Anthropic and all other sessions to the existing providers. -pub struct WithClaude { - base: Base, - repo: Repo, - credentials: Option, - creation: Arc>, -} -impl WithClaude { - pub fn new(base: Base, repo: Repo, credentials: Option) -> Self { - Self { - base, - repo, - credentials, - creation: Arc::new(tokio::sync::Mutex::new(())), - } - } -} -fn cloud_error(error: Error) -> HarnessError { - HarnessError::Container(error.to_string()) -} - -impl WithClaude -where - Repo: AgentSessionRepo + ExternalSessionRepo + Clone, - Base: ContainerManager, -{ - async fn is_claude(&self, id: AgentSessionId) -> Result { - let row = AgentSessionRepo::get(&self.repo, id).await?; - Ok(AgentKind::for_session(row.bot_id, &row.harness) == AgentKind::ClaudeCloud) - } - - async fn attach(&self, id: AgentSessionId) -> Result> { - let _creation = self.creation.lock().await; - let row = AgentSessionRepo::get(&self.repo, id).await?; - let credentials = self - .credentials - .clone() - .ok_or_else(|| cloud_error(Error::NotConnected))?; - let client = Client::new(credentials, row.owner_id.as_ref().to_owned()) - .await - .map_err(cloud_error)?; - let external = ExternalSessionRepo::get(&self.repo, id).await?; - let cloud_id = match external { - Some(mut external) if external.provider == PROVIDER => { - let cloud_id = SessionId::parse(&external.external_id).map_err(cloud_error)?; - if external.external_url.is_none() { - external.external_url = Some(cloud_id.web_url()); - self.repo.upsert(id, external).await?; - } - cloud_id - } - Some(_) => return Err(cloud_error(Error::UncertainCreate)), - None => { - // Write a durable intent before the non-idempotent POST. A crash or - // timeout stays visibly pending rather than minting a duplicate VM. - self.repo - .upsert( - id, - ExternalSession { - provider: PENDING.into(), - external_id: id.to_string(), - external_name: None, - external_url: None, - last_run_id: None, - }, - ) - .await?; - let cloud_id = match client - .create(row.instructions.as_deref().unwrap_or_default()) - .await - { - Ok(id) => id, - Err(error) => { - // Only a definite client rejection is safe to retry as a new create. - if matches!( - error, - Error::Authorization | Error::NotConnected | Error::Http(400..=499) - ) { - ExternalSessionRepo::delete(&self.repo, id).await?; - } - return Err(cloud_error(error)); - } - }; - self.repo - .upsert( - id, - ExternalSession { - provider: PROVIDER.into(), - external_id: cloud_id.as_str().to_owned(), - external_name: Some("Claude Cloud demo".into()), - external_url: Some(cloud_id.web_url()), - last_run_id: None, - }, - ) - .await?; - cloud_id - } - }; - Ok(RuntimeAttachment::solo(acp::attach(Session::with_model( - client, - cloud_id, - claude_cloud_agents::domain::models::Model::parse(&row.model).map_err(cloud_error)?, - )))) - } -} - -impl ContainerManager for WithClaude -where - Base: ContainerManager, - Repo: AgentSessionRepo + ExternalSessionRepo + Clone, -{ - type Transport = CloudTransport; - async fn spawn(&self, command: SpawnContainer) -> Result> { - if command.kind == AgentKind::ClaudeCloud { - self.attach(command.session_id) - .await - .map(|a| a.map_transport(CloudTransport::Claude)) - } else { - self.base - .spawn(command) - .await - .map(|a| a.map_transport(CloudTransport::Other)) - } - } - async fn resume(&self, id: AgentSessionId) -> Result> { - if self.is_claude(id).await? { - self.attach(id) - .await - .map(|a| a.map_transport(CloudTransport::Claude)) - } else { - self.base - .resume(id) - .await - .map(|a| a.map_transport(CloudTransport::Other)) - } - } - async fn session_token(&self, id: AgentSessionId) -> Result> { - if self.is_claude(id).await? { - Ok(None) - } else { - self.base.session_token(id).await - } - } - async fn teardown(&self, id: AgentSessionId) -> Result<()> { - if !self.is_claude(id).await? { - return self.base.teardown(id).await; - } - let Some(external) = ExternalSessionRepo::get(&self.repo, id).await? else { - return Ok(()); - }; - if external.provider != PROVIDER { - return Err(cloud_error(Error::UncertainCreate)); - } - let row = AgentSessionRepo::get(&self.repo, id).await?; - let credentials = self - .credentials - .clone() - .ok_or_else(|| cloud_error(Error::NotConnected))?; - Client::new(credentials, row.owner_id.as_ref().to_owned()) - .await - .map_err(cloud_error)? - .archive(&SessionId::parse(&external.external_id).map_err(cloud_error)?) - .await - .map_err(cloud_error)?; - ExternalSessionRepo::delete(&self.repo, id).await?; - Ok(()) - } - fn resize_effect(&self, from: SandboxSize, to: SandboxSize) -> SandboxResizeEffect { - self.base.resize_effect(from, to) - } - async fn resize(&self, id: AgentSessionId, size: SandboxSize) -> Result<()> { - if self.is_claude(id).await? { - return Err(HarnessError::Container( - "Claude manages its own cloud compute".into(), - )); - } - self.base.resize(id, size).await - } -} - -pub enum CloudTransport { - Other(Base), - Claude(ServerChannel), -} -pub enum CloudSender { - Other(Base), - Claude(tokio::sync::mpsc::UnboundedSender), -} -pub enum CloudReceiver { - Other(Base), - Claude(tokio::sync::mpsc::UnboundedReceiver), -} -impl> - Transport for CloudTransport -{ - type Sender = CloudSender; - type Receiver = CloudReceiver; - fn split(self) -> (Self::Sender, Self::Receiver) { - match self { - Self::Other(base) => { - let (tx, rx) = base.split(); - (CloudSender::Other(tx), CloudReceiver::Other(rx)) - } - Self::Claude(channel) => ( - CloudSender::Claude(channel.tx), - CloudReceiver::Claude(channel.rx), - ), - } - } -} -impl> TransportSender - for CloudSender -{ - async fn send(&self, message: ToRuntimeMessage) -> std::result::Result<(), TransportError> { - match self { - Self::Other(base) => base.send(message).await, - Self::Claude(tx) => TransportSender::send(tx, message).await, - } - } -} -impl> TransportReceiver - for CloudReceiver -{ - async fn recv(&mut self) -> std::result::Result, TransportError> { - match self { - Self::Other(base) => base.recv().await, - Self::Claude(rx) => Ok(rx.recv().await), - } - } -} diff --git a/services/agent_harness_service/src/main.rs b/services/agent_harness_service/src/main.rs index 4d9f0d58d07..0925f73bf71 100644 --- a/services/agent_harness_service/src/main.rs +++ b/services/agent_harness_service/src/main.rs @@ -10,7 +10,6 @@ mod agent_runtime_directory; mod api; mod bots_directory; -mod claude_cloud; mod config; mod containers; mod harness_bindings; @@ -625,14 +624,25 @@ async fn run() -> anyhow::Result<()> { environment = %config.environment, "agent harness serving bots" ); + let claude_provider = Arc::new(claude_cloud_agents::outbound::http::Provider( + claude_credentials.clone(), + )); + let claude_manager = agent_harness::outbound::claude::ClaudeContainerManager::new( + agent_harness::domain::claude::ClaudeSessions::new( + claude_provider.clone(), + session_repo.clone(), + session_repo.clone(), + ), + EgressProvisioner::new(Arc::clone(&mcp_connections), egress_base_url.clone()), + claude_cloud_agents::inbound::acp::attach, + ); let containers = RoutedContainerManager::new( sandbox_and_inmem, cursor_manager, codex_manager, + claude_manager, session_repo.clone(), ); - let containers = - claude_cloud::WithClaude::new(containers, session_repo.clone(), claude_credentials.clone()); let contacts_ingress = Arc::new(contacts::domain::service::SqsContactsIngress { queue: contacts::outbound::ingress::SqsContactsQueue::new( @@ -808,8 +818,8 @@ async fn run() -> anyhow::Result<()> { macrod_models, model_probe_timeout, ) - .with_claude(Arc::new(claude_cloud::ClaudeModels( - claude_credentials.clone(), + .with_claude(Arc::new(agent_harness::outbound::claude::ClaudeModels( + claude_provider, ))), ); let model_state = AgentModelsRouterState::new(