From e2297999ebeced1bea286f4cf38017ffa54c8485 Mon Sep 17 00:00:00 2001 From: OnPoint-Dev-Tools Date: Wed, 19 Aug 2026 22:22:21 -0400 Subject: [PATCH 01/10] FEAT: Add self-hosted Hub passkey foundation - Add hub-server.ts to handle HTTP server logic for CrewCode Hub. - Introduce hub-store.ts for managing user, credential, session, and machine data with SQLite. - Create hub.ts for CLI options parsing and server initialization. - Implement remote-access-security.ts for rate limiting and origin validation. - Add tests for hub functionality, remote access authentication, and security features. - Ensure secure handling of sessions and credentials with appropriate error handling and validation. --- .gitignore | 4 + bin/crewcode-server.mjs | 11 +- docs/web-remote-access.md | 274 +++++++++++++++++++++++- package.json | 2 +- src/main/headless.test.ts | 6 +- src/main/headless.ts | 22 +- src/main/hub-auth.ts | 143 +++++++++++++ src/main/hub-server.test.ts | 88 ++++++++ src/main/hub-server.ts | 248 +++++++++++++++++++++ src/main/hub-store.ts | 248 +++++++++++++++++++++ src/main/hub.test.ts | 33 +++ src/main/hub.ts | 97 +++++++++ src/main/remote-access-auth.test.ts | 71 ++++++ src/main/remote-access-auth.ts | 137 +++++++++++- src/main/remote-access-security.test.ts | 53 +++++ src/main/remote-access-security.ts | 80 +++++++ src/main/remote-access-server.test.ts | 62 ++++++ src/main/remote-access-server.ts | 38 +++- 18 files changed, 1588 insertions(+), 29 deletions(-) create mode 100644 src/main/hub-auth.ts create mode 100644 src/main/hub-server.test.ts create mode 100644 src/main/hub-server.ts create mode 100644 src/main/hub-store.ts create mode 100644 src/main/hub.test.ts create mode 100644 src/main/hub.ts create mode 100644 src/main/remote-access-auth.test.ts create mode 100644 src/main/remote-access-security.test.ts create mode 100644 src/main/remote-access-security.ts diff --git a/.gitignore b/.gitignore index e96fd39..00d7e42 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ docs-website/.astro/ social-media-post.md Work/ promo-video +packaging/arch/pkg/ +packaging/arch/src/ +packaging/arch/*.deb +packaging/arch/*.pkg.tar.* diff --git a/bin/crewcode-server.mjs b/bin/crewcode-server.mjs index ec0c48c..1e82ef3 100755 --- a/bin/crewcode-server.mjs +++ b/bin/crewcode-server.mjs @@ -6,18 +6,21 @@ import { fileURLToPath } from 'url' import { spawnSync } from 'child_process' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const entry = join(root, 'out', 'main', 'headless.js') +const args = process.argv.slice(2) +const command = args[0] === 'hub' ? 'hub' : 'serve' +const entry = join(root, 'out', 'main', command === 'hub' ? 'hub.js' : 'headless.js') if (!existsSync(entry)) { - console.error('CrewCode server build is missing. Run `npm run build` before starting from this checkout.') + console.error(`CrewCode ${command} build is missing. Run \`npm run build\` before starting from this checkout.`) process.exit(1) } // The Electron package sets ELECTRON_RUN_AS_NODE in some development shells. -// A headless server must always execute in ordinary Node.js. +// Headless services must always execute in ordinary Node.js. delete process.env.ELECTRON_RUN_AS_NODE const require = createRequire(import.meta.url) const module = require(entry) -module.runHeadless(process.argv.slice(2)).catch(error => { +const run = command === 'hub' ? module.runHub : module.runHeadless +run(command === 'hub' || args[0] === 'serve' ? args.slice(1) : args).catch(error => { console.error(error?.message || String(error)) process.exitCode = 1 }) diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index a3416df..e54be1d 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -37,6 +37,216 @@ session, and only then installs the privileged client adapter. - Enforce origin checks, request-size limits, rate limits, and session expiry. - Recommend Tailscale or another trusted private network for access between devices. +### Current direct-server security status + +Pairing credentials remain memory-only, short-lived, and single-use. Device sessions +are persisted as SHA-256 digests in an owner-only atomic store, survive restarts, +expire after 30 days or 7 idle days, and can be listed/revoked through authenticated +RPC. HTTP and WebSocket browser requests enforce exact same-origin checks, with +repeatable `--public-origin` exceptions for explicitly configured reverse proxies. +Pairing and invalid-session attempts have bounded per-peer fixed-window limits. + +User-facing `crewcode auth` commands, general authenticated-RPC traffic limits, and +turnkey LAN/Tailscale deployment guidance remain incomplete. Keep the server on +loopback or a trusted private network unless its proxy, TLS, and public origin are +configured deliberately. + +## Connection modes + +CrewCode supports two distinct deployment modes. They must share the typed client +contract, but must not share credentials or silently fall back from one trust model +to the other. + +### Direct mode (implemented preview) + +The brain serves the React application and API itself. A browser opens a one-time +pairing URL, exchanges it for a brain-local session, and talks directly to that +brain. This mode is for loopback, LAN, or a trusted tailnet. It requires a reachable +address and does not provide account login or machine discovery. + +### Self-hosted Hub mode (identity foundation implemented) + +The first Hub slice is implemented as the separate `crewcode hub` process. It +provides durable local identity storage, first-owner passkey bootstrap, passkey +sign-in, revocable browser sessions, audit events, and an authenticated machine-list +skeleton. Machine enrollment, outbound brain presence, connection tickets, relay, +end-to-end browser-to-brain encryption, and the shared renderer adapter remain +planned; the current Hub cannot remotely control a brain yet. + +A user runs one always-on **CrewCode Hub** on a Linux desktop, headless server, +NAS, or other trusted host. The Hub serves the React application, local sign-in, +machine registry, and relay. Every CrewCode brain makes an **outbound-only** +persistent connection to that Hub, so enrolled machines can appear in one dashboard +without opening a separate inbound port for every machine. + +The Hub URL is deployment-specific. CJ's personal deployment uses +`https://crewcode.logixhub.icu`; this is not a CrewCode-operated SaaS endpoint and +must never be hardcoded as the application default. Other users provide their own +LAN address, Tailscale HTTPS name, or user-controlled domain when configuring their +Hub and enrolling brains. + +```text +browser + -> HTTPS local sign-in + machine list -> self-hosted CrewCode Hub + -> authenticated encrypted tunnel -> Hub relay <- outbound tunnel <- CrewCode brain + +Hub control plane: local users, machine keys, enrollment, presence, revocation +Hub relay: connection routing, backpressure, short-lived ticket enforcement +brain: final authorization, workspace sandbox, RPC execution, secrets +``` + +The Hub relay is not a replacement for the brain's authorization boundary. The +brain must validate the user, machine audience, expiry, and session identity on +every new tunnel before installing a privileged client session. + +A managed CrewCode-hosted Hub may be added later, but it must implement the same +protocol and must never be required for self-hosted operation. + +## Self-hosted Hub identity and relay contract + +### Local sign-in and bootstrap + +- First launch creates no default password. It prints a short-lived, single-use + owner setup URL whose credential remains memory-only. The first owner registers + a user-verifying passkey. Recovery codes are still planned and must be implemented + before passkeys are presented as recoverable. +- Subsequent browser sessions authenticate to the Hub with WebAuthn/passkeys. An + optional external OIDC provider may be configured by the Hub owner, but is not + required. +- The browser uses secure, HttpOnly, SameSite cookies for the Hub session; Hub bearer + tokens must not be stored in `localStorage`. +- State-changing Hub routes require CSRF protection and exact checks against the + configured public origin. The Hub refuses ambiguous forwarded-host/protocol + headers unless the reverse proxy is explicitly trusted. +- Recovery must not silently restore access to revoked machines. Recovering Hub + ownership and trusting a machine are separate events. + +The first release may be single-owner, but authorization must still use stable local +user ids so multi-user access can be added without changing machine identity. + +### Machine enrollment + +1. `crewcode enroll --hub ` creates or loads a machine identity key and prints + a short-lived device authorization URL/code. Use an OS keystore or TPM when + available, with an owner-only file fallback for headless systems. +2. The user signs in to their Hub, confirms the machine name and fingerprint, and + assigns the machine to an allowed local user. +3. The brain exchanges the approved device code for a revocable machine credential. + Only a digest/encrypted form is persisted, with owner-only filesystem permissions. +4. The Hub stores the machine public key, owner, display name, created time, + last-seen time, and revocation state. It never receives provider keys, workspace + credentials, source files, transcripts, or terminal output as control-plane data. +5. Re-enrollment and ownership transfer require explicit confirmation. A revoked + machine credential cannot be refreshed. + +Enrollment codes are single-use, short-lived, rate-limited, and bound to the machine +key. A copied code alone must not be enough to impersonate a machine. + +### Presence and discovery + +An enrolled brain opens an outbound `wss://` connection to its configured Hub, +proves possession of its machine key with a Hub nonce, and sends a bounded +capability/presence record. The machine list exposes only metadata such as: + +- stable opaque machine id and user-selected name; +- online, offline, connecting, or revoked status; +- platform, CrewCode version, protocol version, and coarse capabilities; +- last seen time and an optional user-selected location label. + +Workspace paths, repository names, active prompts, and provider identities are not +presence metadata. Presence expires when heartbeats stop; silence is `offline`, +never evidence that a command or agent turn completed. + +### Browser connection + +1. The signed-in browser selects a machine. +2. The Hub issues a very short-lived, single-use connection ticket bound to the + local user, browser session, machine id, requested protocol, and random nonce. +3. The browser and brain connect through the Hub relay. The brain validates the + signed ticket and rejects expired, replayed, revoked, wrong-audience, or + unauthorized-user tickets. +4. The browser and brain perform an authenticated end-to-end handshake using the + enrolled machine public key and a browser ephemeral key before privileged RPC is + enabled. +5. HTTP-style RPC and PTY/agent events are multiplexed as bounded tunnel frames. The + existing versioned request/response envelopes remain the application protocol. +6. Disconnecting marks in-flight outcomes `interrupted` unless the brain observed + and persisted a terminal result. Reconnect never infers success from silence. + +The Hub adapter belongs behind `crewcode-client.ts`. Components and hooks must not +know whether frames use direct HTTP/WebSocket or the Hub relay. + +### Network deployment + +The Hub binds to loopback by default and requires explicit network configuration. +Supported deployment profiles are: + +- **LAN:** bind the Hub to a private interface and use trusted local DNS/TLS. Access + works only from that network. +- **Tailnet (recommended):** keep the Hub private and publish HTTPS through + Tailscale. Browsers and brains join the tailnet; no public ingress is required. +- **User-controlled public origin:** use the owner's own domain and place Caddy, + nginx, or another HTTPS reverse proxy in front of the Hub. For example, CJ uses + `https://crewcode.logixhub.icu` for his deployment. The owner is responsible for + DNS, firewall configuration, and TLS renewal. +- **Reverse tunnel:** keep the Hub local and publish a user-controlled domain through + Cloudflare Tunnel or an equivalent service. This makes the service internet + reachable, and that provider becomes part of the network threat model. + +CrewCode must not automatically enable public exposure, edit firewall rules, or +create a third-party tunnel. Setup should print explicit commands and warnings for +the profile selected by the owner. + +### Relay privacy and limits + +TLS protects each network hop, but hop-by-hop TLS alone lets a reverse proxy, tunnel +provider, or Hub relay inspect source and terminal traffic. Application-layer +end-to-end encryption between browser and brain is required for public/reverse-tunnel +deployments and should be used in every profile; the relay routes opaque frames. +Metadata needed for abuse prevention (local user id, machine id, connection id, +frame size, timestamps, and close reason) may be logged with an owner-configurable +retention period. + +The relay must enforce per-user/machine connection limits, frame-size limits, idle +and absolute connection expiry, bandwidth backpressure, replay protection, and rate +limits before forwarding traffic. It must never accept arbitrary destination hosts +or become a general-purpose TCP proxy. + +### Revocation and custody + +- Owners can inspect and revoke browser sessions, users, and enrolled machines from + the Hub. +- A brain periodically revalidates machine status and immediately closes new and + active tunnels when revocation is observed. +- If identity, scope, relay continuity, or session authority becomes unknown, the + brain refuses new privileged actions and applies the execution-custody rules in + `docs/execution-custody.md`. +- Relay loss does not kill an agent blindly if doing so could corrupt work, but the + run must be contained, recorded as interrupted/unknown where its result was not + observed, and require the documented reauthorization path. +- Audit events record bootstrap, enrollment, connection, rejection, revocation, and + authority changes. They must not include prompts, source content, provider secrets, + or raw terminal streams. + +## Hub service boundary + +The Hub runs as a separate headless process rather than inside the Electron renderer +or main process. It may ship from this repository as `crewcode hub`, but its storage +and network lifecycle remain independent from any one brain. This repository owns +the shared protocol, Hub service, brain connector, CLI enrollment flow, and browser +adapter. No identity, proxy, or database vendor SDK may leak into renderer components +or backend workspace services. + +Minimum Hub data model: + +```text +LocalUser(id, credential, role, created_at, revoked_at) +Machine(id, owner_user_id, public_key, name, status, created_at, last_seen_at, revoked_at) +BrowserSession(id, user_id, created_at, expires_at, revoked_at) +ConnectionTicket(id, user_id, machine_id, browser_session_id, expires_at, used_at) +AuditEvent(id, user_id?, machine_id?, browser_session_id?, type, created_at, metadata) +``` + ## Delivery stages 1. Introduce the transport-neutral client boundary and versioned protocol types. **Complete.** @@ -44,20 +254,72 @@ session, and only then installs the privileged client adapter. 3. Add a loopback-only headless server and a minimal browser connection screen. **Core server, handshake, one-time pairing, authenticated RPC, and connection screen complete; CLI packaging remains.** 4. Add authenticated workspace/filesystem operations. **Browser adapter, pairing exchange, locally persisted device session, workspace listing, text editing, and saving complete.** 5. Add PTY and agent streaming over WebSockets. **PTY and core agent lifecycle services, authenticated event transport, browser chat/terminal controls, workspace-root enforcement, native resume IDs, local transcript fallback, compaction RPC, and permission responses complete. The full desktop shell is not mounted in browsers yet.** -6. Add pairing, session inspection/revocation, LAN endpoints, and Tailscale guidance. -7. Move the desktop application onto the same backend contract. +6. Harden direct mode: persistent expiring sessions, authenticated + inspection/revocation RPC, exact origin checks, and authentication rate limits + are **complete**. User-facing auth CLI commands, general request-rate policy, and + LAN/Tailscale guidance remain. +7. Implement the self-hosted `crewcode hub` process, local owner bootstrap, + passkey sessions, machine registry, audit events, and signed single-use tickets. + **Process/CLI, SQLite identity schema, passkey bootstrap/sign-in, browser sessions, + audit storage, and read-only machine-list skeleton are complete. Enrollment writes, + recovery, machine credentials, and signed connection tickets remain.** +8. Implement `crewcode enroll`, persistent machine identity, outbound presence, and + explicit machine revoke/logout commands. +9. Implement the bounded Hub relay and a transport-neutral multiplexed tunnel with + authenticated end-to-end browser-to-brain encryption. +10. Replace the direct-only browser connection screen with local Hub sign-in, + machine list/status, machine selection, reconnect, and revocation UI while + retaining an explicit direct-pairing route. +11. Persist remote execution custody and test disconnect, restart, revocation, + replay, cross-user isolation, relay compromise, and backpressure behavior. +12. Move the desktop application onto the same backend contract. ## CLI +Implemented direct-server commands: + ```bash npx crewcode@latest npx crewcode serve --host 127.0.0.1 -npx crewcode pair -npx crewcode auth sessions -npx crewcode auth revoke +npx crewcode serve --host 0.0.0.0 --public-origin https://your-hub.example +``` + +Implemented self-hosted Hub command: + +```bash +crewcode hub +crewcode hub --host 0.0.0.0 --public-origin https://your-hub.example +``` + +The Hub defaults to `127.0.0.1:3774`, stores state in `~/.crewcode/hub/hub.sqlite`, +and prints a ten-minute single-use owner setup URL on first launch. Interactive +terminals receive an OSC 8 clickable setup link plus the raw URL as a copy fallback. +Browsers normally treat `http://localhost` as a secure context, but some Linux +browser/passkey-provider combinations reject it with `InsecureLocalhostNotAllowed`; +use a current Chrome/Chromium build for local testing or the final HTTPS Hub origin. +Do not weaken the Hub CSP for extension-injected scripts or styles. Wildcard binds +require an explicit final public origin; non-loopback origins require HTTPS because +the origin is cryptographically bound to passkeys. Put a TLS reverse proxy or +Tailscale HTTPS in front of the HTTP listener for network deployment. + +Planned direct-auth and remaining Hub commands: + +```bash +crewcode pair +crewcode auth sessions +crewcode auth revoke +crewcode enroll --hub +crewcode hub machines +crewcode hub revoke ``` -The initial CLI distribution is implemented. From a checkout, run `npm run serve`; from a published package, run `npx crewcode@latest` or `crewcode serve`. It builds/serves the shared renderer, defaults to loopback, prints a single-use pairing URL, resolves installed provider CLIs without Electron, and shuts down cleanly on SIGINT/SIGTERM. `pair` and persistent `auth` management commands remain planned. +The initial CLI distribution is implemented. From a checkout, run `npm run serve`; +from a published package, run `npx crewcode@latest` or `crewcode serve`. It +builds/serves the shared renderer, defaults to loopback, prints a single-use pairing +URL, resolves installed provider CLIs without Electron, and shuts down cleanly on +SIGINT/SIGTERM. The direct-auth, enrollment, machine-management, and relay commands +above remain planned. `crewcode hub` has its own standalone setup/sign-in/machine-list +screen; it does not yet mount the shared CrewCode workspace client. ## Current backend extraction diff --git a/package.json b/package.json index 49693c4..2c9184a 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "fix-node-pty-permissions": "node scripts/fix-node-pty-permissions.mjs", "postinstall": "npm run fix-node-pty-permissions", "install-electron": "node node_modules/electron/install.js", - "sync-plugin-api": "node scripts/sync-plugin-api-helper.mjs", + "sync-plugin-api": "node packages/crewcode-plugin-api/scripts/sync-vendored.mjs", "crewcode": "node packages/crewcode-plugin-cli/bin/crewcode.mjs", "codemirror:install": "node packages/crew-codemirror/bin/cm.js install", "codemirror:build": "node packages/crew-codemirror/bin/cm.js build", diff --git a/src/main/headless.test.ts b/src/main/headless.test.ts index 2ccf972..68e6f58 100644 --- a/src/main/headless.test.ts +++ b/src/main/headless.test.ts @@ -8,11 +8,13 @@ describe('headless CLI options', () => { }) it('parses serve network and data options', () => { - expect(parseServeOptions(['serve', '--host', '0.0.0.0', '--port', '4000', '--data-dir', 'state', '--workspace-root', 'projects'], '/tmp')).toEqual({ host: '0.0.0.0', port: 4000, dataDir: resolve('/tmp', 'state'), webRoot: undefined, allowedWorkspaceRoots: [resolve('/tmp', 'projects')] }) + expect(parseServeOptions(['serve', '--host', '0.0.0.0', '--port', '4000', '--data-dir', 'state', '--workspace-root', 'projects', '--public-origin', 'https://crewcode.example'], '/tmp')).toEqual({ host: '0.0.0.0', port: 4000, dataDir: resolve('/tmp', 'state'), webRoot: undefined, allowedWorkspaceRoots: [resolve('/tmp', 'projects')], publicOrigins: ['https://crewcode.example'] }) }) - it('rejects invalid ports and unknown options', () => { + it('rejects invalid ports, public origins, and unknown options', () => { expect(() => parseServeOptions(['--port', '70000'])).toThrow('invalid port') + expect(() => parseServeOptions(['--public-origin', 'https://crewcode.example/path'])).toThrow('invalid public origin') + expect(() => parseServeOptions(['--public-origin', 'file:///tmp/hub'])).toThrow('invalid public origin') expect(() => parseServeOptions(['--public'])).toThrow('unknown option') }) }) diff --git a/src/main/headless.ts b/src/main/headless.ts index c067730..718a091 100644 --- a/src/main/headless.ts +++ b/src/main/headless.ts @@ -10,6 +10,7 @@ interface ServeOptions { dataDir: string webRoot?: string allowedWorkspaceRoots?: string[] + publicOrigins?: string[] } function usage(): string { @@ -24,6 +25,7 @@ Options: --data-dir Server state directory (default: ~/.crewcode) --web-root Built renderer directory --workspace-root Allow browser projects under this host directory (repeatable; default: home) + --public-origin Allow an exact browser origin behind a reverse proxy (repeatable) --help Show this help Examples: @@ -41,6 +43,15 @@ function valueAfter(argv: string[], index: number, flag: string): string { return value } +function exactPublicOrigin(value: string): string { + let url: URL + try { url = new URL(value) } catch { throw new Error(`invalid public origin: ${value}`) } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`invalid public origin: ${value}`) + } + return url.origin +} + export function parseServeOptions(argv: string[], cwd = process.cwd()): ServeOptions | { help: true } { const args = argv[0] === 'serve' ? argv.slice(1) : argv if (args.includes('--help') || args.includes('-h')) return { help: true } @@ -49,6 +60,7 @@ export function parseServeOptions(argv: string[], cwd = process.cwd()): ServeOpt let dataDir = join(homedir(), '.crewcode') let webRoot: string | undefined const allowedWorkspaceRoots: string[] = [] + const publicOrigins: string[] = [] for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--host') host = valueAfter(args, index++, arg) @@ -59,9 +71,17 @@ export function parseServeOptions(argv: string[], cwd = process.cwd()): ServeOpt } else if (arg === '--data-dir') dataDir = resolve(cwd, valueAfter(args, index++, arg)) else if (arg === '--web-root') webRoot = resolve(cwd, valueAfter(args, index++, arg)) else if (arg === '--workspace-root') allowedWorkspaceRoots.push(resolve(cwd, valueAfter(args, index++, arg))) + else if (arg === '--public-origin') publicOrigins.push(exactPublicOrigin(valueAfter(args, index++, arg))) else throw new Error(`unknown option: ${arg}`) } - return { host, port, dataDir, webRoot, allowedWorkspaceRoots: allowedWorkspaceRoots.length ? allowedWorkspaceRoots : undefined } + return { + host, + port, + dataDir, + webRoot, + allowedWorkspaceRoots: allowedWorkspaceRoots.length ? allowedWorkspaceRoots : undefined, + publicOrigins: publicOrigins.length ? publicOrigins : undefined, + } } function defaultWebRoot(): string | undefined { diff --git a/src/main/hub-auth.ts b/src/main/hub-auth.ts new file mode 100644 index 0000000..ef2bacc --- /dev/null +++ b/src/main/hub-auth.ts @@ -0,0 +1,143 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto' +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type PublicKeyCredentialCreationOptionsJSON, + type PublicKeyCredentialRequestOptionsJSON, + type RegistrationResponseJSON, +} from '@simplewebauthn/server' +import { HubStore, type HubUser } from './hub-store' + +const CHALLENGE_TTL_MS = 5 * 60_000 +export const HUB_BOOTSTRAP_TTL_MS = 10 * 60_000 +export const HUB_SESSION_TTL_MS = 12 * 60 * 60_000 + +interface PendingChallenge { + challenge: string + expiresAt: number + bootstrapDigest?: Buffer +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value).digest() +} + +function matches(value: string, expected: Buffer): boolean { + const actual = digest(value) + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +export class HubAuth { + private readonly registrationChallenges = new Map() + private readonly authenticationChallenges = new Map() + private bootstrapDigest: Buffer | null = null + private bootstrapExpiresAt = 0 + private bootstrapUsed = false + + constructor( + private readonly store: HubStore, + readonly publicOrigin: string, + private readonly now: () => number = Date.now, + ) {} + + get rpId(): string { return new URL(this.publicOrigin).hostname } + + issueBootstrap(): { token: string; expiresAt: number } | null { + if (this.store.owner()) return null + const token = randomBytes(32).toString('base64url') + this.bootstrapDigest = digest(token) + this.bootstrapExpiresAt = this.now() + HUB_BOOTSTRAP_TTL_MS + this.bootstrapUsed = false + return { token, expiresAt: this.bootstrapExpiresAt } + } + + async registrationOptions(token: string, username: string): Promise<{ flowId: string; options: PublicKeyCredentialCreationOptionsJSON }> { + this.requireBootstrap(token) + const normalized = username.trim() + if (!/^[\p{L}\p{N}_. -]{1,64}$/u.test(normalized)) throw new Error('owner name must be 1-64 letters, numbers, spaces, dots, underscores, or hyphens') + const options = await generateRegistrationOptions({ + rpName: 'CrewCode Hub', + rpID: this.rpId, + userName: normalized, + userDisplayName: normalized, + attestationType: 'none', + authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, + }) + const flowId = randomBytes(16).toString('hex') + this.registrationChallenges.set(flowId, { challenge: options.challenge, expiresAt: this.now() + CHALLENGE_TTL_MS, bootstrapDigest: digest(token) }) + return { flowId, options } + } + + async verifyRegistration(input: { token: string; flowId: string; username: string; response: RegistrationResponseJSON }): Promise<{ user: HubUser; token: string; csrf: string }> { + this.requireBootstrap(input.token) + const pending = this.registrationChallenges.get(input.flowId) + this.registrationChallenges.delete(input.flowId) + if (!pending || pending.expiresAt <= this.now() || !pending.bootstrapDigest || !matches(input.token, pending.bootstrapDigest)) throw new Error('registration challenge is invalid or expired') + const verification = await verifyRegistrationResponse({ + response: input.response, + expectedChallenge: pending.challenge, + expectedOrigin: this.publicOrigin, + expectedRPID: this.rpId, + requireUserVerification: true, + }) + if (!verification.verified || !verification.registrationInfo) throw new Error('passkey registration could not be verified') + const username = input.username.trim() + const user = this.store.createOwnerWithCredential({ + username, + credential: verification.registrationInfo.credential, + deviceType: verification.registrationInfo.credentialDeviceType, + backedUp: verification.registrationInfo.credentialBackedUp, + now: this.now(), + }) + this.bootstrapUsed = true + this.bootstrapDigest = null + const session = this.store.createSession(user.id, this.now(), HUB_SESSION_TTL_MS) + return { user, token: session.token, csrf: session.csrf } + } + + async authenticationOptions(): Promise<{ flowId: string; options: PublicKeyCredentialRequestOptionsJSON }> { + const owner = this.store.owner() + if (!owner) throw new Error('Hub owner setup is incomplete') + const credentials = this.store.credentialsForUser(owner.id) + const options = await generateAuthenticationOptions({ + rpID: this.rpId, + allowCredentials: credentials.map(credential => ({ id: credential.id, transports: credential.transports })), + userVerification: 'required', + }) + const flowId = randomBytes(16).toString('hex') + this.authenticationChallenges.set(flowId, { challenge: options.challenge, expiresAt: this.now() + CHALLENGE_TTL_MS }) + return { flowId, options } + } + + async verifyAuthentication(input: { flowId: string; response: AuthenticationResponseJSON }): Promise<{ user: HubUser; token: string; csrf: string }> { + const pending = this.authenticationChallenges.get(input.flowId) + this.authenticationChallenges.delete(input.flowId) + if (!pending || pending.expiresAt <= this.now()) throw new Error('authentication challenge is invalid or expired') + const credential = this.store.credential(input.response.id) + if (!credential) throw new Error('passkey is not registered with this Hub') + const verification = await verifyAuthenticationResponse({ + response: input.response, + expectedChallenge: pending.challenge, + expectedOrigin: this.publicOrigin, + expectedRPID: this.rpId, + credential: { id: credential.id, publicKey: new Uint8Array(credential.publicKey), counter: credential.counter, transports: credential.transports }, + requireUserVerification: true, + }) + if (!verification.verified) throw new Error('passkey authentication could not be verified') + this.store.updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter) + const owner = this.store.owner() + if (!owner || owner.id !== credential.userId) throw new Error('passkey owner is unavailable') + const session = this.store.createSession(owner.id, this.now(), HUB_SESSION_TTL_MS) + return { user: owner, token: session.token, csrf: session.csrf } + } + + private requireBootstrap(token: string): void { + if (this.store.owner()) throw new Error('Hub owner already exists') + if (this.bootstrapUsed || !this.bootstrapDigest || this.bootstrapExpiresAt <= this.now() || !matches(token, this.bootstrapDigest)) { + throw new Error('bootstrap token is invalid, expired, or already used') + } + } +} diff --git a/src/main/hub-server.test.ts b/src/main/hub-server.test.ts new file mode 100644 index 0000000..fc6b7d5 --- /dev/null +++ b/src/main/hub-server.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { startHubServer, type RunningHubServer } from './hub-server' +import { HubStore } from './hub-store' + +const cleanups: Array<() => void | Promise> = [] +afterEach(async () => { + while (cleanups.length) await cleanups.pop()?.() +}) + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'crewcode-hub-test-')) + cleanups.push(() => rmSync(directory, { recursive: true, force: true })) + return directory +} + +async function server(): Promise { + const running = await startHubServer({ dataDir: temporaryDirectory(), port: 0 }) + cleanups.push(() => running.close()) + return running +} + +describe('Hub store', () => { + it('persists an owner credential and protects session secrets with digests', () => { + const directory = temporaryDirectory() + const path = join(directory, 'hub.sqlite') + const store = new HubStore(path) + const owner = store.createOwnerWithCredential({ + username: 'Owner', + credential: { id: 'credential-id', publicKey: new Uint8Array([1, 2, 3]), counter: 0, transports: ['internal'] }, + deviceType: 'singleDevice', + backedUp: false, + now: 1_000, + }) + const created = store.createSession(owner.id, 2_000, 10_000) + expect(store.authenticateSession(created.token, 3_000)?.userId).toBe(owner.id) + expect(store.authenticateSession(`${created.session.id}.wrong`, 3_000)).toBeNull() + expect(store.validateCsrf(created.session.id, created.csrf)).toBe(true) + expect(store.revokeSession(created.session.id, 4_000)).toBe(true) + expect(store.authenticateSession(created.token, 5_000)).toBeNull() + store.close() + + const reopened = new HubStore(path) + expect(reopened.owner()?.username).toBe('Owner') + expect(reopened.credentialsForUser(owner.id)[0]?.publicKey).toEqual(new Uint8Array([1, 2, 3])) + reopened.close() + }) +}) + +describe('Hub HTTP security boundary', () => { + it('does not disclose the one-time bootstrap token through status', async () => { + const running = await server() + expect(running.bootstrapToken).toBeTruthy() + const response = await fetch(`${running.url}/api/v1/hub/status`) + const body = await response.text() + expect(response.status).toBe(200) + expect(body).not.toContain(running.bootstrapToken as string) + expect(JSON.parse(body)).toEqual({ service: 'crewcode-hub', protocolVersion: 1, ownerConfigured: false }) + }) + + it('rejects foreign browser origins and unauthenticated machine access', async () => { + const running = await server() + const foreign = await fetch(`${running.url}/api/v1/hub/status`, { headers: { origin: 'https://evil.example' } }) + expect(foreign.status).toBe(403) + const machines = await fetch(`${running.url}/api/v1/hub/machines`) + expect(machines.status).toBe(401) + }) + + it('trusts only the configured HTTPS RP origin, not the internal listener host', async () => { + const running = await startHubServer({ dataDir: temporaryDirectory(), port: 0, publicOrigin: 'https://crewcode.example' }) + cleanups.push(() => running.close()) + const internal = await fetch(`${running.url}/api/v1/hub/status`, { headers: { origin: running.url } }) + expect(internal.status).toBe(403) + const configured = await fetch(`${running.url}/api/v1/hub/status`, { headers: { origin: 'https://crewcode.example' } }) + expect(configured.status).toBe(200) + }) + + it('serves the standalone setup screen with a restrictive CSP', async () => { + const running = await server() + const response = await fetch(running.url) + expect(response.status).toBe(200) + expect(response.headers.get('content-security-policy')).toContain("script-src 'self'") + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") + expect(await response.text()).not.toContain('` +} + +const HUB_CSS = `:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0f120f;color:#d7e0dc}*{box-sizing:border-box}body{margin:0}main{min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(560px,100%);border:1px solid #1c2f2f;padding:28px;background:#0f120f}.eyebrow{font:600 11px/1.4 monospace;letter-spacing:.18em;color:#79958a}h1{margin:.25rem 0 1.25rem;font-size:26px}h2{font-size:15px;margin-top:24px}label{display:grid;gap:8px;margin:20px 0;font-size:13px}input,button{border:1px solid #285a48;background:#131a17;color:inherit;padding:10px 12px;font:inherit}button{cursor:pointer;background:#285a48}.quiet{background:transparent}.row{display:flex;align-items:center;justify-content:space-between;gap:16px}.machines{border-top:1px solid #1c2f2f;padding-top:14px;color:#8da49a;font:13px/1.5 monospace}.error{color:#d89595;min-height:1.4em}` + +const HUB_JS = `(()=>{'use strict'; +const $=id=>document.getElementById(id),status=$('status'),error=$('error');let csrf=''; +const b64=b=>{const bytes=new Uint8Array(b);let s='';for(const x of bytes)s+=String.fromCharCode(x);return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'')}; +const bytes=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');while(s.length%4)s+='=';const raw=atob(s);return Uint8Array.from(raw,c=>c.charCodeAt(0))}; +const json=async(url,opts={})=>{const r=await fetch(url,{...opts,headers:{'content-type':'application/json',...(opts.headers||{})}});const body=await r.json();if(!r.ok)throw new Error(body.error||('Request failed: '+r.status));return body}; +const credentialJSON=c=>({id:c.id,rawId:b64(c.rawId),type:c.type,authenticatorAttachment:c.authenticatorAttachment||undefined,clientExtensionResults:c.getClientExtensionResults(),response:c.response.attestationObject?{clientDataJSON:b64(c.response.clientDataJSON),attestationObject:b64(c.response.attestationObject),transports:c.response.getTransports?c.response.getTransports():[]}:{clientDataJSON:b64(c.response.clientDataJSON),authenticatorData:b64(c.response.authenticatorData),signature:b64(c.response.signature),userHandle:c.response.userHandle?b64(c.response.userHandle):undefined}}); +const creation=o=>({...o,challenge:bytes(o.challenge),user:{...o.user,id:bytes(o.user.id)},excludeCredentials:(o.excludeCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); +const request=o=>({...o,challenge:bytes(o.challenge),allowCredentials:(o.allowCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); +const authError=e=>{const message=e&&e.message?e.message:String(e);if(!window.isSecureContext)return'Passkeys require a secure browser context. Open the exact localhost URL printed by CrewCode, or use the configured HTTPS Hub origin.';if(message.includes('InsecureLocalhostNotAllowed'))return'This browser or passkey provider refuses passkeys over HTTP localhost. For local testing, try current Chrome or Chromium. Otherwise run the Hub at its final HTTPS origin and create the passkey there.';return message}; +function view(name){for(const id of ['setup','signin','dashboard'])$(id).hidden=id!==name} +async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines');$('machines').textContent=m.machines.length?m.machines.map(x=>x.name+' · '+x.status).join('\\n'):'No machines enrolled yet.'}catch{view('signin');status.textContent='Sign in to view your machines.'}} +$('setup-button').onclick=async()=>{try{error.textContent='';const token=new URLSearchParams(location.hash.slice(1)).get('bootstrap')||'';const username=$('owner').value;const start=await json('/api/v1/hub/bootstrap/options',{method:'POST',body:JSON.stringify({token,username})});const credential=await navigator.credentials.create({publicKey:creation(start.options)});const done=await json('/api/v1/hub/bootstrap/verify',{method:'POST',body:JSON.stringify({token,username,flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;history.replaceState(null,'',location.pathname);await refresh()}catch(e){error.textContent=authError(e)}}; +$('signin-button').onclick=async()=>{try{error.textContent='';const start=await json('/api/v1/hub/auth/options',{method:'POST',body:'{}'});const credential=await navigator.credentials.get({publicKey:request(start.options)});const done=await json('/api/v1/hub/auth/verify',{method:'POST',body:JSON.stringify({flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;await refresh()}catch(e){error.textContent=authError(e)}}; +$('logout-button').onclick=async()=>{try{await json('/api/v1/hub/logout',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});csrf='';await refresh()}catch(e){error.textContent=e.message}}; +refresh().catch(e=>{status.textContent='Could not connect';error.textContent=e.message});})();` + +function serveAsset(pathname: string, response: ServerResponse): boolean { + let body: string + let type: string + if (pathname === '/' || pathname === '/setup') { body = hubHtml(); type = 'text/html; charset=utf-8' } + else if (pathname === '/hub.css') { body = HUB_CSS; type = 'text/css; charset=utf-8' } + else if (pathname === '/hub.js') { body = HUB_JS; type = 'text/javascript; charset=utf-8' } + else return false + response.writeHead(200, { + 'content-type': type, + 'content-length': Buffer.byteLength(body), + 'cache-control': pathname === '/' ? 'no-store' : 'public, max-age=300', + 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }) + response.end(body) + return true +} + +export async function startHubServer(options: HubServerOptions): Promise { + const host = options.host ?? '127.0.0.1' + const now = options.now ?? Date.now + const store = new HubStore(join(options.dataDir, 'hub.sqlite')) + const authLimiter = new RemoteAccessRateLimiter(HUB_AUTH_ATTEMPTS_PER_MINUTE) + let publicOrigin = options.publicOrigin ?? '' + let auth: HubAuth + + const currentSession = (request: IncomingMessage): HubSession | null => { + const token = cookies(request).get(cookieName(publicOrigin)) ?? '' + return store.authenticateSession(token, now()) + } + + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname + if (pathname.startsWith('/api/') && !hubBrowserOriginAllowed(request, publicOrigin)) { + sendJson(response, 403, { error: 'browser origin is not allowed' }) + return + } + if (request.method === 'GET' && pathname === '/api/v1/hub/status') { + sendJson(response, 200, { service: 'crewcode-hub', protocolVersion: 1, ownerConfigured: store.owner() !== null }) + return + } + if (request.method === 'POST' && (pathname.startsWith('/api/v1/hub/bootstrap/') || pathname.startsWith('/api/v1/hub/auth/'))) { + const limited = authLimiter.consume(remotePeerKey(request), now()) + if (!limited.allowed) { + response.setHeader('retry-after', String(limited.retryAfterSeconds)) + sendJson(response, 429, { error: `too many authentication attempts; retry in ${limited.retryAfterSeconds}s` }) + return + } + } + if (request.method === 'POST' && pathname === '/api/v1/hub/bootstrap/options') { + const body = await readJson(request) + sendJson(response, 200, await auth.registrationOptions(String(body.token ?? ''), String(body.username ?? ''))) + return + } + if (request.method === 'POST' && pathname === '/api/v1/hub/bootstrap/verify') { + const body = await readJson(request) + const result = await auth.verifyRegistration({ token: String(body.token ?? ''), flowId: String(body.flowId ?? ''), username: String(body.username ?? ''), response: body.response as RegistrationResponseJSON }) + setSessionCookie(response, publicOrigin, result.token) + sendJson(response, 200, { user: result.user, csrf: result.csrf }) + return + } + if (request.method === 'POST' && pathname === '/api/v1/hub/auth/options') { + sendJson(response, 200, await auth.authenticationOptions()) + return + } + if (request.method === 'POST' && pathname === '/api/v1/hub/auth/verify') { + const body = await readJson(request) + const result = await auth.verifyAuthentication({ flowId: String(body.flowId ?? ''), response: body.response as AuthenticationResponseJSON }) + setSessionCookie(response, publicOrigin, result.token) + sendJson(response, 200, { user: result.user, csrf: result.csrf }) + return + } + if (request.method === 'GET' && pathname === '/api/v1/hub/session') { + const session = currentSession(request) + const owner = store.owner() + if (!session || !owner || session.userId !== owner.id) { sendJson(response, 401, { error: 'valid Hub session required' }); return } + sendJson(response, 200, { user: owner, csrf: store.rotateCsrf(session.id) }) + return + } + if (request.method === 'GET' && pathname === '/api/v1/hub/machines') { + const session = currentSession(request) + if (!session) { sendJson(response, 401, { error: 'valid Hub session required' }); return } + sendJson(response, 200, { machines: store.machinesForUser(session.userId) }) + return + } + if (request.method === 'POST' && pathname === '/api/v1/hub/logout') { + const session = currentSession(request) + if (!session) { sendJson(response, 401, { error: 'valid Hub session required' }); return } + const csrf = typeof request.headers['x-crewcode-csrf'] === 'string' ? request.headers['x-crewcode-csrf'] : '' + if (!store.validateCsrf(session.id, csrf)) { sendJson(response, 403, { error: 'valid CSRF token required' }); return } + store.revokeSession(session.id, now()) + clearSessionCookie(response, publicOrigin) + sendJson(response, 200, { ok: true }) + return + } + if (request.method === 'GET' && serveAsset(pathname, response)) return + sendJson(response, 404, { error: 'route not found' }) + } catch (error) { + sendJson(response, 400, { error: (error as Error).message }) + } + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(options.port ?? 0, host, () => { server.off('error', reject); resolve() }) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Hub did not bind a TCP address') + const displayHost = host === '127.0.0.1' ? 'localhost' : host === '0.0.0.0' ? '127.0.0.1' : host.includes(':') ? `[${host}]` : host + const url = `http://${displayHost}:${address.port}` + publicOrigin ||= url + auth = new HubAuth(store, publicOrigin, now) + const bootstrap = auth.issueBootstrap() + return { + host, + port: address.port, + url, + publicOrigin, + ...(bootstrap ? { bootstrapToken: bootstrap.token, bootstrapUrl: `${publicOrigin}/#bootstrap=${encodeURIComponent(bootstrap.token)}` } : {}), + close: () => new Promise((resolve, reject) => server.close(error => { + store.close() + error ? reject(error) : resolve() + })), + } +} diff --git a/src/main/hub-store.ts b/src/main/hub-store.ts new file mode 100644 index 0000000..41cb3f0 --- /dev/null +++ b/src/main/hub-store.ts @@ -0,0 +1,248 @@ +import { createHash, randomBytes } from 'crypto' +import { chmodSync, mkdirSync } from 'fs' +import { dirname } from 'path' +import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server' + +const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite') +type DatabaseSync = import('node:sqlite').DatabaseSync + +export interface HubUser { + id: string + username: string + role: 'owner' + createdAt: number +} + +export interface HubCredentialRecord { + id: string + userId: string + publicKey: Uint8Array + counter: number + transports?: AuthenticatorTransportFuture[] + deviceType: 'singleDevice' | 'multiDevice' + backedUp: boolean +} + +export interface HubSession { + id: string + userId: string + createdAt: number + expiresAt: number +} + +export interface HubMachineSummary { + id: string + name: string + status: 'offline' | 'online' | 'revoked' + platform: string | null + version: string | null + createdAt: number + lastSeenAt: number | null + revokedAt: number | null +} + +interface UserRow { id: string; username: string; role: string; created_at: number } +interface CredentialRow { + id: string + user_id: string + public_key: Uint8Array + counter: number + transports: string | null + device_type: string + backed_up: number +} +interface SessionRow { id: string; user_id: string; created_at: number; expires_at: number } +interface MachineRow { + id: string + name: string + status: string + platform: string | null + version: string | null + created_at: number + last_seen_at: number | null + revoked_at: number | null +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('base64') +} + +export class HubStore { + private readonly db: DatabaseSync + + constructor(readonly path: string) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + try { chmodSync(dirname(path), 0o700) } catch { /* no-op on Windows */ } + this.db = new DatabaseSync(path, { timeout: 5_000, enableForeignKeyConstraints: true, allowExtension: false }) + try { chmodSync(path, 0o600) } catch { /* no-op on Windows */ } + this.db.exec(` + PRAGMA journal_mode = DELETE; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS local_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + role TEXT NOT NULL CHECK(role = 'owner'), + created_at INTEGER NOT NULL, + revoked_at INTEGER + ) STRICT; + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES local_users(id), + public_key BLOB NOT NULL, + counter INTEGER NOT NULL, + transports TEXT, + device_type TEXT NOT NULL, + backed_up INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS browser_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES local_users(id), + token_digest TEXT NOT NULL, + csrf_digest TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER + ) STRICT; + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES local_users(id), + public_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'offline', + platform TEXT, + version TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER, + revoked_at INTEGER + ) STRICT; + CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + user_id TEXT, + machine_id TEXT, + type TEXT NOT NULL, + created_at INTEGER NOT NULL, + metadata TEXT NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS browser_sessions_digest ON browser_sessions(token_digest); + CREATE INDEX IF NOT EXISTS machines_owner ON machines(owner_user_id); + `) + } + + close(): void { this.db.close() } + + owner(): HubUser | null { + const row = this.db.prepare("SELECT id, username, role, created_at FROM local_users WHERE role = 'owner' AND revoked_at IS NULL LIMIT 1").get() as unknown as UserRow | undefined + return row ? { id: row.id, username: row.username, role: 'owner', createdAt: row.created_at } : null + } + + createOwnerWithCredential(input: { + username: string + credential: WebAuthnCredential + deviceType: 'singleDevice' | 'multiDevice' + backedUp: boolean + now: number + }): HubUser { + if (this.owner()) throw new Error('Hub owner already exists') + const user: HubUser = { id: randomBytes(16).toString('hex'), username: input.username, role: 'owner', createdAt: input.now } + this.db.exec('BEGIN IMMEDIATE') + try { + this.db.prepare('INSERT INTO local_users(id, username, role, created_at) VALUES (?, ?, ?, ?)') + .run(user.id, user.username, user.role, user.createdAt) + this.db.prepare('INSERT INTO webauthn_credentials(id, user_id, public_key, counter, transports, device_type, backed_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(input.credential.id, user.id, input.credential.publicKey, input.credential.counter, JSON.stringify(input.credential.transports ?? []), input.deviceType, input.backedUp ? 1 : 0, input.now) + this.audit('hub.owner.created', user.id, null, { username: user.username }, input.now) + this.db.exec('COMMIT') + return user + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + credentialsForUser(userId: string): HubCredentialRecord[] { + const rows = this.db.prepare('SELECT id, user_id, public_key, counter, transports, device_type, backed_up FROM webauthn_credentials WHERE user_id = ?').all(userId) as unknown as CredentialRow[] + return rows.map(row => ({ + id: row.id, + userId: row.user_id, + publicKey: new Uint8Array(row.public_key), + counter: row.counter, + transports: JSON.parse(row.transports ?? '[]') as AuthenticatorTransportFuture[], + deviceType: row.device_type === 'multiDevice' ? 'multiDevice' : 'singleDevice', + backedUp: row.backed_up === 1, + })) + } + + credential(id: string): HubCredentialRecord | null { + const row = this.db.prepare('SELECT id, user_id, public_key, counter, transports, device_type, backed_up FROM webauthn_credentials WHERE id = ?').get(id) as unknown as CredentialRow | undefined + if (!row) return null + return { + id: row.id, + userId: row.user_id, + publicKey: new Uint8Array(row.public_key), + counter: row.counter, + transports: JSON.parse(row.transports ?? '[]') as AuthenticatorTransportFuture[], + deviceType: row.device_type === 'multiDevice' ? 'multiDevice' : 'singleDevice', + backedUp: row.backed_up === 1, + } + } + + updateCredentialCounter(id: string, counter: number): void { + this.db.prepare('UPDATE webauthn_credentials SET counter = ? WHERE id = ?').run(counter, id) + } + + createSession(userId: string, now: number, ttlMs: number): { session: HubSession; token: string; csrf: string } { + const token = randomBytes(32).toString('base64url') + const csrf = randomBytes(24).toString('base64url') + const session: HubSession = { id: randomBytes(16).toString('hex'), userId, createdAt: now, expiresAt: now + ttlMs } + this.db.prepare('INSERT INTO browser_sessions(id, user_id, token_digest, csrf_digest, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(session.id, userId, digest(token), digest(csrf), session.createdAt, session.expiresAt) + this.audit('hub.session.created', userId, null, { sessionId: session.id }, now) + return { session, token: `${session.id}.${token}`, csrf } + } + + authenticateSession(token: string, now: number): HubSession | null { + const separator = token.indexOf('.') + if (separator < 1) return null + const id = token.slice(0, separator) + const secret = token.slice(separator + 1) + const row = this.db.prepare('SELECT id, user_id, created_at, expires_at FROM browser_sessions WHERE id = ? AND token_digest = ? AND revoked_at IS NULL AND expires_at > ?') + .get(id, digest(secret), now) as unknown as SessionRow | undefined + return row ? { id: row.id, userId: row.user_id, createdAt: row.created_at, expiresAt: row.expires_at } : null + } + + rotateCsrf(sessionId: string): string { + const csrf = randomBytes(24).toString('base64url') + this.db.prepare('UPDATE browser_sessions SET csrf_digest = ? WHERE id = ? AND revoked_at IS NULL').run(digest(csrf), sessionId) + return csrf + } + + validateCsrf(sessionId: string, csrf: string): boolean { + const row = this.db.prepare('SELECT 1 AS ok FROM browser_sessions WHERE id = ? AND csrf_digest = ? AND revoked_at IS NULL').get(sessionId, digest(csrf)) as { ok: number } | undefined + return row?.ok === 1 + } + + revokeSession(sessionId: string, now: number): boolean { + const result = this.db.prepare('UPDATE browser_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now, sessionId) + return Number(result.changes) === 1 + } + + machinesForUser(userId: string): HubMachineSummary[] { + const rows = this.db.prepare('SELECT id, name, status, platform, version, created_at, last_seen_at, revoked_at FROM machines WHERE owner_user_id = ? ORDER BY name COLLATE NOCASE').all(userId) as unknown as MachineRow[] + return rows.map(row => ({ + id: row.id, + name: row.name, + status: row.revoked_at ? 'revoked' : row.status === 'online' ? 'online' : 'offline', + platform: row.platform, + version: row.version, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + revokedAt: row.revoked_at, + })) + } + + audit(type: string, userId: string | null, machineId: string | null, metadata: Record, now: number): void { + this.db.prepare('INSERT INTO audit_events(id, user_id, machine_id, type, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?)') + .run(randomBytes(16).toString('hex'), userId, machineId, type, now, JSON.stringify(metadata)) + } +} diff --git a/src/main/hub.test.ts b/src/main/hub.test.ts new file mode 100644 index 0000000..8a2a676 --- /dev/null +++ b/src/main/hub.test.ts @@ -0,0 +1,33 @@ +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { normalizeHubOrigin, parseHubOptions, terminalLink } from './hub' + +describe('Hub CLI options', () => { + it('prints a clickable terminal link with a plain-text fallback', () => { + expect(terminalLink('Open setup', 'http://localhost:3774/#bootstrap=secret', true)).toBe('\u001B]8;;http://localhost:3774/#bootstrap=secret\u0007Open setup\u001B]8;;\u0007') + expect(terminalLink('Open setup', 'http://localhost:3774/#bootstrap=secret', false)).toBe('http://localhost:3774/#bootstrap=secret') + }) + it('uses safe loopback defaults', () => { + expect(parseHubOptions([], '/tmp')).toMatchObject({ host: '127.0.0.1', port: 3774 }) + }) + + it('parses an explicit network deployment', () => { + expect(parseHubOptions(['hub', '--host', '0.0.0.0', '--port', '4444', '--data-dir', 'state', '--public-origin', 'https://crewcode.example'], '/tmp')).toEqual({ + host: '0.0.0.0', + port: 4444, + dataDir: resolve('/tmp', 'state'), + publicOrigin: 'https://crewcode.example', + }) + }) + + it('requires a final public origin for wildcard binds', () => { + expect(() => parseHubOptions(['--host', '0.0.0.0'])).toThrow('--public-origin is required') + }) + + it('only accepts secure or loopback browser origins', () => { + expect(normalizeHubOrigin('https://crewcode.example/')).toBe('https://crewcode.example') + expect(normalizeHubOrigin('http://localhost:3774')).toBe('http://localhost:3774') + expect(() => normalizeHubOrigin('http://crewcode.example')).toThrow('use HTTPS') + expect(() => normalizeHubOrigin('https://crewcode.example/path')).toThrow('no path') + }) +}) diff --git a/src/main/hub.ts b/src/main/hub.ts new file mode 100644 index 0000000..0853aa7 --- /dev/null +++ b/src/main/hub.ts @@ -0,0 +1,97 @@ +import { homedir } from 'os' +import { join, resolve } from 'path' +import { startHubServer } from './hub-server' + +export interface HubCliOptions { + host: string + port: number + dataDir: string + publicOrigin?: string +} + +function usage(): string { + return `CrewCode self-hosted Hub + +Usage: + crewcode hub [options] + +Options: + --host
Bind address (default: 127.0.0.1) + --port TCP port, 0 chooses an available port (default: 3774) + --data-dir Hub state directory (default: ~/.crewcode/hub) + --public-origin Final HTTPS browser origin (required for network binds) + --help Show this help + +Examples: + crewcode hub + crewcode hub --host 0.0.0.0 --public-origin https://crewcode.example + +The public origin is cryptographically bound to passkeys. Choose the final LAN, +Tailscale, or user-controlled HTTPS name before creating the owner passkey.` +} + +function valueAfter(argv: string[], index: number, flag: string): string { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`) + return value +} + +export function normalizeHubOrigin(value: string): string { + let url: URL + try { url = new URL(value) } catch { throw new Error(`invalid public origin: ${value}`) } + const localHttp = url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') + if ((!localHttp && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`invalid public origin: ${value}; use HTTPS or loopback HTTP with no path`) + } + return url.origin +} + +export function parseHubOptions(argv: string[], cwd = process.cwd()): HubCliOptions | { help: true } { + const args = argv[0] === 'hub' ? argv.slice(1) : argv + if (args.includes('--help') || args.includes('-h')) return { help: true } + let host = '127.0.0.1' + let port = 3774 + let dataDir = join(homedir(), '.crewcode', 'hub') + let publicOrigin: string | undefined + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--host') host = valueAfter(args, index++, arg) + else if (arg === '--port') { + const raw = valueAfter(args, index++, arg) + port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error(`invalid port: ${raw}`) + } else if (arg === '--data-dir') dataDir = resolve(cwd, valueAfter(args, index++, arg)) + else if (arg === '--public-origin') publicOrigin = normalizeHubOrigin(valueAfter(args, index++, arg)) + else throw new Error(`unknown option: ${arg}`) + } + if ((host === '0.0.0.0' || host === '::') && !publicOrigin) throw new Error('--public-origin is required for network Hub binds') + return { host, port, dataDir, publicOrigin } +} + +export function terminalLink(label: string, url: string, isTerminal = Boolean(process.stdout.isTTY)): string { + return isTerminal ? `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007` : url +} + +export async function runHub(argv = process.argv.slice(2)): Promise { + const parsed = parseHubOptions(argv) + if ('help' in parsed) { console.log(usage()); return } + const hub = await startHubServer(parsed) + console.log(`CrewCode Hub listening on ${hub.url}`) + console.log(`Hub browser origin: ${hub.publicOrigin}`) + if (hub.bootstrapUrl) { + console.log(`Create the first owner passkey (single use, expires in 10 minutes):\n${terminalLink('Open owner passkey setup', hub.bootstrapUrl)}`) + if (process.stdout.isTTY) console.log(`If the link is not clickable, copy this URL:\n${hub.bootstrapUrl}`) + } + else console.log('Hub owner is configured. Sign in with a registered passkey.') + if (parsed.host === '0.0.0.0' || parsed.host === '::') console.warn('Network access is enabled. Terminate TLS at the configured public origin.') + const shutdown = (): void => { void hub.close().finally(() => process.exit(0)) } + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) +} + +if (require.main === module) { + void runHub().catch(error => { + console.error((error as Error).message) + process.exitCode = 1 + }) +} diff --git a/src/main/remote-access-auth.test.ts b/src/main/remote-access-auth.test.ts new file mode 100644 index 0000000..4f726ca --- /dev/null +++ b/src/main/remote-access-auth.test.ts @@ -0,0 +1,71 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { mkdtempSync } from 'fs' +import { describe, expect, it } from 'vitest' +import { RemoteAccessAuth } from './remote-access-auth' + +function exchange(auth: RemoteAccessAuth): { token: string; id: string } { + const pairing = auth.issuePairing() + const result = auth.exchange(pairing.token) + if (!result.sessionToken || !result.sessionId) throw new Error(result.error ?? 'exchange failed') + return { token: result.sessionToken, id: result.sessionId } +} + +describe('RemoteAccessAuth', () => { + it('persists only hashed sessions with owner-only permissions and restores them', () => { + const directory = mkdtempSync(join(tmpdir(), 'crewcode-remote-auth-')) + const storePath = join(directory, 'sessions.json') + const auth = new RemoteAccessAuth({ storePath }) + const session = exchange(auth) + + expect(existsSync(storePath)).toBe(true) + const stored = readFileSync(storePath, 'utf8') + expect(stored).not.toContain(session.token) + if (process.platform !== 'win32') expect(statSync(storePath).mode & 0o777).toBe(0o600) + + const restored = new RemoteAccessAuth({ storePath }) + expect(restored.authenticate(session.token)).toBe(true) + expect(restored.list()).toMatchObject([{ id: session.id, status: 'active' }]) + }) + + it('enforces absolute and idle expiry', () => { + let now = 1_000 + const absolute = new RemoteAccessAuth({ now: () => now, sessionTtlMs: 100, idleTtlMs: 1_000 }) + const absoluteSession = exchange(absolute) + now = 1_100 + expect(absolute.authenticate(absoluteSession.token)).toBe(false) + expect(absolute.list()[0].status).toBe('expired') + + now = 2_000 + const idle = new RemoteAccessAuth({ now: () => now, sessionTtlMs: 10_000, idleTtlMs: 100 }) + const idleSession = exchange(idle) + now = 2_100 + expect(idle.authenticate(idleSession.token)).toBe(false) + expect(idle.list()[0].status).toBe('expired') + }) + + it('revokes a session without exposing its digest', () => { + let now = 5_000 + const auth = new RemoteAccessAuth({ now: () => now }) + const session = exchange(auth) + now += 1 + expect(auth.revoke(session.id)).toBe(true) + expect(auth.revoke(session.id)).toBe(false) + expect(auth.authenticate(session.token)).toBe(false) + expect(auth.list()).toEqual([expect.objectContaining({ id: session.id, status: 'revoked', revokedAt: now })]) + expect(JSON.stringify(auth.list())).not.toContain(session.token) + }) + + it('fails closed when the persisted credential file is corrupt', () => { + const directory = mkdtempSync(join(tmpdir(), 'crewcode-remote-auth-corrupt-')) + const storePath = join(directory, 'sessions.json') + const auth = new RemoteAccessAuth({ storePath }) + const session = exchange(auth) + writeFileSync(storePath, '{ broken', 'utf8') + + const restored = new RemoteAccessAuth({ storePath }) + expect(restored.authenticate(session.token)).toBe(false) + expect(restored.list()).toEqual([]) + }) +}) diff --git a/src/main/remote-access-auth.ts b/src/main/remote-access-auth.ts index b14258c..36ae64a 100644 --- a/src/main/remote-access-auth.ts +++ b/src/main/remote-access-auth.ts @@ -1,4 +1,6 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import { dirname } from 'path' interface PairingCredential { digest: Buffer @@ -7,11 +9,39 @@ interface PairingCredential { } interface DeviceSession { + id: string digest: Buffer createdAt: number lastSeenAt: number + expiresAt: number + revokedAt?: number +} + +interface PersistedAuthFile { + version: 1 + sessions: Array & { digest: string }> } +export interface RemoteAccessSessionInfo { + id: string + createdAt: number + lastSeenAt: number + expiresAt: number + status: 'active' | 'expired' | 'revoked' + revokedAt?: number +} + +export interface RemoteAccessAuthOptions { + storePath?: string + sessionTtlMs?: number + idleTtlMs?: number + now?: () => number +} + +const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60_000 +const DEFAULT_IDLE_TTL_MS = 7 * 24 * 60 * 60_000 +const LAST_SEEN_PERSIST_INTERVAL_MS = 60_000 + function digest(value: string): Buffer { return createHash('sha256').update(value).digest() } @@ -21,42 +51,129 @@ function matches(value: string, expected: Buffer): boolean { return actual.length === expected.length && timingSafeEqual(actual, expected) } -/** In-memory credentials intentionally expire when the server process exits. */ +/** Pairing credentials stay in memory; hashed device sessions may persist across restarts. */ export class RemoteAccessAuth { private readonly pairings = new Map() private readonly sessions = new Map() + private readonly storePath?: string + private readonly sessionTtlMs: number + private readonly idleTtlMs: number + private readonly now: () => number + private readonly persistedLastSeen = new Map() + + constructor(options: RemoteAccessAuthOptions = {}) { + this.storePath = options.storePath + this.sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS + this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS + this.now = options.now ?? Date.now + this.load() + } issuePairing(ttlMs = 10 * 60_000): { token: string; expiresAt: number } { const token = randomBytes(24).toString('base64url') const id = randomBytes(8).toString('hex') - const expiresAt = Date.now() + ttlMs + const expiresAt = this.now() + ttlMs this.pairings.set(id, { digest: digest(token), expiresAt, used: false }) return { token: `${id}.${token}`, expiresAt } } - exchange(pairingToken: string): { sessionToken?: string; error?: string } { + exchange(pairingToken: string): { sessionToken?: string; sessionId?: string; expiresAt?: number; error?: string } { const separator = pairingToken.indexOf('.') if (separator < 1) return { error: 'invalid pairing token' } const id = pairingToken.slice(0, separator) const secret = pairingToken.slice(separator + 1) const pairing = this.pairings.get(id) - if (!pairing || pairing.used || pairing.expiresAt < Date.now() || !matches(secret, pairing.digest)) { + const now = this.now() + if (!pairing || pairing.used || pairing.expiresAt < now || !matches(secret, pairing.digest)) { return { error: 'pairing token is invalid, expired, or already used' } } pairing.used = true const sessionToken = randomBytes(32).toString('base64url') const sessionId = randomBytes(8).toString('hex') - const now = Date.now() - this.sessions.set(sessionId, { digest: digest(sessionToken), createdAt: now, lastSeenAt: now }) - return { sessionToken: `${sessionId}.${sessionToken}` } + const expiresAt = now + this.sessionTtlMs + this.sessions.set(sessionId, { id: sessionId, digest: digest(sessionToken), createdAt: now, lastSeenAt: now, expiresAt }) + this.persistedLastSeen.set(sessionId, now) + this.flush() + return { sessionToken: `${sessionId}.${sessionToken}`, sessionId, expiresAt } } authenticate(sessionToken: string): boolean { const separator = sessionToken.indexOf('.') if (separator < 1) return false - const session = this.sessions.get(sessionToken.slice(0, separator)) - if (!session || !matches(sessionToken.slice(separator + 1), session.digest)) return false - session.lastSeenAt = Date.now() + const id = sessionToken.slice(0, separator) + const session = this.sessions.get(id) + const now = this.now() + if (!session || session.revokedAt || session.expiresAt <= now || session.lastSeenAt + this.idleTtlMs <= now) return false + if (!matches(sessionToken.slice(separator + 1), session.digest)) return false + session.lastSeenAt = now + const persistedAt = this.persistedLastSeen.get(id) ?? 0 + if (now - persistedAt >= LAST_SEEN_PERSIST_INTERVAL_MS) { + this.persistedLastSeen.set(id, now) + this.flush() + } + return true + } + + list(): RemoteAccessSessionInfo[] { + const now = this.now() + return [...this.sessions.values()] + .map(session => ({ + id: session.id, + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + expiresAt: session.expiresAt, + status: session.revokedAt ? 'revoked' as const : session.expiresAt <= now || session.lastSeenAt + this.idleTtlMs <= now ? 'expired' as const : 'active' as const, + ...(session.revokedAt ? { revokedAt: session.revokedAt } : {}), + })) + .sort((left, right) => right.createdAt - left.createdAt) + } + + revoke(sessionId: string): boolean { + const session = this.sessions.get(sessionId) + if (!session || session.revokedAt) return false + session.revokedAt = this.now() + this.flush() return true } + + private load(): void { + if (!this.storePath || !existsSync(this.storePath)) return + try { + const parsed = JSON.parse(readFileSync(this.storePath, 'utf8')) as Partial + if (parsed.version !== 1 || !Array.isArray(parsed.sessions)) return + for (const value of parsed.sessions) { + if (!value || typeof value.id !== 'string' || typeof value.digest !== 'string') continue + if (![value.createdAt, value.lastSeenAt, value.expiresAt].every(Number.isFinite)) continue + if (value.revokedAt !== undefined && !Number.isFinite(value.revokedAt)) continue + const session: DeviceSession = { + id: value.id, + digest: Buffer.from(value.digest, 'base64'), + createdAt: value.createdAt, + lastSeenAt: value.lastSeenAt, + expiresAt: value.expiresAt, + ...(value.revokedAt === undefined ? {} : { revokedAt: value.revokedAt }), + } + if (session.digest.length !== 32) continue + this.sessions.set(session.id, session) + this.persistedLastSeen.set(session.id, session.lastSeenAt) + } + } catch { + // Refuse unknown/corrupt persisted credentials rather than failing the server + // open. A later successful pairing replaces the store with valid state. + this.sessions.clear() + this.persistedLastSeen.clear() + } + } + + private flush(): void { + if (!this.storePath) return + mkdirSync(dirname(this.storePath), { recursive: true }) + const payload: PersistedAuthFile = { + version: 1, + sessions: [...this.sessions.values()].map(session => ({ ...session, digest: session.digest.toString('base64') })), + } + const temporary = `${this.storePath}.${process.pid}.tmp` + writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }) + renameSync(temporary, this.storePath) + } } diff --git a/src/main/remote-access-security.test.ts b/src/main/remote-access-security.test.ts new file mode 100644 index 0000000..ff799b1 --- /dev/null +++ b/src/main/remote-access-security.test.ts @@ -0,0 +1,53 @@ +import type { IncomingMessage } from 'http' +import { describe, expect, it } from 'vitest' +import { + browserOriginAllowed, + RemoteAccessRateLimiter, +} from './remote-access-security' + +function request(origin?: string, host = '127.0.0.1:3773'): IncomingMessage { + return { headers: { ...(origin === undefined ? {} : { origin }), host } } as IncomingMessage +} + +describe('remote access browser origin checks', () => { + it('accepts CLI traffic without Origin and exact same-origin browsers', () => { + expect(browserOriginAllowed(request())).toBe(true) + expect(browserOriginAllowed(request('http://127.0.0.1:3773'))).toBe(true) + }) + + it('rejects cross-origin, null, malformed, and path-bearing origins', () => { + expect(browserOriginAllowed(request('https://evil.example'))).toBe(false) + expect(browserOriginAllowed(request('null'))).toBe(false) + expect(browserOriginAllowed(request('not a url'))).toBe(false) + expect(browserOriginAllowed(request('https://hub.example/path'))).toBe(false) + }) + + it('accepts an explicitly configured reverse-proxy origin', () => { + expect(browserOriginAllowed(request('https://crewcode.example'), ['https://crewcode.example'])).toBe(true) + }) +}) + +describe('RemoteAccessRateLimiter', () => { + it('refuses attempts over the fixed-window limit and resets afterward', () => { + const limiter = new RemoteAccessRateLimiter(2, 1_000) + expect(limiter.consume('peer', 10_000).allowed).toBe(true) + expect(limiter.consume('peer', 10_000).allowed).toBe(true) + expect(limiter.consume('peer', 10_000)).toMatchObject({ allowed: false, retryAfterSeconds: 1 }) + expect(limiter.consume('peer', 11_000).allowed).toBe(true) + }) + + it('keeps independent peer budgets', () => { + const limiter = new RemoteAccessRateLimiter(1) + expect(limiter.consume('one', 1).allowed).toBe(true) + expect(limiter.consume('one', 1).allowed).toBe(false) + expect(limiter.consume('two', 1).allowed).toBe(true) + }) + + it('bounds remembered peer windows', () => { + const limiter = new RemoteAccessRateLimiter(1, 60_000, 2) + expect(limiter.consume('one', 1).allowed).toBe(true) + expect(limiter.consume('two', 1).allowed).toBe(true) + expect(limiter.consume('three', 1).allowed).toBe(true) + expect(limiter.consume('one', 1).allowed).toBe(true) + }) +}) diff --git a/src/main/remote-access-security.ts b/src/main/remote-access-security.ts new file mode 100644 index 0000000..b4dfb43 --- /dev/null +++ b/src/main/remote-access-security.ts @@ -0,0 +1,80 @@ +import type { IncomingMessage } from 'http' + +export const REMOTE_AUTH_RATE_WINDOW_MS = 60_000 +export const REMOTE_PAIR_ATTEMPTS_PER_WINDOW = 10 +export const REMOTE_UNAUTHENTICATED_ATTEMPTS_PER_WINDOW = 60 + +interface RateWindow { + startedAt: number + attempts: number +} + +export interface RateLimitResult { + allowed: boolean + retryAfterSeconds: number +} + +/** Small fixed-window limiter for authentication boundaries, keyed by peer address. */ +export class RemoteAccessRateLimiter { + private readonly windows = new Map() + + constructor( + private readonly limit: number, + private readonly windowMs = REMOTE_AUTH_RATE_WINDOW_MS, + private readonly maxPeers = 10_000, + ) {} + + consume(key: string, now = Date.now()): RateLimitResult { + if (!this.windows.has(key) && this.windows.size >= this.maxPeers) { + for (const [peer, window] of this.windows) { + if (now - window.startedAt >= this.windowMs) this.windows.delete(peer) + } + while (this.windows.size >= this.maxPeers) { + const oldest = this.windows.keys().next().value as string | undefined + if (oldest === undefined) break + this.windows.delete(oldest) + } + } + const previous = this.windows.get(key) + const current = !previous || now - previous.startedAt >= this.windowMs + ? { startedAt: now, attempts: 0 } + : previous + current.attempts += 1 + this.windows.set(key, current) + return { + allowed: current.attempts <= this.limit, + retryAfterSeconds: Math.max(1, Math.ceil((current.startedAt + this.windowMs - now) / 1000)), + } + } +} + +function normalizedOrigin(value: string): string | null { + try { + const url = new URL(value) + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null + return url.origin + } catch { + return null + } +} + +/** + * Browser requests always carry Origin for cross-origin POST and WebSocket + * handshakes. Non-browser CLI requests may omit it. A supplied origin must match + * either the exact request Host over HTTP or an explicitly configured public URL. + */ +export function browserOriginAllowed(request: IncomingMessage, publicOrigins: readonly string[] = []): boolean { + const supplied = request.headers.origin + if (supplied === undefined) return true + if (Array.isArray(supplied) || supplied === 'null') return false + const origin = normalizedOrigin(supplied) + if (!origin) return false + const allowed = new Set(publicOrigins.map(normalizedOrigin).filter((value): value is string => value !== null)) + const host = request.headers.host + if (host) allowed.add(`http://${host}`) + return allowed.has(origin) +} + +export function remotePeerKey(request: IncomingMessage): string { + return request.socket.remoteAddress ?? 'unknown' +} diff --git a/src/main/remote-access-server.test.ts b/src/main/remote-access-server.test.ts index 8cea95b..b90de64 100644 --- a/src/main/remote-access-server.test.ts +++ b/src/main/remote-access-server.test.ts @@ -149,4 +149,66 @@ describe('remote access server', () => { expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ ok: false, error: { code: 'FORBIDDEN' } }) }) + + it('lists sanitized sessions and revokes them through authenticated RPC', async () => { + const server = await start() + const pair = await fetch(`${server.url}/api/v1/pair`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: server.pairingToken }) }) + const { sessionToken } = await pair.json() as { sessionToken: string } + const sessionId = sessionToken.slice(0, sessionToken.indexOf('.')) + const rpc = (id: string, method: string, params: Record) => fetch(`${server.url}/api/v1/rpc`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ protocolVersion: 1, id, method, params }), + }) + + const listed = await rpc('sessions', 'auth.sessions', {}) + const listedBody = await listed.json() + expect(listedBody).toMatchObject({ ok: true, result: [{ id: sessionId, status: 'active' }] }) + expect(JSON.stringify(listedBody)).not.toContain(sessionToken) + expect(await (await rpc('revoke', 'auth.revoke', { sessionId })).json()).toMatchObject({ ok: true, result: { revoked: true } }) + expect((await rpc('after-revoke', 'workspaces.list', {})).status).toBe(401) + }) + + it('restores device sessions when the server restarts with the same data directory', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'crewcode-remote-persist-')) + running = await startRemoteAccessServer({ dataDir, allowedWorkspaceRoots: [tmpdir()] }) + const pair = await fetch(`${running.url}/api/v1/pair`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: running.pairingToken }) }) + const { sessionToken } = await pair.json() as { sessionToken: string } + await running.close() + running = await startRemoteAccessServer({ dataDir, allowedWorkspaceRoots: [tmpdir()] }) + + const response = await fetch(`${running.url}/api/v1/rpc`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ protocolVersion: 1, id: 'restored', method: 'workspaces.list', params: {} }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, id: 'restored' }) + }) + + it('rejects cross-origin browser API requests while allowing exact same-origin requests', async () => { + const server = await start() + const rejected = await fetch(`${server.url}/api/v1/pair`, { + method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://evil.example' }, + body: JSON.stringify({ token: server.pairingToken }), + }) + expect(rejected.status).toBe(403) + + const accepted = await fetch(`${server.url}/api/v1/pair`, { + method: 'POST', headers: { 'content-type': 'application/json', origin: server.url }, + body: JSON.stringify({ token: server.pairingToken }), + }) + expect(accepted.status).toBe(200) + }) + + it('rate limits repeated pairing attempts by peer address', async () => { + const server = await start() + const statuses: number[] = [] + for (let index = 0; index < 11; index += 1) { + const response = await fetch(`${server.url}/api/v1/pair`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: 'invalid.token' }), + }) + statuses.push(response.status) + } + expect(statuses.slice(0, 10)).toEqual(Array(10).fill(401)) + expect(statuses[10]).toBe(429) + }) }) diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index b2cfd40..a50c924 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -12,6 +12,13 @@ import { } from '../shared/remote-access-types' import { FilesystemService } from './filesystem-service' import { RemoteAccessAuth } from './remote-access-auth' +import { + browserOriginAllowed, + REMOTE_PAIR_ATTEMPTS_PER_WINDOW, + REMOTE_UNAUTHENTICATED_ATTEMPTS_PER_WINDOW, + remotePeerKey, + RemoteAccessRateLimiter, +} from './remote-access-security' import { WorkspaceService } from './workspace-service' import { PtyService } from './pty-service' import { AgentBridgeService, type AgentPathResolver } from './agents/bridge-service' @@ -35,6 +42,8 @@ export interface RemoteAccessServerOptions { resolveAgentPath?: AgentPathResolver /** Host directories that paired browsers may discover/register/create under. */ allowedWorkspaceRoots?: string[] + /** Exact HTTPS/HTTP browser origins accepted when a reverse proxy changes Host. */ + publicOrigins?: string[] } export interface RunningRemoteAccessServer { @@ -126,7 +135,9 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const ptyService = new PtyService() const transcriptService = new TranscriptService(options.dataDir) const agentService = new AgentBridgeService(options.resolveAgentPath ?? (() => null)) - const auth = options.auth ?? new RemoteAccessAuth() + const auth = options.auth ?? new RemoteAccessAuth({ storePath: join(options.dataDir, 'remote-access-sessions.json') }) + const pairingLimiter = new RemoteAccessRateLimiter(REMOTE_PAIR_ATTEMPTS_PER_WINDOW) + const unauthenticatedLimiter = new RemoteAccessRateLimiter(REMOTE_UNAUTHENTICATED_ATTEMPTS_PER_WINDOW) const allowedWorkspaceRoots = (options.allowedWorkspaceRoots?.length ? options.allowedWorkspaceRoots : [homedir()]) .map(root => realpathSync(root)) const allowedPath = (candidate: unknown): string => { @@ -162,6 +173,8 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions return { worktrees: parsePorcelainWorktrees(result.stdout ?? '', cwd) } } const handlers = new Map([ + ['auth.sessions', () => auth.list()], + ['auth.revoke', params => ({ revoked: auth.revoke(String(params.sessionId ?? '')) })], ['workspaces.list', () => workspaceService.list()], ['workspaces.inspectPath', params => { const path = allowedPath(params.path) @@ -280,11 +293,21 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const server: Server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? '/', 'http://localhost').pathname + if (pathname.startsWith('/api/') && !browserOriginAllowed(request, options.publicOrigins)) { + sendJson(response, 403, { error: remoteError('FORBIDDEN', 'browser origin is not allowed') }) + return + } if (request.method === 'GET' && pathname === '/api/v1/capabilities') { sendJson(response, 200, capabilitySnapshot()) return } if (request.method === 'POST' && pathname === '/api/v1/pair') { + const limited = pairingLimiter.consume(remotePeerKey(request)) + if (!limited.allowed) { + response.setHeader('retry-after', String(limited.retryAfterSeconds)) + sendJson(response, 429, { error: remoteError('FORBIDDEN', `too many pairing attempts; retry in ${limited.retryAfterSeconds}s`) }) + return + } const body = await readJson(request) as { token?: unknown } const exchanged = auth.exchange(typeof body.token === 'string' ? body.token : '') if (exchanged.error) sendJson(response, 401, { error: remoteError('UNAUTHENTICATED', exchanged.error) }) @@ -293,7 +316,9 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions } if (request.method === 'POST' && pathname === '/api/v1/attachments') { if (!auth.authenticate(bearer(request))) { - sendJson(response, 401, { error: remoteError('UNAUTHENTICATED', 'valid device session required') }) + const limited = unauthenticatedLimiter.consume(remotePeerKey(request)) + if (!limited.allowed) response.setHeader('retry-after', String(limited.retryAfterSeconds)) + sendJson(response, limited.allowed ? 401 : 429, { error: remoteError('UNAUTHENTICATED', limited.allowed ? 'valid device session required' : `too many authentication attempts; retry in ${limited.retryAfterSeconds}s`) }) return } const url = new URL(request.url ?? '/', 'http://localhost') @@ -314,7 +339,9 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions } if (request.method === 'POST' && pathname === '/api/v1/rpc') { if (!auth.authenticate(bearer(request))) { - sendJson(response, 401, { error: remoteError('UNAUTHENTICATED', 'valid device session required') }) + const limited = unauthenticatedLimiter.consume(remotePeerKey(request)) + if (!limited.allowed) response.setHeader('retry-after', String(limited.retryAfterSeconds)) + sendJson(response, limited.allowed ? 401 : 429, { error: remoteError('UNAUTHENTICATED', limited.allowed ? 'valid device session required' : `too many authentication attempts; retry in ${limited.retryAfterSeconds}s`) }) return } const body = await readJson(request) as Partial @@ -357,8 +384,9 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const pathname = new URL(request.url ?? '/', 'http://localhost').pathname const protocols = String(request.headers['sec-websocket-protocol'] ?? '').split(',').map(value => value.trim()) const token = protocols.find(value => value !== 'crewcode.v1') ?? '' - if (pathname !== '/api/v1/events' || !protocols.includes('crewcode.v1') || !auth.authenticate(token)) { - socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') + if (pathname !== '/api/v1/events' || !browserOriginAllowed(request, options.publicOrigins) || !protocols.includes('crewcode.v1') || !auth.authenticate(token)) { + const limited = unauthenticatedLimiter.consume(remotePeerKey(request)) + socket.write(`HTTP/1.1 ${limited.allowed ? '401 Unauthorized' : '429 Too Many Requests'}\r\nConnection: close\r\n${limited.allowed ? '' : `Retry-After: ${limited.retryAfterSeconds}\r\n`}\r\n`) socket.destroy() return } From 70e22ea6d1337f1b360bd5bb680ff14dfd4ecc07 Mon Sep 17 00:00:00 2001 From: OnPoint-Dev-Tools Date: Wed, 19 Aug 2026 22:17:57 -0400 Subject: [PATCH 02/10] build: add Arch Linux package recipe - Ensure secure handling of sessions and credentials with appropriate error handling and validation. --- AGENTS.md | 6 +- README.md | 46 +++ docs/README.md | 5 +- docs/arch-linux-package.md | 84 +++++ docs/getting-started.md | 31 +- docs/releasing.md | 23 ++ docs/security-model.md | 25 ++ electron.vite.config.ts | 1 + package-lock.json | 218 ++++++++++++- package.json | 2 + packaging/arch/.SRCINFO | 48 +++ packaging/arch/.gitignore | 5 + packaging/arch/PKGBUILD | 70 +++++ scripts/install-linux.sh | 296 ++++++++++++++++++ src/main/crewcode-plugin-cli.test.ts | 2 + src/renderer/src/App.tsx | 21 ++ .../src/components/canvas/CanvasMode.test.ts | 62 ++++ .../src/components/canvas/CanvasMode.tsx | 26 ++ src/renderer/src/styles/styles.css | 27 ++ 19 files changed, 980 insertions(+), 18 deletions(-) create mode 100644 docs/arch-linux-package.md create mode 100644 packaging/arch/.SRCINFO create mode 100644 packaging/arch/.gitignore create mode 100644 packaging/arch/PKGBUILD create mode 100755 scripts/install-linux.sh create mode 100644 src/renderer/src/components/canvas/CanvasMode.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8ac3bf5..391f101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,11 @@ Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for a ### Client and transport boundary -The shared React renderer is being prepared for desktop and browser clients. New renderer code must obtain privileged operations through the typed CrewCode client boundary in `src/renderer/src/runtime/crewcode-client.ts`; do not introduce transport-specific HTTP/WebSocket calls in components. Electron currently installs `window.electronAPI` as that client. The future web adapter will implement the same contract over authenticated, versioned HTTP/WebSocket RPC. Protocol envelopes live in `src/shared/remote-access-types.ts`; see `docs/web-remote-access.md`. +The shared React renderer supports desktop and direct browser clients. New renderer code must obtain privileged operations through the typed CrewCode client boundary in `src/renderer/src/runtime/crewcode-client.ts`; do not introduce transport-specific HTTP/WebSocket calls in components. Electron installs `window.electronAPI`; the web adapter implements the same contract over authenticated, versioned HTTP/WebSocket RPC. Protocol envelopes live in `src/shared/remote-access-types.ts`; see `docs/web-remote-access.md`. + +Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. + +The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Do not let Hub identity imply brain execution authority: enrollment, tickets, relay, and brain-side authorization remain separate gates. ### Path alias diff --git a/README.md b/README.md index 0aa59a6..0ebba6c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,52 @@ CrewCode is strongest today for: 4. watch output in chat, terminal panes, and Mission Control 5. inspect diffs, commit changes, open or merge PRs, or discard work +## Install on Linux + +The installer detects Arch-based and Debian-based distributions and uses their +native package manager. Other x86_64 Linux distributions receive a user-local +AppImage installation. + +Download and review the installer before running it: + +```bash +curl --proto '=https' --tlsv1.2 -fsSLo install-crewcode.sh \ + https://crewcode.logixhub.icu/install +less install-crewcode.sh +sh install-crewcode.sh +``` + +Or use the convenience one-liner after reviewing the +[installer source](./scripts/install-linux.sh): + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://crewcode.logixhub.icu/install | sh +``` + +The installer verifies the selected `v0.2.1` release artifact with SHA-256 and +prompts before invoking a package manager or writing user-local application +files. It refuses to run as root. Use `sh install-crewcode.sh --dry-run` to see +the selected method without changing files. + +### Manual Arch Linux package + +Until `crewcode-bin` is available in the AUR, Arch users can instead build and +install the pacman-managed package directly from this repository: + +```bash +sudo pacman -S --needed base-devel git +git clone https://github.com/OnPoint-Dev-Tools/crewcode.git +cd crewcode/packaging/arch +less PKGBUILD # review the package recipe before building +makepkg -si +``` + +The PKGBUILD downloads CrewCode's official x86_64 release artifact, verifies its +SHA-256 checksum, and repackages it for pacman. Arch does not install the Debian +package directly. See the [Arch Linux package guide](./docs/arch-linux-package.md) +for upgrade, uninstall, and maintainer instructions. + ## Development ```bash diff --git a/docs/README.md b/docs/README.md index 183f186..9f3411b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,8 @@ internal references). | Doc | What it covers | | --- | --- | -| [getting-started.md](./getting-started.md) | Install (download placeholder + build from source), providers, first run | +| [getting-started.md](./getting-started.md) | Install from a release or source, providers, first run | +| [arch-linux-package.md](./arch-linux-package.md) | Build, upgrade, and uninstall the temporary manual Arch Linux package | | [keybindings.md](./keybindings.md) | Every shortcut, defaults per group, and the editable `~/.crewcode/keys.json` override file | | [tweaks-panel.md](./tweaks-panel.md) | The floating Layout panel: density and workspace dock presentation controls | | [system-monitor.md](./system-monitor.md) | CPU/memory pill and panel: per-workspace process usage, jump-to and kill controls | @@ -59,8 +60,6 @@ internal references). ## Content gaps for the site -- **Install links** — `getting-started.md` has a placeholder; swap in real - download links when installers are published. - **Notifications** — `notifications.md` is a developer API reference; if the notification bar needs user documentation, it's a couple of paragraphs, not that file. diff --git a/docs/arch-linux-package.md b/docs/arch-linux-package.md new file mode 100644 index 0000000..f8b9935 --- /dev/null +++ b/docs/arch-linux-package.md @@ -0,0 +1,84 @@ +# Arch Linux package + +Until CrewCode can be listed in the Arch User Repository (AUR), this repository +ships a manual `crewcode-bin` PKGBUILD in [`packaging/arch`](../packaging/arch/). +It downloads the official x86_64 Debian release artifact, verifies its SHA-256 +checksum, and repackages its files as a native package managed by pacman. Arch +does not install the Debian package directly. + +Only x86_64 is currently supported because CrewCode does not yet publish a Linux +ARM64 artifact. + +## Install + +Install Arch's standard package-building tools if needed: + +```bash +sudo pacman -S --needed base-devel git +``` + +Clone CrewCode, inspect the package recipe, then build and install it: + +```bash +git clone https://github.com/OnPoint-Dev-Tools/crewcode.git +cd crewcode/packaging/arch +less PKGBUILD +makepkg -si +``` + +`makepkg` downloads the pinned CrewCode release and refuses to build if its +checksum does not match. `pacman` installs the resulting `crewcode-bin` package. +Launch CrewCode from the desktop application menu or run: + +```bash +crewcode +``` + +Optional integrations such as an AI agent CLI must still be installed and +authenticated separately. + +## Upgrade + +Pull a repository revision containing an updated PKGBUILD and rebuild it: + +```bash +cd crewcode +git pull --ff-only +cd packaging/arch +makepkg -si +``` + +Until the package reaches AUR, AUR helpers cannot discover upgrades +automatically. + +## Uninstall + +```bash +sudo pacman -Rns crewcode-bin +``` + +This removes application files managed by pacman. It does not remove the user's +CrewCode configuration and data. + +## Maintainer release update + +After publishing a stable GitHub release with a matching +`CrewCode--amd64.deb` artifact: + +1. Set `pkgver` to the release version and reset `pkgrel` to `1`. +2. Replace `sha256sums_x86_64` with the artifact's SHA-256 checksum. Never use + `SKIP` for a release binary. +3. Regenerate metadata from `packaging/arch`: + + ```bash + makepkg --printsrcinfo > .SRCINFO + ``` + +4. Run `makepkg --cleanbuild`, install the result, and verify both the desktop + launcher and `crewcode` command. +5. If available, run `namcap PKGBUILD` and `namcap crewcode-bin-*.pkg.tar.zst` + and review its findings before committing. + +When AUR account registration becomes available, the contents of +`packaging/arch` can be pushed to the separate AUR Git repository for +`crewcode-bin`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1da28c8..094c228 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,15 +6,36 @@ chat, terminals, a code editor, and git review in one window. ## Install - +Download the latest stable Linux, macOS, or Windows artifact from +[GitHub Releases](https://github.com/OnPoint-Dev-Tools/crewcode/releases/latest). -> [!NOTE] -> Official installers for **Linux (AppImage/deb), macOS (dmg), and Windows** -> are coming soon — download links will appear here. +On x86_64 Linux, download and review the universal installer before running it: + +```bash +curl --proto '=https' --tlsv1.2 -fsSLo install-crewcode.sh \ + https://crewcode.logixhub.icu/install +less install-crewcode.sh +sh install-crewcode.sh +``` + +The installer uses pacman on Arch-based systems, apt on Debian-based systems, +and a user-local AppImage elsewhere. It verifies the pinned release checksum, +refuses root execution, and prompts before installation. The convenience form +is: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://crewcode.logixhub.icu/install | sh +``` + +Arch Linux users can alternatively build a pacman-managed `crewcode-bin` package +using the [manual Arch package instructions](./arch-linux-package.md). The recipe +verifies and repackages CrewCode's official release artifact; it does not install +a Debian package directly. ### Build from source (contributors) -Requirements: Node.js 20+, git. +Requirements: Node.js 22.16 or newer, git. ```bash git clone https://github.com/OnPoint-Dev-Tools/crewcode.git diff --git a/docs/releasing.md b/docs/releasing.md index 29d1462..d1a6833 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -64,6 +64,29 @@ gh release list gh release edit v0.1.1 --draft=false ``` +## Updating the Linux web installer + +The universal Linux installer is pinned to one stable release instead of trusting +a mutable `latest` download. After publishing a new stable release: + +1. Update the version, Debian SHA-256, and AppImage SHA-256 in + `scripts/install-linux.sh`. Obtain digests from the published GitHub assets, + not from a local build with the same filename. +2. Update `packaging/arch/PKGBUILD` and regenerate its `.SRCINFO`. +3. Run the installer's `--dry-run` checks and isolated Arch, Debian, and AppImage + method tests. +4. Copy the verified script byte-for-byte to `public/install` in the separate + `CjLogic/crewcode-website` repository, build that site, and verify + `dist/install` still matches. +5. Deploy the website and confirm + `https://crewcode.logixhub.icu/install` starts with `#!/bin/sh` and does not + return the SPA HTML fallback. + +The public installer refuses root execution, prompts before installation, and +uses native packages on Arch/Debian with a user-local AppImage fallback. Do not +replace the pinned checksums with `SKIP` or runtime parsing of an unsigned +`latest` response. + ## Release channels Two trains, and the distinction is **prerelease vs not** — not electron-builder diff --git a/docs/security-model.md b/docs/security-model.md index 2c552f4..025280a 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -34,6 +34,31 @@ re-decided at each hop's gate, not carried forward as context. - Production renderer CSP locks `default-src 'self'`, `connect-src 'self' data: blob:` (`src/main/index.ts:89`), so a compromised renderer cannot open arbitrary sockets. +## Remote browser -> headless brain boundary + +**Boundary:** a network browser must not acquire privileged RPC authority through a +replayed pairing link, stolen persisted file, cross-origin request, or unbounded +credential guessing. + +**Enforcement:** `RemoteAccessAuth` keeps pairing credentials memory-only, +short-lived, and single-use. Device session files contain SHA-256 digests rather than +bearer tokens, are written atomically with owner-only permissions, survive restart, +and enforce 30-day absolute plus 7-day idle expiry. Authenticated `auth.sessions` and +`auth.revoke` RPC expose only sanitized metadata. HTTP and WebSocket browser requests +must match the request's exact origin or a CLI-configured `--public-origin`. +Pairing and failed-session attempts use bounded per-peer fixed-window limits. The +brain still revalidates registered workspace roots for filesystem, Git, PTY, and +agent operations; transport authentication does not widen filesystem scope. + +**Tests:** `remote-access-auth.test.ts`, `remote-access-security.test.ts`, and +`remote-access-server.test.ts` cover persistence/restart, expiry, revocation, +corrupt-store refusal, origin rejection, rate limiting, and workspace denial. + +**Residual limitation:** the direct server does not yet have a general +per-authenticated-session RPC bandwidth/request budget, remote execution custody is +not fully persisted, and public deployment still depends on correctly configured +TLS/reverse-proxy infrastructure. Prefer loopback or a trusted tailnet. + ## Hop 1 — untrusted content -> agent **Boundary:** injected instructions in scraped/file/MCP content must not gain diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5ff52d1..1e8b357 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ input: { index: resolve('src/main/index.ts'), headless: resolve('src/main/headless.ts'), + hub: resolve('src/main/hub.ts'), }, }, } diff --git a/package-lock.json b/package-lock.json index 9af40bf..1c3a52b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "crewcode", "version": "0.2.1", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.179", @@ -35,6 +36,7 @@ "@mdxeditor/editor": "^4.0.0", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.2.2", + "@simplewebauthn/server": "^13.3.2", "@types/diff": "^7.0.2", "@types/pidusage": "^2.0.5", "@xterm/addon-canvas": "^0.7.0", @@ -1769,6 +1771,12 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1827,6 +1835,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lexical/clipboard": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.35.0.tgz", @@ -2479,15 +2493,140 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.3.tgz", + "integrity": "sha512-hiRJvr5ydif9fbTA7czZw1OfgzYDhu5gXNzhDfS3wSXzWYoSS/BHY+Wu2c36CNbn3mI6FoVLf0yHvQy0D4rZWw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.3.tgz", + "integrity": "sha512-N3POfw5RA7efAliAATiudtmvKQqukVEOzrMQuqQY/us2EPKczMy2WiecLt1SX6s3b0OwcFaPUXGF6uIYlBUTbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "@peculiar/asn1-x509-attr": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.3.tgz", + "integrity": "sha512-E9zYmC5mk7eiDKqQAOsZGrJ7mUCIDC0031s4Nsl7dj1Za5EBKcHVqY+1vD/a4xbk480PGqvi455W2b4FeHRJ8Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.3.tgz", + "integrity": "sha512-4xmeZiZ46VI2qGbiZPzdv6p9IhMtjWdbwZf3VCgN8OAVcued60ej5Ki2FF94srVH4Ot/h45Co7T5C/fpQXP4rA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.3.tgz", + "integrity": "sha512-Nzwoj+fRr1XB9CQuc4AanUuvQ3OIXAY+ngZIYP+eZUqd+Sonj+ZHTnrg+egQuUJ64/iMi9HFPN04MokGrFTK0w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.3", + "@peculiar/asn1-pkcs8": "^2.9.3", + "@peculiar/asn1-rsa": "^2.9.3", + "@peculiar/asn1-schema": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.3.tgz", + "integrity": "sha512-ecGZpkY6Lq5bSgTFU+LS74WjawzDgjWHcjFMVNPH/1C0j53Xi9dmLcPMdmZyk8gdsd2RKeV1fP9EbLB6W6zZ1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.3.tgz", + "integrity": "sha512-yjWVrQEmPp2y9lWjLLE28BRHbt7wYdwWvwXjFgNuekP9mDD/ofz31muVI8qOg2as7XZs1bZIZGPPnJ/0osTClQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.3", + "@peculiar/asn1-pfx": "^2.9.3", + "@peculiar/asn1-pkcs8": "^2.9.3", + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "@peculiar/asn1-x509-attr": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.3.tgz", + "integrity": "sha512-t7m3e9p/Gf9YoMM+hLsHUqB+NhOlifiOkENAIG4RV2BFWVGTATVzbXoR8L0EgNWpiriPaeIjBCS5B9PTtkaxQw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/@peculiar/asn1-schema": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", - "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", - "dev": true, + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.3.tgz", + "integrity": "sha512-SOux4+jikCnOwoJvpBp/grOqzFmJPnNSwe3sAg1Bn93YdmCiDtvolZifJIhiRq0UWMTnLRPj9/ZCMAc2W9VsMQ==", "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.6", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.3.tgz", + "integrity": "sha512-HE+ejy9dX9JP3yLL6CGYoWym4Cted1lGXH7DxsbNDGiq8KabwVR7mzYidwsyNZ/m0hNWjiS+dzSwrsgMB/OIaQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.3.tgz", + "integrity": "sha512-v5Oa6p7hCT3ONqYHQyTFQYcycD06eMhHbr0m7evpvPQSpWJIq8GgbdnvA9bVGTUlOx6KOeDrX7Z0W4s/yboNTg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.3", + "@peculiar/asn1-x509": "^2.9.3", + "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, @@ -2508,7 +2647,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -2531,6 +2669,28 @@ "node": ">=14.18.0" } }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", @@ -3933,6 +4093,25 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4787,7 +4966,6 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -10319,7 +10497,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10329,7 +10506,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -10708,6 +10884,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -11882,6 +12064,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", diff --git a/package.json b/package.json index 2c9184a..8f4828c 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "dist:mac": "npm run build && electron-builder --mac dmg zip", "preview": "env -u ELECTRON_RUN_AS_NODE electron-vite preview", "serve": "npm run build && node bin/crewcode-server.mjs serve", + "hub": "npm run build && node bin/crewcode-server.mjs hub", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit && tsc -p packages/crewcode-plugin-api/tsconfig.json --noEmit", "rebuild": "electron-rebuild -f -o node-pty && npm run fix-node-pty-permissions", @@ -203,6 +204,7 @@ "@mdxeditor/editor": "^4.0.0", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.2.2", + "@simplewebauthn/server": "^13.3.2", "@types/diff": "^7.0.2", "@types/pidusage": "^2.0.5", "@xterm/addon-canvas": "^0.7.0", diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO new file mode 100644 index 0000000..4e8901a --- /dev/null +++ b/packaging/arch/.SRCINFO @@ -0,0 +1,48 @@ +pkgbase = crewcode-bin + pkgdesc = Desktop environment for orchestrating AI coding agents across local project worktrees + pkgver = 0.2.1 + pkgrel = 1 + url = https://github.com/OnPoint-Dev-Tools/crewcode + arch = x86_64 + license = Apache-2.0 + depends = alsa-lib + depends = at-spi2-core + depends = cairo + depends = dbus + depends = expat + depends = glib2 + depends = glibc + depends = gtk3 + depends = libcups + depends = libdrm + depends = libgcc + depends = libnotify + depends = libsecret + depends = libx11 + depends = libxcb + depends = libxcomposite + depends = libxdamage + depends = libxext + depends = libxfixes + depends = libxkbcommon + depends = libxrandr + depends = libxss + depends = libxtst + depends = mesa + depends = nspr + depends = nss + depends = pango + depends = systemd-libs + depends = util-linux-libs + depends = xdg-utils + optdepends = git: local repository and worktree operations + optdepends = github-cli: GitHub pull request integration + optdepends = libappindicator: system tray integration + provides = crewcode + conflicts = crewcode + noextract = CrewCode-0.2.1-amd64.deb + options = !strip + source_x86_64 = CrewCode-0.2.1-amd64.deb::https://github.com/OnPoint-Dev-Tools/crewcode/releases/download/v0.2.1/CrewCode-0.2.1-amd64.deb + sha256sums_x86_64 = d8ed0ecce54ebfda70cc5563f5edaba0ec0f671bc1690556895a32d33f92c3c6 + +pkgname = crewcode-bin diff --git a/packaging/arch/.gitignore b/packaging/arch/.gitignore new file mode 100644 index 0000000..8c4bd26 --- /dev/null +++ b/packaging/arch/.gitignore @@ -0,0 +1,5 @@ +# makepkg downloads and outputs +*.deb +*.pkg.tar.* +/pkg/ +/src/ diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..71a9a2a --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,70 @@ +# Maintainer: OnPoint Tools + +pkgname=crewcode-bin +pkgver=0.2.1 +pkgrel=1 +pkgdesc='Desktop environment for orchestrating AI coding agents across local project worktrees' +arch=('x86_64') +url='https://github.com/OnPoint-Dev-Tools/crewcode' +license=('Apache-2.0') +depends=( + 'alsa-lib' + 'at-spi2-core' + 'cairo' + 'dbus' + 'expat' + 'glib2' + 'glibc' + 'gtk3' + 'libcups' + 'libdrm' + 'libgcc' + 'libnotify' + 'libsecret' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'libxss' + 'libxtst' + 'mesa' + 'nspr' + 'nss' + 'pango' + 'systemd-libs' + 'util-linux-libs' + 'xdg-utils' +) +optdepends=( + 'git: local repository and worktree operations' + 'github-cli: GitHub pull request integration' + 'libappindicator: system tray integration' +) +provides=('crewcode') +conflicts=('crewcode') +options=('!strip') + +_deb="CrewCode-${pkgver}-amd64.deb" +source_x86_64=( + "${_deb}::https://github.com/OnPoint-Dev-Tools/crewcode/releases/download/v${pkgver}/${_deb}" +) +noextract=("${_deb}") +sha256sums_x86_64=( + 'd8ed0ecce54ebfda70cc5563f5edaba0ec0f671bc1690556895a32d33f92c3c6' +) + +package() { + bsdtar -xOf "${srcdir}/${_deb}" data.tar.xz | + bsdtar -xf - -C "${pkgdir}" + + install -d "${pkgdir}/usr/bin" + ln -s /opt/CrewCode/crewcode "${pkgdir}/usr/bin/crewcode" + + install -d "${pkgdir}/usr/share/licenses/${pkgname}" + ln -s /opt/CrewCode/resources/licenses/LICENSE \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 0000000..e676c1f --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,296 @@ +#!/bin/sh +# CrewCode universal Linux installer. +# Served at https://crewcode.logixhub.icu/install +set -eu + +REPOSITORY="OnPoint-Dev-Tools/crewcode" +VERSION="0.2.1" +DEB_NAME="CrewCode-${VERSION}-amd64.deb" +APPIMAGE_NAME="CrewCode-${VERSION}.AppImage" +DEB_SHA256="d8ed0ecce54ebfda70cc5563f5edaba0ec0f671bc1690556895a32d33f92c3c6" +APPIMAGE_SHA256="905edf071502502549777ff292c5c52e3e75ae5bad468dbe1dae223265da878f" +RELEASE_BASE="https://github.com/${REPOSITORY}/releases/download/v${VERSION}" +METHOD="${CREWCODE_INSTALL_METHOD:-auto}" +ASSUME_YES=0 +DRY_RUN=0 +TMP_DIR="" + +say() { + printf '%s\n' "$*" +} + +fail() { + printf 'CrewCode installer: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: install [--method auto|arch|deb|appimage] [--yes] [--dry-run] + + --method METHOD Override Linux distribution detection. + --yes Accept the installer confirmation prompt. + --dry-run Print the selected method and artifact without changing files. + -h, --help Show this help. +EOF +} + +cleanup() { + if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT HUP INT TERM + +need_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +confirm() { + prompt=$1 + if [ "$ASSUME_YES" -eq 1 ]; then + return 0 + fi + if [ ! -r /dev/tty ]; then + fail "confirmation requires a terminal; rerun with --yes only after reviewing the script" + fi + printf '%s [y/N] ' "$prompt" >/dev/tty + IFS= read -r answer /dev/null 2>&1; then + confirm "Install the Arch base-devel toolchain?" || fail "installation cancelled" + if [ "$ASSUME_YES" -eq 1 ]; then + sudo pacman -S --needed --noconfirm base-devel + else + run_with_tty sudo pacman -S --needed base-devel + fi + fi + need_command makepkg + + build_dir="${TMP_DIR}/crewcode-bin" + mkdir -p "$build_dir" + cat >"${build_dir}/PKGBUILD" </dev/null 2>&1); then + install -m 0644 \ + "${extract_dir}/squashfs-root/usr/share/icons/hicolor/512x512/apps/crewcode.png" \ + "${icon_dir}/crewcode.png" + desktop_icon="crewcode" + else + desktop_icon="$app_path" + fi + + desktop_exec=$(escape_desktop_value "${bin_dir}/crewcode") + cat >"${applications_dir}/crewcode.desktop" </dev/null 2>&1; then + update-desktop-database "$applications_dir" >/dev/null 2>&1 || true + fi + + say "CrewCode installed at ${app_path}" + case ":${PATH}:" in + *":${bin_dir}:"*) ;; + *) say "Add ${bin_dir} to PATH to run 'crewcode' from a terminal." ;; + esac +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --method) + [ "$#" -ge 2 ] || fail "--method requires a value" + METHOD=$2 + shift 2 + ;; + --method=*) METHOD=${1#*=}; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown argument: $1" ;; + esac +done + +case "$METHOD" in + auto|arch|deb|appimage) ;; + *) fail "unsupported method: $METHOD (expected auto, arch, deb, or appimage)" ;; +esac + +[ "$(uname -s)" = "Linux" ] || fail "this installer currently supports Linux only" +case "$(uname -m)" in + x86_64|amd64) ;; + *) fail "CrewCode Linux releases currently support x86_64 only" + ;; +esac +[ "$(id -u)" -ne 0 ] || fail "do not run this installer as root; it requests sudo only when needed" + +detect_method + +case "$METHOD" in + arch) artifact_description="${DEB_NAME} repackaged with makepkg" ;; + deb) artifact_description="${DEB_NAME}" ;; + appimage) artifact_description="${APPIMAGE_NAME}" ;; +esac + +say "CrewCode Linux installer" +say "Version: ${VERSION}" +say "Method: ${METHOD}" +say "Artifact: ${artifact_description}" + +if [ "$DRY_RUN" -eq 1 ]; then + say "Dry run complete; no files were changed." + exit 0 +fi + +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/crewcode-install.XXXXXX") + +case "$METHOD" in + arch) install_arch ;; + deb) install_deb ;; + appimage) install_appimage ;; +esac + +say "CrewCode ${VERSION} installation complete." diff --git a/src/main/crewcode-plugin-cli.test.ts b/src/main/crewcode-plugin-cli.test.ts index 63fb477..8a18e8c 100644 --- a/src/main/crewcode-plugin-cli.test.ts +++ b/src/main/crewcode-plugin-cli.test.ts @@ -22,6 +22,8 @@ describe('crewcode plugin CLI', () => { expect(manifest.name).toBe('My Panel') expect(manifest.$schema).toBe('https://crewcode-plugins.cortex-ai.icu/schemas/crewcode.plugin.schema.json') expect(existsSync(join(result.path, 'panel.html'))).toBe(true) + expect(readFileSync(join(result.path, 'crewcode-plugin-api.js'))) + .toEqual(readFileSync(join(process.cwd(), 'packages', 'crewcode-plugin-api', 'browser', 'crewcode-plugin-api.js'))) }) it('installs a plugin for dev with copy mode', () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9152db5..d3f088f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1308,6 +1308,26 @@ export default function App() { appliedModes.markDelivered(sessionId, mode), [appliedModes], ) + const canvasModePromptControl = useCallback((paneId: string) => { + const session = chatSessions.getActiveSession(paneId) + if (!session) return undefined + const locked = (useMessagesStore.getState().messagesByTab[session.id]?.length ?? 0) > 0 + || lastDeliveredMode(session.id) !== undefined + return { + enabled: session.modePromptsEnabled ?? true, + locked, + onToggle: () => { + const current = chatSessions.getActiveSession(paneId) + if (!current) return + const currentLocked = (useMessagesStore.getState().messagesByTab[current.id]?.length ?? 0) > 0 + || lastDeliveredMode(current.id) !== undefined + if (currentLocked) return + chatSessions.update(paneId, current.id, { + modePromptsEnabled: !(current.modePromptsEnabled ?? true), + }) + }, + } + }, [chatSessions, lastDeliveredMode]) const [promptPickerOpen, setPromptPickerOpen] = useState(false) // ── Solo send ─────────────────────────────────────────────────────────── @@ -2306,6 +2326,7 @@ export default function App() { id: pane.id, kind: pane.kind, title: pane.title, + modePrompt: pane.kind === 'chat' ? canvasModePromptControl(pane.id) : undefined, content: pane.kind === 'chat' ? ( ({ + useSettings: () => ({ + state: { hideVerboseAgentLogs: false }, + set: vi.fn(), + }), +})) + +import { CanvasMode, type CanvasPaneView } from './CanvasMode' + +function renderPane(modePrompt?: CanvasPaneView['modePrompt']): TestRenderer.ReactTestRenderer { + return TestRenderer.create(createElement(CanvasMode, { + workspaceName: 'Test workspace', + openChatCount: 1, + openTerminalCount: 0, + panes: [{ + id: 'chat-1', + kind: 'chat' as const, + title: 'Workbench Chat 1', + content: createElement('div', null, 'chat'), + modePrompt, + }], + })) +} + +describe('CanvasMode chat pane bar', () => { + it('toggles the per-chat mode prompt while the session is fresh', () => { + const onToggle = vi.fn() + let renderer!: TestRenderer.ReactTestRenderer + act(() => { + renderer = renderPane({ enabled: true, locked: false, onToggle }) + }) + + const toggle = renderer.root.findByProps({ + 'aria-label': 'Inject CrewCode mode prompt for this Workbench chat', + }) + expect(toggle.props['aria-pressed']).toBe(true) + expect(toggle.props.disabled).toBe(false) + + act(() => toggle.props.onClick()) + expect(onToggle).toHaveBeenCalledOnce() + act(() => renderer.unmount()) + }) + + it('disables the mode prompt toggle after session context is delivered', () => { + let renderer!: TestRenderer.ReactTestRenderer + act(() => { + renderer = renderPane({ enabled: false, locked: true, onToggle: vi.fn() }) + }) + + const toggle = renderer.root.findByProps({ + 'aria-label': 'Inject CrewCode mode prompt for this Workbench chat', + }) + expect(toggle.props['aria-pressed']).toBe(false) + expect(toggle.props.disabled).toBe(true) + expect(toggle.props.title).toContain('was disabled') + act(() => renderer.unmount()) + }) +}) diff --git a/src/renderer/src/components/canvas/CanvasMode.tsx b/src/renderer/src/components/canvas/CanvasMode.tsx index 8a379a5..f4b7203 100644 --- a/src/renderer/src/components/canvas/CanvasMode.tsx +++ b/src/renderer/src/components/canvas/CanvasMode.tsx @@ -9,6 +9,11 @@ export interface CanvasPaneView { kind: CanvasPaneKind title: string content: ReactNode + modePrompt?: { + enabled: boolean + locked: boolean + onToggle: () => void + } } interface CanvasModeProps { @@ -64,6 +69,27 @@ export function CanvasMode({ workspaceName, openChatCount, openTerminalCount, pa
{pane.title}
+ {pane.kind === 'chat' && pane.modePrompt && ( + + )} {pane.kind === 'chat' && (
- +

` } -const HUB_CSS = `:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0f120f;color:#d7e0dc}*{box-sizing:border-box}body{margin:0}main{min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(560px,100%);border:1px solid #1c2f2f;padding:28px;background:#0f120f}.eyebrow{font:600 11px/1.4 monospace;letter-spacing:.18em;color:#79958a}h1{margin:.25rem 0 1.25rem;font-size:26px}h2{font-size:15px;margin-top:24px}label{display:grid;gap:8px;margin:20px 0;font-size:13px}input,button{border:1px solid #285a48;background:#131a17;color:inherit;padding:10px 12px;font:inherit}button{cursor:pointer;background:#285a48}.quiet{background:transparent}.row{display:flex;align-items:center;justify-content:space-between;gap:16px}.machines{border-top:1px solid #1c2f2f;padding-top:14px;color:#8da49a;font:13px/1.5 monospace}.error{color:#d89595;min-height:1.4em}` +const HUB_CSS = `:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0f120f;color:#d7e0dc}*{box-sizing:border-box}body{margin:0}main{min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(640px,100%);border:1px solid #1c2f2f;padding:28px;background:#0f120f}.eyebrow{font:600 11px/1.4 monospace;letter-spacing:.18em;color:#79958a}h1{margin:.25rem 0 1.25rem;font-size:26px}h2{font-size:15px;margin-top:24px}label{display:grid;gap:8px;margin:20px 0;font-size:13px}input,button{border:1px solid #285a48;background:#131a17;color:inherit;padding:10px 12px;font:inherit}button{cursor:pointer;background:#285a48}.quiet{background:transparent}.row,.machine{display:flex;align-items:center;justify-content:space-between;gap:16px}.machines{border-top:1px solid #1c2f2f;margin-bottom:14px;color:#8da49a;font:13px/1.5 monospace}.machine{padding:10px 0;border-bottom:1px solid #1c2f2f}.machine button{padding:5px 8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid #1c2f2f;padding:12px;color:#8da49a}.error{color:#d89595;min-height:1.4em}` const HUB_JS = `(()=>{'use strict'; const $=id=>document.getElementById(id),status=$('status'),error=$('error');let csrf=''; @@ -113,10 +142,11 @@ const creation=o=>({...o,challenge:bytes(o.challenge),user:{...o.user,id:bytes(o const request=o=>({...o,challenge:bytes(o.challenge),allowCredentials:(o.allowCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); const authError=e=>{const message=e&&e.message?e.message:String(e);if(!window.isSecureContext)return'Passkeys require a secure browser context. Open the exact localhost URL printed by CrewCode, or use the configured HTTPS Hub origin.';if(message.includes('InsecureLocalhostNotAllowed'))return'This browser or passkey provider refuses passkeys over HTTP localhost. For local testing, try current Chrome or Chromium. Otherwise run the Hub at its final HTTPS origin and create the passkey there.';return message}; function view(name){for(const id of ['setup','signin','dashboard'])$(id).hidden=id!==name} -async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines');$('machines').textContent=m.machines.length?m.machines.map(x=>x.name+' · '+x.status).join('\\n'):'No machines enrolled yet.'}catch{view('signin');status.textContent='Sign in to view your machines.'}} +async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};row.append(revoke)}list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} $('setup-button').onclick=async()=>{try{error.textContent='';const token=new URLSearchParams(location.hash.slice(1)).get('bootstrap')||'';const username=$('owner').value;const start=await json('/api/v1/hub/bootstrap/options',{method:'POST',body:JSON.stringify({token,username})});const credential=await navigator.credentials.create({publicKey:creation(start.options)});const done=await json('/api/v1/hub/bootstrap/verify',{method:'POST',body:JSON.stringify({token,username,flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;history.replaceState(null,'',location.pathname);await refresh()}catch(e){error.textContent=authError(e)}}; $('signin-button').onclick=async()=>{try{error.textContent='';const start=await json('/api/v1/hub/auth/options',{method:'POST',body:'{}'});const credential=await navigator.credentials.get({publicKey:request(start.options)});const done=await json('/api/v1/hub/auth/verify',{method:'POST',body:JSON.stringify({flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;await refresh()}catch(e){error.textContent=authError(e)}}; -$('logout-button').onclick=async()=>{try{await json('/api/v1/hub/logout',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});csrf='';await refresh()}catch(e){error.textContent=e.message}}; +$('enrollment-button').onclick=async()=>{try{error.textContent='';const issued=await json('/api/v1/hub/enrollments',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'}),out=$('enrollment');out.hidden=false;out.textContent='Enrollment token (single use; do not share):\\n'+issued.token+'\\n\\nRun on the machine within 10 minutes, then paste the token when prompted:\\ncrewcode enroll --hub '+location.origin}catch(e){error.textContent=e.message}}; +$('logout-button').onclick=async()=>{try{await json('/api/v1/hub/logout',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});csrf='';$('enrollment').hidden=true;$('enrollment').textContent='';await refresh()}catch(e){error.textContent=e.message}}; refresh().catch(e=>{status.textContent='Could not connect';error.textContent=e.message});})();` function serveAsset(pathname: string, response: ServerResponse): boolean { @@ -143,6 +173,8 @@ export async function startHubServer(options: HubServerOptions): Promise { + const csrf = typeof request.headers['x-crewcode-csrf'] === 'string' ? request.headers['x-crewcode-csrf'] : '' + return store.validateCsrf(session.id, csrf) + } + const server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? '/', 'http://localhost').pathname @@ -170,6 +207,14 @@ export async function startHubServer(options: HubServerOptions): Promise + if (!machineColumns.some(column => column.name === 'credential_digest')) { + this.db.exec('ALTER TABLE machines ADD COLUMN credential_digest TEXT') + } + this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS machines_credential_digest ON machines(credential_digest)') } close(): void { this.db.close() } @@ -227,12 +239,48 @@ export class HubStore { return Number(result.changes) === 1 } - machinesForUser(userId: string): HubMachineSummary[] { + createMachine(input: { userId: string; publicKey: string; name: string; platform: string | null; version: string | null; now: number }): { machine: HubMachineSummary; token: string } { + const id = randomBytes(16).toString('hex') + const secret = randomBytes(32).toString('base64url') + this.db.prepare("INSERT INTO machines(id, owner_user_id, public_key, credential_digest, name, status, platform, version, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, 'online', ?, ?, ?, ?)") + .run(id, input.userId, input.publicKey, digest(secret), input.name, input.platform, input.version, input.now, input.now) + this.audit('hub.machine.enrolled', input.userId, id, { name: input.name, platform: input.platform }, input.now) + return { + machine: { id, name: input.name, status: 'online', platform: input.platform, version: input.version, createdAt: input.now, lastSeenAt: input.now, revokedAt: null }, + token: `${id}.${secret}`, + } + } + + authenticateMachine(token: string): HubMachineIdentity | null { + const separator = token.indexOf('.') + if (separator < 1) return null + const id = token.slice(0, separator) + const secret = token.slice(separator + 1) + const row = this.db.prepare('SELECT id, owner_user_id, revoked_at FROM machines WHERE id = ? AND credential_digest = ? AND revoked_at IS NULL') + .get(id, digest(secret)) as { id: string; owner_user_id: string; revoked_at: number | null } | undefined + return row ? { id: row.id, ownerUserId: row.owner_user_id, revokedAt: row.revoked_at } : null + } + + heartbeatMachine(machineId: string, platform: string | null, version: string | null, now: number): boolean { + const result = this.db.prepare("UPDATE machines SET status = 'online', platform = ?, version = ?, last_seen_at = ? WHERE id = ? AND revoked_at IS NULL") + .run(platform, version, now, machineId) + return Number(result.changes) === 1 + } + + revokeMachine(userId: string, machineId: string, now: number): boolean { + const result = this.db.prepare("UPDATE machines SET revoked_at = ?, status = 'offline' WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL") + .run(now, machineId, userId) + if (Number(result.changes) !== 1) return false + this.audit('hub.machine.revoked', userId, machineId, {}, now) + return true + } + + machinesForUser(userId: string, now = Date.now(), onlineWindowMs = 90_000): HubMachineSummary[] { const rows = this.db.prepare('SELECT id, name, status, platform, version, created_at, last_seen_at, revoked_at FROM machines WHERE owner_user_id = ? ORDER BY name COLLATE NOCASE').all(userId) as unknown as MachineRow[] return rows.map(row => ({ id: row.id, name: row.name, - status: row.revoked_at ? 'revoked' : row.status === 'online' ? 'online' : 'offline', + status: row.revoked_at ? 'revoked' : row.last_seen_at !== null && row.last_seen_at > now - onlineWindowMs ? 'online' : 'offline', platform: row.platform, version: row.version, createdAt: row.created_at, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e05c5d4..885be7e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -333,11 +333,14 @@ export default function App() { // ── Tabs per workspace ─────────────────────────────────────────────────── const { - tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, selectWorkspace, openTab: handleNewTab, openTabInWorkspace, openPluginTab, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, + tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, getActiveTabIdForWorkspace, selectWorkspace, openTab: handleNewTab, openTabInWorkspace, openPluginTab, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, splitGroups, splitTabIds, splitPrimaryTabId, setSplitTab, closeSplitGroup, pinTab, unpinTab, renameTab, setTabColor, setTabUrl, setBrowserSessionMode, reorderTab, allTabIds, tabInfoById, } = useWorkspaceTabs({ activeWs, workspaceName: activeWorkspace.name }) const activeTabGitOpen = activeTabId ? (gitOpenByTab[activeTabId] ?? false) : false + const workspaceNavigationHistoryRef = useRef(EMPTY_WORKSPACE_NAVIGATION_HISTORY) + const workspaceDestinationByIdRef = useRef>({}) + const tabNavigationHistoryByWorkspaceRef = useRef>({}) // App settings must remain reachable before the first server workspace is // registered. Workspace tabs cannot materialize without a workspace id, so // browser/empty-state settings use this app-level destination. @@ -454,13 +457,20 @@ export default function App() { const handleWsSelect = useCallback((wsId: string) => { if (!wsId || wsId === activeWs) return - // Workspace swaps can remount heavy editor/browser panes; make rapid - // keyboard cycling interruptible so the shell stays responsive. + // Record the destination before starting the transition. Remote workspaces + // may mount slowly, and React can coalesce rapid transitions before their + // effects run; history must still include the workspace the user selected. + const remembered = workspaceDestinationByIdRef.current[wsId] + const tabId = remembered?.tabId || getActiveTabIdForWorkspace(wsId) + workspaceNavigationHistoryRef.current = recordWorkspaceVisit( + workspaceNavigationHistoryRef.current, + { wsId, tabId, sessionId: remembered?.sessionId }, + ) startTransition(() => { selectWorkspace(wsId) setActiveWs(wsId) }) - }, [activeWs, selectWorkspace]) + }, [activeWs, getActiveTabIdForWorkspace, selectWorkspace]) const jumpToWorkspaceTab = useCallback((wsId: string, tabId: string) => { if (!wsId || !tabId) return @@ -496,8 +506,6 @@ export default function App() { const sessions = chatSessions.getSessions(activeTabId) const sessActive = chatSessions.getActiveId(activeTabId) const activeSession = chatSessions.getActiveSession(activeTabId) - const workspaceNavigationHistoryRef = useRef(EMPTY_WORKSPACE_NAVIGATION_HISTORY) - const tabNavigationHistoryByWorkspaceRef = useRef>({}) useEffect(() => { if (!activeWs || !activeTabId) return const visit = { @@ -505,6 +513,10 @@ export default function App() { tabId: activeTabId, sessionId: activeTab?.kind === 'chat' ? (sessActive || undefined) : undefined, } + workspaceDestinationByIdRef.current[activeWs] = { + tabId: visit.tabId, + sessionId: visit.sessionId, + } workspaceNavigationHistoryRef.current = recordWorkspaceVisit( workspaceNavigationHistoryRef.current, visit, @@ -3118,7 +3130,7 @@ export default function App() { onClone={ws.cloneRepo} onInit={ws.initProject} onAddRemote={ws.addRemote} - onAdded={(id) => { setActiveWs(id); setDrawerOpen(false) }} + onAdded={(id) => { handleWsSelect(id); setDrawerOpen(false) }} /> {crewSession && ( diff --git a/src/renderer/src/hooks/useWorkspaceTabs.test.ts b/src/renderer/src/hooks/useWorkspaceTabs.test.ts index fa2e9bd..7fd0094 100644 --- a/src/renderer/src/hooks/useWorkspaceTabs.test.ts +++ b/src/renderer/src/hooks/useWorkspaceTabs.test.ts @@ -88,6 +88,29 @@ describe('useWorkspaceTabs plugin lifecycle', () => { hook.unmount() }) + it('resolves the remembered or default tab for local and remote workspace ids', () => { + vi.unstubAllGlobals() + installStorage({ + [STORAGE_KEY]: JSON.stringify({ + wsTabs: { + local: [{ id: 'local-chat', kind: 'chat', label: 'Local', live: false }], + remote: [ + { id: 'remote-chat', kind: 'chat', label: 'Remote', live: false }, + { id: 'remote-terminal', kind: 'terminal', label: 'Terminal', live: false }, + ], + }, + activeByWs: { local: 'local-chat', remote: 'remote-terminal' }, + splitMap: {}, + }), + }) + const hook = renderHook(useWorkspaceTabs, { activeWs: 'local', workspaceName: 'Local' }) + + expect(hook.result.current.getActiveTabIdForWorkspace('remote')).toBe('remote-terminal') + expect(hook.result.current.getActiveTabIdForWorkspace('new-remote')).toBe('new-remote-chat') + + hook.unmount() + }) + it('allows multiple non-singleton plugin tab instances', () => { const hook = renderHook(useWorkspaceTabs, { activeWs: 'ws1', workspaceName: 'Workspace One' }) const tab = pluginTab({ singleton: false }) diff --git a/src/renderer/src/hooks/useWorkspaceTabs.ts b/src/renderer/src/hooks/useWorkspaceTabs.ts index e3f824f..2ee75a7 100644 --- a/src/renderer/src/hooks/useWorkspaceTabs.ts +++ b/src/renderer/src/hooks/useWorkspaceTabs.ts @@ -207,7 +207,17 @@ export function useWorkspaceTabs({ activeWs, workspaceName }: UseWorkspaceTabsOp // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeWs, wsTabs]) - /** Point the active tab at a workspace's first tab — used on workspace switch. */ + const getActiveTabIdForWorkspace = useCallback((wsId: string) => { + if (!wsId) return '' + const latest = persistedStateRef.current + const list = latest.wsTabs[wsId] ?? [] + const current = latest.activeByWs[wsId] + return current && (list.some(tab => tab.id === current) || current === `${wsId}-chat`) + ? current + : (list[0]?.id ?? `${wsId}-chat`) + }, []) + + /** Point the active tab at a workspace's last active tab. */ const selectWorkspace = useCallback((wsId: string) => { const latest = persistedStateRef.current const list = latest.wsTabs[wsId] ?? [] @@ -543,10 +553,10 @@ export function useWorkspaceTabs({ activeWs, workspaceName }: UseWorkspaceTabsOp }, [wsTabs]) return useMemo(() => ({ - tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, selectWorkspace, openTab, openTabInWorkspace, openPluginTab, openPluginTabInWorkspace, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, + tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, getActiveTabIdForWorkspace, selectWorkspace, openTab, openTabInWorkspace, openPluginTab, openPluginTabInWorkspace, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, splitGroups, splitTabId, splitTabIds, splitPrimaryTabId, setSplitTab, closeSplitGroup, pinTab, unpinTab, renameTab, setTabColor, setTabUrl, setBrowserSessionMode, reorderTab, allTabIds, tabInfoById, - }), [tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, selectWorkspace, openTab, openTabInWorkspace, openPluginTab, openPluginTabInWorkspace, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, + }), [tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, getActiveTabIdForWorkspace, selectWorkspace, openTab, openTabInWorkspace, openPluginTab, openPluginTabInWorkspace, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, splitGroups, splitTabId, splitTabIds, splitPrimaryTabId, setSplitTab, closeSplitGroup, pinTab, unpinTab, renameTab, setTabColor, setTabUrl, setBrowserSessionMode, reorderTab, allTabIds, tabInfoById]) } diff --git a/tsconfig.node.json b/tsconfig.node.json index 6ea0ea3..de485e5 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 1d6e665fea0852cc79c239e816a28d2e80388ac7 Mon Sep 17 00:00:00 2001 From: OnPoint-Dev-Tools Date: Sat, 22 Aug 2026 23:02:21 -0400 Subject: [PATCH 05/10] feat: add tests for useGitSidebar, useProviderModels, WebAgentChat, and hub-relay-client - Implement tests for `useGitSidebar` to verify isolated branch switching behavior. - Add tests for `useProviderModels` to ensure fallback behavior during remote discovery. - Create tests for `WebAgentChat` to validate execution custody and event handling. - Introduce tests for `hub-relay-client` to cover connection management and event subscription. feat: implement web bridge routes for persistent state management - Add functionality to manage web bridge routes in local storage. - Ensure routes are recoverable across module reloads without losing authority. feat: enhance surface UI state management - Introduce state management for surface UI to isolate open drawers per chat session. - Implement functions to set and check the open state of surfaces. feat: add worktree selection management for chat sessions - Implement worktree selection isolation for chat sessions and Git workspace tabs. - Provide functions to resolve selected worktrees based on session context. feat: define shared hub relay types for consistent protocol handling - Create shared types for hub relay communication, including connection tickets and control frames. feat: started the browser-parity work. Implemented/connected: - Browser attachment upload path - MCP registry loading and secure ID-only server selection - GitHub status, PR listing, workflow runs, issues, - create/merge/approve operations - OpenAI/xAI browser voice using Brain-held keys - Browser editor formatting through workspace Prettier - Persistent Stop button and provider model fixes --- .gitignore | 2 + AGENTS.md | 2 +- docs/execution-custody.md | 21 +- docs/security-model.md | 38 +- docs/web-remote-access.md | 115 +++- src/main/agents/bridge-service.test.ts | 28 + src/main/agents/bridge-service.ts | 14 + src/main/filesystem-service.ts | 17 + src/main/gh.ts | 36 +- src/main/github-service.ts | 100 ++++ src/main/hub-brain-relay.ts | 491 ++++++++++++++++ src/main/hub-connection-tickets.ts | 69 +++ src/main/hub-machine-enrollment.test.ts | 6 +- src/main/hub-machine-enrollment.ts | 62 +- src/main/hub-relay-crypto.ts | 72 +++ src/main/hub-relay-limits.test.ts | 26 + src/main/hub-relay-limits.ts | 43 ++ src/main/hub-relay.test.ts | 554 ++++++++++++++++++ src/main/hub-server.test.ts | 15 +- src/main/hub-server.ts | 283 ++++++++- src/main/hub-store.ts | 10 + src/main/hub.ts | 8 +- src/main/index.ts | 67 +-- src/main/mcp-config-service.ts | 28 + src/main/mcpConfig.ts | 34 +- src/main/remote-access-server.ts | 52 +- src/renderer/src/App.tsx | 157 +++-- src/renderer/src/components/chat/ChatPane.tsx | 35 +- .../src/components/writer/WriterWorkspace.tsx | 9 +- src/renderer/src/hooks/useAgentBridge.ts | 80 ++- .../src/hooks/useBridgeRegistry.test.ts | 123 +++- src/renderer/src/hooks/useBridgeRegistry.ts | 52 +- .../useBridgeRegistry.web-teardown.test.ts | 95 +++ .../src/hooks/useComposerSend.test.ts | 12 + src/renderer/src/hooks/useComposerSend.ts | 25 +- src/renderer/src/hooks/useGitSidebar.test.ts | 49 ++ src/renderer/src/hooks/useGitSidebar.ts | 43 +- .../src/hooks/useProviderModels.test.ts | 37 ++ src/renderer/src/hooks/useProviderModels.ts | 13 +- src/renderer/src/runtime/WebAgentChat.test.ts | 35 ++ src/renderer/src/runtime/WebAgentChat.tsx | 19 +- .../src/runtime/WebConnectionScreen.tsx | 127 +++- .../src/runtime/hub-relay-client.test.ts | 120 ++++ src/renderer/src/runtime/hub-relay-client.ts | 368 ++++++++++++ .../src/runtime/web-bridge-routes.test.ts | 32 + src/renderer/src/runtime/web-bridge-routes.ts | 73 +++ .../src/runtime/web-rpc-client.test.ts | 47 +- src/renderer/src/runtime/web-rpc-client.ts | 215 ++++--- .../src/stores/chat-messages-store.test.ts | 19 + .../src/stores/chat-messages-store.ts | 4 +- src/renderer/src/surface-ui-state.test.ts | 21 + src/renderer/src/surface-ui-state.ts | 14 + .../src/surface-worktree-selection.test.ts | 31 + .../src/surface-worktree-selection.ts | 25 + src/shared/hub-relay-types.ts | 32 + src/shared/remote-access-types.ts | 5 + 56 files changed, 3740 insertions(+), 370 deletions(-) create mode 100644 src/main/github-service.ts create mode 100644 src/main/hub-brain-relay.ts create mode 100644 src/main/hub-connection-tickets.ts create mode 100644 src/main/hub-relay-crypto.ts create mode 100644 src/main/hub-relay-limits.test.ts create mode 100644 src/main/hub-relay-limits.ts create mode 100644 src/main/hub-relay.test.ts create mode 100644 src/main/mcp-config-service.ts create mode 100644 src/renderer/src/hooks/useBridgeRegistry.web-teardown.test.ts create mode 100644 src/renderer/src/hooks/useGitSidebar.test.ts create mode 100644 src/renderer/src/hooks/useProviderModels.test.ts create mode 100644 src/renderer/src/runtime/WebAgentChat.test.ts create mode 100644 src/renderer/src/runtime/hub-relay-client.test.ts create mode 100644 src/renderer/src/runtime/hub-relay-client.ts create mode 100644 src/renderer/src/runtime/web-bridge-routes.test.ts create mode 100644 src/renderer/src/runtime/web-bridge-routes.ts create mode 100644 src/renderer/src/surface-ui-state.test.ts create mode 100644 src/renderer/src/surface-ui-state.ts create mode 100644 src/renderer/src/surface-worktree-selection.test.ts create mode 100644 src/renderer/src/surface-worktree-selection.ts create mode 100644 src/shared/hub-relay-types.ts diff --git a/.gitignore b/.gitignore index 00d7e42..46d1231 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ packaging/arch/pkg/ packaging/arch/src/ packaging/arch/*.deb packaging/arch/*.pkg.tar.* +tester/ +test-workspace/ diff --git a/AGENTS.md b/AGENTS.md index 1494038..7640b14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ The shared React renderer supports desktop and direct browser clients. New rende Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. -The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence is outbound-only and revocation must fail closed. Do not let Hub identity or machine presence imply brain execution authority: tickets, relay, command scope, and brain-side authorization remain separate gates. +The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence and relay connections are outbound-only and revocation must fail closed. Hub connection tickets remain short-lived, memory-only, one-shot, browser-session/user/machine bound, and exact-origin protected. Relay application frames must stay end-to-end encrypted and ordered; the Hub may route metadata but must not receive RPC/source/terminal/agent plaintext. Do not let Hub identity, machine presence, or requested ticket scope imply Brain execution authority: `crewcode brain` defaults to no RPC grants, and every decrypted method must pass both explicit Brain-local scope and registered-workspace validation. Relay loss means pending outcomes are interrupted, never successful. ### Path alias diff --git a/docs/execution-custody.md b/docs/execution-custody.md index d854c13..7ef072f 100644 --- a/docs/execution-custody.md +++ b/docs/execution-custody.md @@ -215,8 +215,25 @@ anything else is, by definition, unexplained. **Remote-access transport** (`src/main/agents/bridge-service.ts`) — partial. Mid-turn mode changes are refused and deferred, and orphaned permission requests -are cancelled on close/abort/stop. It does **not** yet persist custody records or -implement the halt/reauthorize lifecycle. Stated plainly rather than implied. +are cancelled on explicit abort/stop or provider close. In Hub mode, browser relay +loss now detaches rather than stopping Brain-owned agents and PTYs. The Brain keeps +a bounded 1,000-event/1 MiB replay window per detached resource, caps each user at +100 owned resources, exposes execution status, and permits the same authenticated +owner with the required Brain-local scope +to reclaim a stable resource id using a fresh encrypted connection. Reclaimed events +are held until the browser chat subscriber and recovered execution route are ready, +preventing a fast completed reply from falling between transport setup and App mount. +Interrupted RPCs and prompts are never replayed. The completed conversation transcript remains in the +Brain-local conversation store. + +This custody is currently **Brain-process-resident**, not crash durable. A Brain +process stop, VPS restart, machine revocation, or loss of the persistent Brain-to-Hub +relay closes the loopback backend and its provider processes. Remote access still +does not persist the full halt/reauthorize journal described above, so such a loss +must not be presented as a successfully completed turn. A fresh connection drops +resource ids the restarted Brain could not actually reclaim. The next explicit user +prompt may idempotently reassert the stable bridge and create a replacement provider +process, but CrewCode never replays the interrupted prompt or infers its outcome. **Not yet covered:** terminal (PTY) panes, plugin capability sessions, and SSH host-key changes during a live session. Crew merges have their own equivalent diff --git a/docs/security-model.md b/docs/security-model.md index 19149c8..ee15abf 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -76,19 +76,41 @@ outbound HTTPS requests. Presence goes offline after 90 seconds without a succes heartbeat, and owner revocation immediately makes the bearer credential fail closed. Browser mutations retain exact-origin and CSRF enforcement. -**Authority limit:** presence proves only that the enrolled process recently -possessed its machine credential. It does not authorize commands, filesystem access, -agent execution, a relay, or a connection ticket. The current Ed25519 key is reserved -for later signed-ticket/relay work; heartbeats currently authenticate with the -separate bearer credential over TLS. +**Connection and execution gates:** only an authenticated Hub browser session plus +CSRF can issue a 60-second memory-only ticket for one owned, active, relay-connected +machine. Ticket ids are one-shot even after a wrong-secret guess. The authenticated +outbound Brain WebSocket and exact-origin browser WebSocket are paired only for that +machine; the relay bounds frame size and buffered output, applies a shared per-connection +token bucket (240-frame/8 MiB burst, refilling at 60 frames and 2 MiB per second), +and never accepts arbitrary destinations. Browser and Brain then use ephemeral P-256 ECDH. The Brain signs the +handshake transcript with its enrolled Ed25519 key, and ordered application frames +use direction-separated HKDF/AES-256-GCM keys. The Hub sees routing metadata and +handshake public values, but not RPC, source, terminal, prompt, or response plaintext. + +Hub identity still does not grant execution. `crewcode brain` grants no RPC scope by +default and requires explicit local `--workspace-root` plus repeatable +`--allow-scope` settings. Each decrypted method is classified at the Brain and must +be included in both the ticket request and local grant. The reused backend then +revalidates registered workspace roots for filesystem, Git, PTY, and agent calls. **Tests:** `hub-server.test.ts` covers CSRF, issue/enroll, replay rejection, stale presence, heartbeat, and revocation. `hub-machine-enrollment.test.ts` covers URL -policy, argument-secret avoidance, credential validation, and owner-only file mode. +policy, argument-secret avoidance, credential validation, owner-only file mode, and +Brain CLI grants. `hub-relay.test.ts` covers ticket expiry/one-shot behavior, +authenticated relay routing, Ed25519-authenticated E2EE, scoped read success, local +scope denial, per-connection traffic rejection, and ticket replay rejection. +`hub-relay-client.test.ts` covers explicit fresh-ticket reconnect without RPC replay. **Residual limitation:** machine credential rotation/logout and recovery are not yet -implemented. The long-running brain presence process requires an external service -manager for automatic restart and does not yet use signed per-request challenges. +implemented. Relay traffic is bounded per connection, but durable bandwidth metrics +and broader aggregate abuse accounting are not implemented. Browser relay loss now +detaches Brain-owned terminals and agents instead of stopping them. A fresh encrypted +connection can explicitly reclaim stable resource ids, with up to 100 owned resources +per user and 1,000 events / 1 MiB of detached evidence buffered per resource; +interrupted RPCs are never replayed. +Execution custody is still process-resident rather than crash durable: Brain process, +VPS, revocation, or persistent Brain-to-Hub relay loss can stop execution without a +complete remote halt journal. Attachment tunneling is also not implemented. ## Hop 1 — untrusted content -> agent diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index d3c0b95..021fe29 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -64,14 +64,35 @@ pairing URL, exchanges it for a brain-local session, and talks directly to that brain. This mode is for loopback, LAN, or a trusted tailnet. It requires a reachable address and does not provide account login or machine discovery. -### Self-hosted Hub mode (identity foundation implemented) - -The first Hub slice is implemented as the separate `crewcode hub` process. It -provides durable local identity storage, first-owner passkey bootstrap, passkey -sign-in, revocable browser sessions, audit events, and an authenticated machine-list -skeleton. Machine enrollment, outbound brain presence, connection tickets, relay, -end-to-end browser-to-brain encryption, and the shared renderer adapter remain -planned; the current Hub cannot remotely control a brain yet. +### Self-hosted Hub mode (encrypted relay preview implemented) + +The separate `crewcode hub` process now provides durable local identity storage, +first-owner passkey bootstrap, passkey sign-in, revocable browser sessions, machine +enrollment/presence, short-lived one-shot connection tickets, a bounded outbound +relay, and an end-to-end encrypted browser-to-Brain transport. The Hub dashboard can +open the shared renderer for an online machine. Workspace, terminal, and agent RPC +use the existing typed web-client adapter through the encrypted tunnel. + +This is still a preview: recovery codes, live dashboard updates, persisted remote +crash-durable execution custody, attachment tunneling, cross-device chat discovery, +and durable bandwidth accounting remain incomplete. Per-connection frame and byte +token buckets are enforced. Browser disconnect now detaches Brain-owned agents and +terminals; a fresh encrypted connection explicitly reclaims known stable resource ids +and replays only bounded observed events, never interrupted RPC requests or prompts. +The browser discovers same-owner execution routes before the shared App mounts and +buffers reclaimed events until chat subscribers are ready. If an earlier connection +already consumed the detached event window, a completed execution can recover its +latest assistant reply from the Brain-local conversation store over an owner-checked, +agent-scoped encrypted RPC. The browser persists only the opaque chat/resource route +needed to request that recovery across a full page close; it contains no credential +or added authority. Recovery events use stable ids and are idempotent in chat. +The Brain dashboard reports running/completed/blocked/failed/interrupted executions. +This survives browser/network loss while the Brain process and its persistent Hub +relay remain alive. A Brain/VPS restart still interrupts active execution and does +not replay the unobserved prompt. After reconnect, CrewCode can recover the latest +persisted assistant reply and an explicit new user prompt idempotently reasserts the +stable bridge, creating a replacement provider process only when the Brain's +process-local execution registry is gone. A user runs one always-on **CrewCode Hub** on a Linux desktop, headless server, NAS, or other trusted host. The Hub serves the React application, local sign-in, @@ -162,9 +183,11 @@ never evidence that a command or agent turn completed. 1. The signed-in browser selects a machine. 2. The Hub issues a very short-lived, single-use connection ticket bound to the local user, browser session, machine id, requested protocol, and random nonce. -3. The browser and brain connect through the Hub relay. The brain validates the - signed ticket and rejects expired, replayed, revoked, wrong-audience, or - unauthorized-user tickets. +3. The browser presents the opaque ticket once to the Hub relay. The Hub consumes + it, revalidates machine ownership/revocation, and sends immutable user/session/ + scope claims over the machine-authenticated outbound channel. Expired, replayed, + wrong-machine, offline, or revoked tickets are rejected. Tickets are memory-only, + not self-contained bearer claims or durable signed tokens. 4. The browser and brain perform an authenticated end-to-end handshake using the enrolled machine public key and a browser ephemeral key before privileged RPC is enabled. @@ -268,10 +291,19 @@ AuditEvent(id, user_id?, machine_id?, browser_session_id?, type, created_at, met outbound heartbeat presence, dashboard status, and revocation are complete. Machine logout/credential rotation remain.** 9. Implement the bounded Hub relay and a transport-neutral multiplexed tunnel with - authenticated end-to-end browser-to-brain encryption. + authenticated end-to-end browser-to-brain encryption. **Preview complete:** + one-shot 60-second tickets, outbound authenticated WebSocket relay, P-256 ephemeral + ECDH, enrolled Ed25519 Brain authentication, HKDF/AES-256-GCM ordered frames, + 30-minute idle and 8-hour absolute connection expiry, backpressure/frame bounds, + per-connection frame/byte token buckets, and typed RPC/event multiplexing are + implemented. Explicit fresh-ticket browser reconnect preserves UI state without + replaying interrupted operations and reclaims known Brain-owned terminal/agent + ids. Stable web bridge ids allow the same remote thread to reattach after a page + reload. Cross-device thread discovery and Brain-process restart recovery remain. 10. Replace the direct-only browser connection screen with local Hub sign-in, machine list/status, machine selection, reconnect, and revocation UI while - retaining an explicit direct-pairing route. + retaining an explicit direct-pairing route. **Initial machine selection and shared + renderer launch are complete; automatic reconnect and live status remain.** 11. Persist remote execution custody and test disconnect, restart, revocation, replay, cross-user isolation, relay compromise, and backpressure behavior. 12. Move the desktop application onto the same backend contract. @@ -316,16 +348,30 @@ crewcode brain Enrollment creates an Ed25519 machine identity plus a random bearer credential in `~/.crewcode/brain/hub-machine.json`, written with owner-only permissions. The Hub stores the public key and only a SHA-256 digest of the bearer secret. `crewcode brain` -then makes outbound HTTPS heartbeat requests every 30 seconds; the dashboard marks a -machine offline after 90 seconds without a successful heartbeat. Revoking it in the -dashboard immediately rejects later heartbeats. Enrollment tokens are never written +then maintains an authenticated outbound WebSocket relay and sends HTTPS heartbeats +every 30 seconds; the dashboard marks a machine offline after 90 seconds without a +successful heartbeat. Revoking it closes active relay sessions and rejects later +heartbeats. Enrollment tokens are never written to the Hub database and are invalidated by Hub restart, expiry, first successful use, or a failed guess against their id. -This presence process does **not** accept commands, expose workspaces, start agents, -or establish a relay. The Ed25519 key is reserved for the later signed-ticket and -encrypted-relay stages; current heartbeat authentication uses the separate random -machine bearer credential over HTTPS. +Remote authority is disabled by default. Enable only explicit Brain-local roots and +scopes, for example: + +```bash +crewcode brain \ + --workspace-root ~/developing \ + --allow-scope workspace:read \ + --allow-scope workspace:write \ + --allow-scope terminal \ + --allow-scope agent +``` + +Hub sign-in and ticket scope requests cannot widen these grants. Every RPC method is +classified again at the Brain and filesystem/PTY/agent operations retain registered- +workspace enforcement. The enrolled Ed25519 identity signs each ephemeral P-256 +handshake; HKDF-derived AES-256-GCM keys encrypt ordered application frames so the Hub +routes ciphertext rather than source, terminal, prompt, or response content. Planned direct-auth and remaining Hub commands: @@ -342,10 +388,9 @@ The initial CLI distribution is implemented. From a checkout, run `npm run serve from a published package, run `npx crewcode@latest` or `crewcode serve`. It builds/serves the shared renderer, defaults to loopback, prints a single-use pairing URL, resolves installed provider CLIs without Electron, and shuts down cleanly on -SIGINT/SIGTERM. The direct-auth CLI and remaining machine-management/relay commands above remain -planned. Enrollment and dashboard revocation are implemented. `crewcode hub` has its -own standalone setup/sign-in/machine-list screen; it does not yet mount the shared -CrewCode workspace client. +SIGINT/SIGTERM. The direct-auth CLI and remaining machine-management commands above +remain planned. Enrollment, dashboard revocation, machine selection, and shared +CrewCode workspace-client launch through the encrypted Hub relay are implemented. ## Current backend extraction @@ -359,6 +404,11 @@ are now Electron transport adapters for those operations. Native folder pickers, attachment handling, formatting, and destructive filesystem mutations remain in the Electron adapter until their browser API and validation contracts are added. +Hub-relayed attachment tunneling is not implemented. The browser can list the +Brain-owned MCP registry and select entries by opaque id; `bridge.start` resolves +those ids server-side and never accepts executable MCP command or environment +definitions from the browser. + `PtyService` now owns process lifecycle independently of Electron. Both Electron IPC and the remote server adapt that service. Browser terminal creation is restricted to registered workspace roots, commands use authenticated HTTP RPC, @@ -370,4 +420,19 @@ providers. The reusable service now persists provider resume IDs and normalized user/assistant transcript fallback, and exposes provider compaction. Complex cross-provider handoff summaries and every desktop-only surface still live in the Electron application; browser chat intentionally uses the same bridge contract -without pretending unsupported desktop controls are available. +without pretending unsupported desktop controls are available. Prompt acceptance is +not treated as turn completion: the browser keeps the Stop control active until an +authoritative terminal bridge event arrives. Provider model discovery uses the same +authenticated RPC and keeps curated fallback choices visible while that asynchronous +discovery is pending or unavailable. The GitHub sidebar can read Brain-local `gh` +status, pull requests, workflow runs, and issues, and can create/merge/approve pull +requests inside registered workspaces without exposing the Brain's GitHub token. +Browser voice supports Brain-configured OpenAI/xAI realtime client secrets, +dictation, and speech; permanent keys remain server-side, remote key mutation is +denied, and remote audio is bounded to 8 MiB. Browser editor formatting is routed to +workspace-local Prettier through the sandboxed filesystem service. + +Delegation/Crew lifecycle transport, plugin iframe asset/capability routing, +Brain-local voice sidecars, remote GitHub login/logout/repository publishing, file +watch events, and language-server framing remain incomplete browser work. They must +not be represented as enabled merely because the shared desktop UI mounts. diff --git a/src/main/agents/bridge-service.test.ts b/src/main/agents/bridge-service.test.ts index 3f57d2c..6a25773 100644 --- a/src/main/agents/bridge-service.test.ts +++ b/src/main/agents/bridge-service.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { describe, expect, it, vi } from 'vitest' import { AgentBridgeService } from './bridge-service' @@ -20,4 +23,29 @@ describe('AgentBridgeService', () => { await expect(service.abort('missing')).resolves.toEqual({ ok: true }) await expect(service.stop('missing')).resolves.toEqual({ ok: true }) }) + + it('treats a duplicate stable start as an attach without stopping the provider', async () => { + const service = new AgentBridgeService(() => null) + const stop = vi.spyOn(service, 'stop') + const opts = { bridgeId: 'stable-web-bridge', provider: 'ollama' as const, cwd: '/tmp', model: 'test-model' } + const dataDir = mkdtempSync(join(tmpdir(), 'crewcode-bridge-service-')) + const previousDataDir = process.env.CREWCODE_DATA_DIR + process.env.CREWCODE_DATA_DIR = dataDir + + try { + await expect(service.start(opts)).resolves.toEqual({ ok: true }) + expect(stop).toHaveBeenCalledTimes(1) + await expect(service.start(opts)).resolves.toEqual({ ok: true }) + expect(stop).toHaveBeenCalledTimes(1) + await expect(service.start({ ...opts, model: 'different-model' })).resolves.toEqual({ + error: 'bridge already exists with different execution configuration; stop it before restarting', + }) + expect(stop).toHaveBeenCalledTimes(1) + } finally { + await service.stop(opts.bridgeId) + if (previousDataDir === undefined) delete process.env.CREWCODE_DATA_DIR + else process.env.CREWCODE_DATA_DIR = previousDataDir + rmSync(dataDir, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/agents/bridge-service.ts b/src/main/agents/bridge-service.ts index 6df3cdf..40a7d12 100644 --- a/src/main/agents/bridge-service.ts +++ b/src/main/agents/bridge-service.ts @@ -68,6 +68,20 @@ export class AgentBridgeService { async start(rawOpts: BridgeStartOpts): Promise<{ ok?: boolean; error?: string }> { if (!rawOpts.bridgeId || !rawOpts.cwd) return { error: 'bridgeId and cwd are required' } if (!REMOTE_AGENT_PROVIDERS.has(rawOpts.provider)) return { error: 'agent provider is not available over remote access' } + const existing = this.bridges.get(rawOpts.bridgeId) + if (existing && !rawOpts.freshSession) { + // Remote clients use stable bridge ids so they can reassert attachment + // after a browser reconnect. A duplicate start must not stop an active + // provider turn. Refuse contradictory immutable configuration instead of + // changing execution authority underneath the existing bridge. + const sameExecution = existing.opts.provider === rawOpts.provider + && existing.opts.cwd === rawOpts.cwd + && existing.opts.model === rawOpts.model + && existing.opts.conversationKey === rawOpts.conversationKey + return sameExecution + ? { ok: true } + : { error: 'bridge already exists with different execution configuration; stop it before restarting' } + } await this.stop(rawOpts.bridgeId) const remote = isRemoteRoot(rawOpts.cwd) const path = HTTP_ONLY_PROVIDERS.has(rawOpts.provider) diff --git a/src/main/filesystem-service.ts b/src/main/filesystem-service.ts index b933a12..3efea52 100644 --- a/src/main/filesystem-service.ts +++ b/src/main/filesystem-service.ts @@ -55,6 +55,23 @@ export class FilesystemService { catch (error) { return { error: (error as Error).message } } } + async format(root: string, sub: string, text: string): Promise<{ ok?: boolean; text?: string; error?: string }> { + if (isRemoteRoot(root)) return { error: 'format unavailable on SSH workspaces' } + if (!root || !isAbsolute(root)) return { error: 'absolute root required' } + const target = join(root, sub) + if (!safeUnder(root, target)) return { error: 'path escapes root' } + const localName = process.platform === 'win32' ? 'prettier.cmd' : 'prettier' + const local = join(root, 'node_modules', '.bin', localName) + const command = existsSync(local) ? local : 'prettier' + return new Promise(resolve => { + const child = execFile(command, ['--stdin-filepath', basename(target)], { cwd: root, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => { + if (error) resolve({ error: stderr?.trim() || ((error as NodeJS.ErrnoException).code === 'ENOENT' ? 'prettier not found' : error.message) }) + else resolve({ ok: true, text: stdout }) + }) + child.stdin?.end(text) + }) + } + writeFile(root: string, sub: string, text: string): ReturnType | { ok?: boolean; error?: string } { if (isRemoteRoot(root)) return remoteWriteFile(root, sub, text) if (!root || !isAbsolute(root)) return { error: 'absolute root required' } diff --git a/src/main/gh.ts b/src/main/gh.ts index 9ccd9f0..266d141 100644 --- a/src/main/gh.ts +++ b/src/main/gh.ts @@ -1,6 +1,7 @@ import { ipcMain, BrowserWindow, shell } from 'electron' import { spawn, spawnSync, ChildProcess } from 'child_process' import { publishRepository, type PublishRepoOpts } from './github-publish' +import { ghAvailable, getGhStatus, runGh } from './github-service' export interface GhStatus { available: boolean @@ -19,30 +20,6 @@ export interface GhAuthEvent { error?: string } -function ghAvailable(): boolean { - try { - const r = spawnSync('gh', ['--version'], { encoding: 'utf8' }) - return r.status === 0 - } catch { - return false - } -} - -function ghStatus(): GhStatus { - if (!ghAvailable()) { - return { available: false, loggedIn: false, user: null, host: null, raw: '', error: 'gh CLI not found in PATH' } - } - const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8' }) - const raw = (r.stdout ?? '') + (r.stderr ?? '') - // `gh auth status` exits 0 when logged in, non-zero when not. - const loggedIn = r.status === 0 - const userMatch = raw.match(/account\s+(\S+)/i) ?? raw.match(/Logged in to\s+(\S+)\s+as\s+(\S+)/i) - const hostMatch = raw.match(/Logged in to\s+(\S+)/i) - const user = userMatch ? (userMatch[2] ?? userMatch[1]) : null - const host = hostMatch ? hostMatch[1] : null - return { available: true, loggedIn, user, host, raw } -} - let activeLogin: ChildProcess | null = null function broadcast(event: GhAuthEvent): void { @@ -116,15 +93,6 @@ function logout(): { ok: boolean; error?: string } { return { ok: true } } -/** Run a gh subcommand in a repo and collapse the result into { ok, output, error }. */ -function runGh(cwd: string, args: string[]): { ok: boolean; output: string; error?: string } { - if (!ghAvailable()) return { ok: false, output: '', error: 'gh CLI not found in PATH' } - const r = spawnSync('gh', args, { cwd, encoding: 'utf8' }) - const output = ((r.stdout ?? '') + (r.stderr ?? '')).trim() - if (r.status !== 0) return { ok: false, output, error: output || `gh ${args[0]} ${args[1]} failed` } - return { ok: true, output } -} - export type RepoCreateOpts = PublishRepoOpts /** Publish a local folder completely, including its first commit and push. */ @@ -140,7 +108,7 @@ function repoCreate(cwd: string, opts: RepoCreateOpts): { ok: boolean; output: s } export function registerGhIpc(): void { - ipcMain.handle('gh:status', () => ghStatus()) + ipcMain.handle('gh:status', () => getGhStatus()) ipcMain.handle('gh:loginStart', () => startLogin()) ipcMain.handle('gh:loginCancel', () => cancelLogin()) ipcMain.handle('gh:logout', () => logout()) diff --git a/src/main/github-service.ts b/src/main/github-service.ts new file mode 100644 index 0000000..f7ff1e7 --- /dev/null +++ b/src/main/github-service.ts @@ -0,0 +1,100 @@ +import { spawnSync } from 'child_process' + +interface GitHubPullRequest { + number: number + title: string + state: 'OPEN' | 'CLOSED' | 'MERGED' + branch: string + url: string +} + +interface GitHubRun { + id: number + name: string + status: 'queued' | 'in_progress' | 'completed' + conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null + branch: string +} + +export interface HeadlessGitHubStatus { + owner: string + repo: string + prs: GitHubPullRequest[] + runs: GitHubRun[] + issues: number +} + +export interface GhStatusResult { + available: boolean + loggedIn: boolean + user: string | null + host: string | null + raw: string + error?: string +} + +export function ghAvailable(): boolean { + try { + return spawnSync('gh', ['--version'], { encoding: 'utf8', windowsHide: true }).status === 0 + } catch { + return false + } +} + +export function getGhStatus(): GhStatusResult { + if (!ghAvailable()) return { available: false, loggedIn: false, user: null, host: null, raw: '', error: 'gh CLI not found in PATH' } + const result = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', windowsHide: true }) + const raw = (result.stdout ?? '') + (result.stderr ?? '') + const userMatch = raw.match(/account\s+(\S+)/i) ?? raw.match(/Logged in to\s+(\S+)\s+as\s+(\S+)/i) + const hostMatch = raw.match(/Logged in to\s+(\S+)/i) + return { + available: true, + loggedIn: result.status === 0, + user: userMatch ? (userMatch[2] ?? userMatch[1]) : null, + host: hostMatch?.[1] ?? null, + raw, + } +} + +export function runGh(cwd: string, args: string[]): { ok: boolean; output: string; error?: string } { + if (!ghAvailable()) return { ok: false, output: '', error: 'gh CLI not found in PATH' } + const result = spawnSync('gh', args, { cwd, encoding: 'utf8', windowsHide: true }) + const output = ((result.stdout ?? '') + (result.stderr ?? '')).trim() + if (result.status !== 0) return { ok: false, output, error: output || `gh ${args.join(' ')} failed` } + return { ok: true, output } +} + +export function getGitHubStatus(cwd: string): HeadlessGitHubStatus | { error: string } { + if (!ghAvailable()) return { error: 'gh CLI not found' } + const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', windowsHide: true }) + const remoteUrl = remoteResult.stdout?.trim() ?? '' + if (!remoteUrl.includes('github.com')) return { error: 'not a GitHub repo' } + const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/) + if (!match) return { error: 'could not parse GitHub remote URL' } + const [, owner, repo] = match + + let prs: GitHubPullRequest[] = [] + const prResult = spawnSync('gh', ['pr', 'list', '--json', 'number,title,headRefName,state,url', '--limit', '20'], { cwd, encoding: 'utf8', windowsHide: true }) + if (prResult.status === 0 && prResult.stdout) { + try { + const raw = JSON.parse(prResult.stdout) as Array<{ number: number; title: string; headRefName: string; state: string; url: string }> + prs = raw.map(pr => ({ number: pr.number, title: pr.title, state: pr.state as GitHubPullRequest['state'], branch: pr.headRefName, url: pr.url })) + } catch { /* malformed gh output produces an empty section */ } + } + + let runs: GitHubRun[] = [] + const runResult = spawnSync('gh', ['run', 'list', '--json', 'databaseId,name,status,conclusion,headBranch', '--limit', '10'], { cwd, encoding: 'utf8', windowsHide: true }) + if (runResult.status === 0 && runResult.stdout) { + try { + const raw = JSON.parse(runResult.stdout) as Array<{ databaseId: number; name: string; status: string; conclusion: string | null; headBranch: string }> + runs = raw.map(run => ({ id: run.databaseId, name: run.name, status: run.status as GitHubRun['status'], conclusion: run.conclusion as GitHubRun['conclusion'], branch: run.headBranch })) + } catch { /* malformed gh output produces an empty section */ } + } + + let issues = 0 + const issueResult = spawnSync('gh', ['issue', 'list', '--state', 'open', '--json', 'number', '--limit', '100'], { cwd, encoding: 'utf8', windowsHide: true }) + if (issueResult.status === 0 && issueResult.stdout) { + try { issues = (JSON.parse(issueResult.stdout) as unknown[]).length } catch { /* keep zero */ } + } + return { owner, repo, prs, runs, issues } +} diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts new file mode 100644 index 0000000..6d25310 --- /dev/null +++ b/src/main/hub-brain-relay.ts @@ -0,0 +1,491 @@ +import { realpathSync } from 'fs' +import { join } from 'path' +import WebSocket from 'ws' +import { + CREWCODE_REMOTE_PROTOCOL_VERSION, + type CrewCodeRemoteRequest, + type CrewCodeRemoteResponse, +} from '../shared/remote-access-types' +import { + type BrainAccessScope, + type HubRelayControlFrame, + type HubTunnelPlaintext, +} from '../shared/hub-relay-types' +import { resolveHeadlessAgentPath } from './headless-agent-resolver' +import { loadConversation } from './agents/conversation-store' +import type { MachineCredentialFile } from './hub-machine-enrollment' +import { createBrainRelayCipher, type BrainRelayCipher } from './hub-relay-crypto' +import { startRemoteAccessServer } from './remote-access-server' + +const READ_METHODS = new Set([ + 'workspaces.list', 'workspaces.inspectPath', 'fs.readDir', 'fs.readFile', 'fs.readDataUrl', 'fs.listFiles', + 'git.status', 'git.diff', 'git.log', 'git.branches', 'git.remotes', 'worktrees.list', + 'github.status', 'gh.status', +]) +const WRITE_METHOD_PREFIXES = ['workspaces.', 'fs.', 'git.', 'worktrees.', 'gh.'] +const AGENT_METHOD_PREFIXES = ['bridge.', 'agents.', 'transcripts.', 'mcp.', 'voice.'] + +interface RelaySession { + connectionId: string + userId: string + grantedScopes: Set + cipher?: BrainRelayCipher + expectedBrowserSequence: number + brainSequence: number +} + +export interface BrainRelayOptions { + credential: MachineCredentialFile + dataDir: string + allowedWorkspaceRoots: string[] + allowedScopes: BrainAccessScope[] +} + +export interface RunningBrainRelay { + close(): Promise + closed: Promise +} + +export function brainScopeForMethod(method: string): BrainAccessScope | null { + if (READ_METHODS.has(method)) return 'workspace:read' + if (method.startsWith('pty.')) return 'terminal' + if (AGENT_METHOD_PREFIXES.some(prefix => method.startsWith(prefix))) return 'agent' + if (WRITE_METHOD_PREFIXES.some(prefix => method.startsWith(prefix))) return 'workspace:write' + return null +} + +function deniedResponse(request: CrewCodeRemoteRequest, message: string): CrewCodeRemoteResponse { + return { + protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, + id: request.id, + ok: false, + error: { code: 'FORBIDDEN', message }, + } +} + +function latestAssistantMessageIndex(messages: Array<{ role: string; content: string }>): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]! + if (message.role === 'assistant' && message.content.trim()) return index + } + return -1 +} + +function websocketOrigin(origin: string): string { + const url = new URL(origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = '/api/v1/hub/relay' + return url.toString() +} + +export async function startBrainRelay(options: BrainRelayOptions): Promise { + const roots = options.allowedWorkspaceRoots.map(root => realpathSync(root)) + const runtimeDataDir = join(options.dataDir, 'runtime') + // Agent persistence helpers also run in Electron, where they fall back to + // app.getPath(). A Brain is ordinary Node, so pin the same explicit runtime + // directory before any agent bridge can lazily open those stores. + process.env.CREWCODE_DATA_DIR = runtimeDataDir + const backend = await startRemoteAccessServer({ + host: '127.0.0.1', + port: 0, + dataDir: runtimeDataDir, + allowedWorkspaceRoots: roots, + resolveAgentPath: resolveHeadlessAgentPath, + }) + const pairResponse = await fetch(`${backend.url}/api/v1/pair`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: backend.pairingToken }), + }) + const pair = await pairResponse.json() as { sessionToken?: string } + if (!pairResponse.ok || !pair.sessionToken) { await backend.close(); throw new Error('could not initialize the brain RPC boundary') } + const backendToken = pair.sessionToken + const eventSocket = new WebSocket(backend.url.replace(/^http/, 'ws') + '/api/v1/events', ['crewcode.v1', backendToken]) + await new Promise((resolve, reject) => { eventSocket.once('open', resolve); eventSocket.once('error', reject) }) + + const relay = new WebSocket(websocketOrigin(options.credential.hubOrigin), ['crewcode.brain.v1', options.credential.token]) + const sessions = new Map() + type ResourceOwner = { + userId: string + connectionId: string | null + createdAt: number + lastEventAt: number + status: 'idle' | 'running' | 'completed' | 'blocked' | 'failed' | 'interrupted' + cwd?: string + provider?: string + conversationScopeKey?: string + droppedEvents: number + } + const paneOwners = new Map() + const bridgeOwners = new Map() + const requestOwners = new Map() + const detachedEvents = new Map>() + // Keep a process-local semantic snapshot of the current/latest text turn even + // while it is attached. Browser close and Brain's logical `close` frame travel + // on different sockets, so a few final encrypted deltas can otherwise target + // the just-closed connection before detach is observed and disappear. A fresh + // explicit claim receives this replacement snapshot before later live deltas. + const bridgeTextSnapshots = new Map() + const MAX_OWNED_RESOURCES_PER_USER = 100 + const MAX_DETACHED_EVENTS_PER_RESOURCE = 1_000 + const MAX_DETACHED_EVENT_BYTES_PER_RESOURCE = 1024 * 1024 + const appendDetachedEvent = (resourceId: string, event: { channel: 'pty' | 'bridge'; event: unknown }): void => { + const events = [...(detachedEvents.get(resourceId) ?? []), event] + let bytes = events.reduce((total, item) => total + Buffer.byteLength(JSON.stringify(item)), 0) + let dropped = 0 + while (events.length > MAX_DETACHED_EVENTS_PER_RESOURCE || bytes > MAX_DETACHED_EVENT_BYTES_PER_RESOURCE) { + const removed = events.shift() + if (!removed) break + bytes -= Buffer.byteLength(JSON.stringify(removed)) + dropped += 1 + } + const owner = bridgeOwners.get(resourceId) ?? paneOwners.get(resourceId) + if (owner) owner.droppedEvents += dropped + detachedEvents.set(resourceId, events) + } + const allowed = new Set(options.allowedScopes) + let closing = false + let backendClose: Promise | null = null + const closeBackend = (): Promise => { + backendClose ??= (async () => { + const sessionId = backendToken.slice(0, backendToken.indexOf('.')) + try { + await fetch(`${backend.url}/api/v1/rpc`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${backendToken}` }, + body: JSON.stringify({ protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: 'brain-internal-revoke', method: 'auth.revoke', params: { sessionId } }), + }) + } catch { /* the loopback boundary is already closing */ } + await backend.close() + })() + return backendClose + } + let closeResolve!: () => void + let relayReadyResolve!: () => void + const closed = new Promise(resolve => { closeResolve = resolve }) + const relayReady = new Promise(resolve => { relayReadyResolve = resolve }) + + const sendEncrypted = (session: RelaySession, plaintext: HubTunnelPlaintext): void => { + if (!session.cipher || relay.readyState !== WebSocket.OPEN) return + const sequence = session.brainSequence++ + const frame: HubRelayControlFrame = { + type: 'encrypted', + connectionId: session.connectionId, + sequence, + ciphertext: session.cipher.encryptBrain(sequence, JSON.stringify(plaintext)), + } + relay.send(JSON.stringify(frame)) + } + + const releaseSession = (connectionId: string): void => { + sessions.delete(connectionId) + // Browser transport custody is deliberately separate from execution custody. + // A dropped browser detaches its resources; only explicit bridge.stop, + // pty.kill, Brain shutdown, or authority revocation terminates them. + for (const owner of paneOwners.values()) if (owner.connectionId === connectionId) owner.connectionId = null + for (const owner of bridgeOwners.values()) if (owner.connectionId === connectionId) owner.connectionId = null + } + + eventSocket.on('message', raw => { + let event: { channel?: 'pty' | 'bridge'; event?: unknown } + try { event = JSON.parse(raw.toString()) as typeof event } catch { return } + if (event.channel !== 'pty' && event.channel !== 'bridge') return + const eventRecord = event.event && typeof event.event === 'object' ? event.event as Record : {} + const nestedRequest = eventRecord.request && typeof eventRecord.request === 'object' ? eventRecord.request as Record : null + const resourceId = event.channel === 'pty' ? String(eventRecord.paneId ?? '') : String(eventRecord.bridgeId ?? nestedRequest?.bridgeId ?? '') + const owner = event.channel === 'pty' ? paneOwners.get(resourceId) : bridgeOwners.get(resourceId) + if (owner && nestedRequest && typeof nestedRequest.requestId === 'string') requestOwners.set(nestedRequest.requestId, resourceId) + if (!owner) return + owner.lastEventAt = Date.now() + if (event.channel === 'bridge') { + const type = String(eventRecord.type ?? '') + if (type === 'turn_start') { + owner.status = 'running' + const turnId = typeof eventRecord.turnId === 'string' ? eventRecord.turnId : '' + if (turnId) bridgeTextSnapshots.set(resourceId, { turnId, text: '' }) + } else if (type === 'text_delta') { + const turnId = typeof eventRecord.turnId === 'string' ? eventRecord.turnId : '' + const delta = typeof eventRecord.delta === 'string' ? eventRecord.delta : '' + const snapshot = bridgeTextSnapshots.get(resourceId) + if (turnId && delta) { + const text = snapshot?.turnId === turnId ? snapshot.text + delta : delta + // Detached event history has the same 1 MiB resource bound. Cap the + // semantic replacement too rather than allowing provider text to grow + // without limit inside the always-on Brain process. + const bounded = Buffer.from(text).subarray(0, MAX_DETACHED_EVENT_BYTES_PER_RESOURCE).toString('utf8').replace(/\uFFFD$/, '') + bridgeTextSnapshots.set(resourceId, { turnId, text: bounded }) + } + } else if (type === 'turn_end') owner.status = 'completed' + else if (type === 'user_request') owner.status = 'blocked' + else if (type === 'user_request_resolved' && owner.status === 'blocked') owner.status = 'running' + else if (type === 'error') owner.status = 'failed' + else if (type === 'closed') owner.status = owner.status === 'running' ? 'interrupted' : 'completed' + else if (type === 'ready' && owner.status === 'idle') owner.status = 'idle' + } else if (String(eventRecord.type ?? '') === 'exit') owner.status = 'completed' + const session = owner.connectionId ? sessions.get(owner.connectionId) : null + if (session) sendEncrypted(session, { type: 'event', channel: event.channel, event: event.event }) + else appendDetachedEvent(resourceId, { channel: event.channel, event: event.event }) + }) + + relay.on('message', async raw => { + let frame: HubRelayControlFrame + try { frame = JSON.parse(raw.toString()) as HubRelayControlFrame } catch { relay.close(4002, 'invalid Hub relay frame'); return } + if (frame.type === 'brainReady') { relayReadyResolve(); return } + if (frame.type === 'connect') { + const grantedScopes = frame.requestedScopes.filter(scope => allowed.has(scope)) + sessions.set(frame.connectionId, { connectionId: frame.connectionId, userId: frame.userId, grantedScopes: new Set(grantedScopes), expectedBrowserSequence: 0, brainSequence: 0 }) + return + } + if (!('connectionId' in frame)) return + const session = sessions.get(frame.connectionId) + if (!session) return + if (frame.type === 'clientHello') { + if (session.cipher) { + relay.send(JSON.stringify({ type: 'close', connectionId: frame.connectionId, reason: 'duplicate end-to-end handshake rejected' } satisfies HubRelayControlFrame)) + sessions.delete(frame.connectionId) + return + } + try { + session.cipher = createBrainRelayCipher({ connectionId: frame.connectionId, clientKey: frame.key, machinePrivateKey: options.credential.privateKey }) + const hello: HubRelayControlFrame = { + type: 'serverHello', connectionId: frame.connectionId, key: session.cipher.serverKey, + signature: session.cipher.signature, grantedScopes: [...session.grantedScopes], + } + relay.send(JSON.stringify(hello)) + } catch { + relay.send(JSON.stringify({ type: 'close', connectionId: frame.connectionId, reason: 'end-to-end handshake rejected' } satisfies HubRelayControlFrame)) + sessions.delete(frame.connectionId) + } + return + } + if (frame.type === 'close') { releaseSession(frame.connectionId); return } + if (frame.type !== 'encrypted' || !session.cipher) return + if (frame.sequence !== session.expectedBrowserSequence) { + relay.send(JSON.stringify({ type: 'close', connectionId: frame.connectionId, reason: 'encrypted frame sequence rejected' } satisfies HubRelayControlFrame)) + releaseSession(frame.connectionId) + return + } + session.expectedBrowserSequence += 1 + let plaintext: HubTunnelPlaintext + try { plaintext = JSON.parse(session.cipher.decryptBrowser(frame.sequence, frame.ciphertext)) as HubTunnelPlaintext } catch { + relay.send(JSON.stringify({ type: 'close', connectionId: frame.connectionId, reason: 'encrypted frame authentication failed' } satisfies HubRelayControlFrame)) + releaseSession(frame.connectionId) + return + } + if (plaintext.type !== 'rpc') return + const request = plaintext.request + if (!request || request.protocolVersion !== CREWCODE_REMOTE_PROTOCOL_VERSION || typeof request.id !== 'string' || !request.id || typeof request.method !== 'string' || !request.params || typeof request.params !== 'object') { + relay.send(JSON.stringify({ type: 'close', connectionId: frame.connectionId, reason: 'invalid encrypted RPC envelope' } satisfies HubRelayControlFrame)) + releaseSession(frame.connectionId) + return + } + const scope = brainScopeForMethod(request.method) + const params = request.params as Record + const responseParams = params.response && typeof params.response === 'object' ? params.response as Record : null + const responseRequestId = request.method === 'bridge.respondUserRequest' ? String(responseParams?.requestId ?? '') : '' + const resourceId = request.method.startsWith('pty.') ? String(params.paneId ?? '') : request.method.startsWith('bridge.') ? String(params.bridgeId ?? '') : '' + const ownerMap = request.method.startsWith('pty.') ? paneOwners : request.method.startsWith('bridge.') && request.method !== 'bridge.respondUserRequest' ? bridgeOwners : null + const createsResource = request.method === 'pty.create' || request.method === 'bridge.start' + const existingOwner = ownerMap && resourceId ? ownerMap.get(resourceId) : undefined + const ownedResourceCount = [...paneOwners.values(), ...bridgeOwners.values()].filter(owner => owner.userId === session.userId).length + const exceedsResourceLimit = createsResource && !existingOwner && ownedResourceCount >= MAX_OWNED_RESOURCES_PER_USER + const canAttach = existingOwner?.userId === session.userId + && (existingOwner.connectionId === null || existingOwner.connectionId === session.connectionId) + const wrongOwner = !!existingOwner && !canAttach + const missingOwner = !!ownerMap && !!resourceId && !createsResource && existingOwner?.connectionId !== session.connectionId + const requestResourceId = responseRequestId ? requestOwners.get(responseRequestId) : undefined + const requestOwner = requestResourceId ? bridgeOwners.get(requestResourceId) : undefined + const wrongRequestOwner = !!responseRequestId && requestOwner?.connectionId !== session.connectionId + let response: CrewCodeRemoteResponse + let replayResourceId = '' + if (!scope || !session.grantedScopes.has(scope)) { + response = deniedResponse(request, scope + ? `Brain authorization does not grant ${scope} for ${request.method}` + : `Brain authorization does not expose ${request.method}`) + } else if (request.method === 'bridge.list') { + const executions = [...bridgeOwners].filter(([, owner]) => owner.userId === session.userId).map(([bridgeId, owner]) => ({ + bridgeId, + status: owner.status, + attached: owner.connectionId !== null, + cwd: owner.cwd, + provider: owner.provider, + conversationScopeKey: owner.conversationScopeKey, + createdAt: owner.createdAt, + lastEventAt: owner.lastEventAt, + droppedEvents: owner.droppedEvents, + })) + response = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: true, result: { executions } } + sendEncrypted(session, { type: 'rpcResult', response }) + return + } else if (request.method === 'bridge.recoverHistory') { + const conversationScopeKey = typeof params.conversationScopeKey === 'string' ? params.conversationScopeKey : '' + if (!resourceId || !conversationScopeKey || conversationScopeKey.length > 512) { + response = deniedResponse(request, 'A valid local chat scope is required for Brain history recovery') + sendEncrypted(session, { type: 'rpcResult', response }) + return + } + // Machine enrollment is single-owner. The opaque scope comes from that + // owner's local browser state and is useful after a Brain restart has + // erased process-resident resource ownership while preserving its local + // conversation shard. Agent scope is still required above. + const history = loadConversation(`web:${conversationScopeKey}`) + const latestAssistantIndex = latestAssistantMessageIndex(history) + response = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: true, result: { replayed: latestAssistantIndex !== -1 } } + sendEncrypted(session, { type: 'rpcResult', response }) + if (latestAssistantIndex !== -1) { + sendEncrypted(session, { + type: 'event', channel: 'bridge', + event: { type: 'history_agent', bridgeId: resourceId, turnId: `recovered-${resourceId}-${latestAssistantIndex}`, text: history[latestAssistantIndex]!.content }, + }) + } + return + } else if (request.method === 'bridge.replayHistory') { + const owner = resourceId ? bridgeOwners.get(resourceId) : undefined + if (!owner || owner.userId !== session.userId || owner.connectionId !== session.connectionId || !owner.conversationScopeKey) { + response = deniedResponse(request, 'Brain session does not own this agent history') + sendEncrypted(session, { type: 'rpcResult', response }) + return + } + const history = loadConversation(`web:${owner.conversationScopeKey}`) + const latestAssistantIndex = latestAssistantMessageIndex(history) + response = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: true, result: { replayed: latestAssistantIndex !== -1 } } + sendEncrypted(session, { type: 'rpcResult', response }) + if (latestAssistantIndex !== -1) { + sendEncrypted(session, { + type: 'event', + channel: 'bridge', + event: { + type: 'history_agent', + bridgeId: resourceId, + turnId: `recovered-${resourceId}-${latestAssistantIndex}`, + text: history[latestAssistantIndex]!.content, + }, + }) + } + return + } else if (request.method === 'bridge.claim' || request.method === 'pty.claim') { + const idsKey = request.method === 'bridge.claim' ? 'bridgeIds' : 'paneIds' + const claims = Array.isArray(params[idsKey]) ? (params[idsKey] as unknown[]).map(String).slice(0, 100) : [] + const claimsMap = request.method === 'bridge.claim' ? bridgeOwners : paneOwners + const claimed: string[] = [] + for (const id of claims) { + const owner = claimsMap.get(id) + if (!owner || owner.userId !== session.userId) continue + // A page refresh can establish its fresh encrypted session before Brain + // observes the old browser socket closing. Explicit same-owner claim is + // an atomic custody handoff; otherwise events keep targeting the stale + // connection and the refreshed page can neither receive nor prompt. + owner.connectionId = session.connectionId + claimed.push(id) + } + response = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: true, result: { claimed } } + sendEncrypted(session, { type: 'rpcResult', response }) + for (const id of claimed) { + for (const event of detachedEvents.get(id) ?? []) sendEncrypted(session, { type: 'event', channel: event.channel, event: event.event }) + detachedEvents.delete(id) + const bridgeOwner = request.method === 'bridge.claim' ? bridgeOwners.get(id) : undefined + const snapshot = bridgeOwner && (bridgeOwner.status === 'running' || bridgeOwner.status === 'blocked') + ? bridgeTextSnapshots.get(id) + : undefined + if (snapshot?.text) { + sendEncrypted(session, { + type: 'event', channel: 'bridge', + event: { type: 'history_agent', bridgeId: id, turnId: snapshot.turnId, text: snapshot.text }, + }) + } + } + return + } else if (exceedsResourceLimit) { + response = deniedResponse(request, `Brain resource limit of ${MAX_OWNED_RESOURCES_PER_USER} terminals and agents reached`) + } else if (request.method === 'bridge.start' && params.freshSession !== true && existingOwner?.userId === session.userId) { + // Stable browser bridge ids make start an idempotent same-owner attach as + // well as a create operation. This is also the recovery fallback when a + // restored page misses its eager bridge.claim (for example, because the + // old socket still looked attached during startup). Explicit claim is an + // optimization for replaying buffered events, not a prerequisite for the + // owner's next prompt. Taking custody here is no broader than bridge.claim, + // which already permits an atomic same-owner handoff from a stale socket. + existingOwner.connectionId = session.connectionId + replayResourceId = resourceId + response = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: true, result: { ok: true, attached: true } } + } else if (wrongOwner || missingOwner || wrongRequestOwner) { + response = deniedResponse(request, 'Brain session does not own this terminal or agent resource') + } else { + // Reserve caller-chosen resource ids before invoking the backend. PTY and + // agent implementations may emit their first event before create/start + // resolves; claiming afterward drops that event and leaves the UI stuck. + if (ownerMap && resourceId && createsResource) ownerMap.set(resourceId, { + userId: session.userId, + connectionId: session.connectionId, + createdAt: Date.now(), + lastEventAt: Date.now(), + status: 'idle', + cwd: typeof params.cwd === 'string' ? params.cwd : undefined, + provider: typeof params.provider === 'string' ? params.provider : undefined, + conversationScopeKey: typeof params.conversationScopeKey === 'string' ? params.conversationScopeKey : undefined, + droppedEvents: 0, + }) + try { + const result = await fetch(`${backend.url}/api/v1/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${backendToken}` }, + body: JSON.stringify(request), + }) + response = await result.json() as CrewCodeRemoteResponse + const semanticResult = response.ok && response.result && typeof response.result === 'object' + ? response.result as { ok?: boolean; error?: unknown } + : null + const created = response.ok && semanticResult?.ok !== false && !semanticResult?.error + const reservedOwner = ownerMap && resourceId ? ownerMap.get(resourceId) : undefined + if (!created && ownerMap && resourceId && createsResource && reservedOwner?.connectionId === session.connectionId) ownerMap.delete(resourceId) + if (created && request.method === 'pty.create' && resourceId) detachedEvents.delete(resourceId) + if (response.ok && responseRequestId) requestOwners.delete(responseRequestId) + if (response.ok && (request.method === 'bridge.stop' || request.method === 'pty.kill') && ownerMap && resourceId) { + ownerMap.delete(resourceId) + detachedEvents.delete(resourceId) + if (request.method === 'bridge.stop') { + bridgeTextSnapshots.delete(resourceId) + for (const [requestId, ownerResourceId] of requestOwners) { + if (ownerResourceId === resourceId) requestOwners.delete(requestId) + } + } + } + } catch (error) { + const reservedOwner = ownerMap && resourceId ? ownerMap.get(resourceId) : undefined + if (ownerMap && resourceId && createsResource && reservedOwner?.connectionId === session.connectionId) ownerMap.delete(resourceId) + response = { + protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id: request.id, ok: false, + error: { code: 'INTERNAL', message: `Brain RPC failed: ${(error as Error).message}` }, + } + } + } + // A backend request may outlive its browser transport. Its execution and + // events remain in Brain custody, but the interrupted RPC result belongs to + // the released logical session and must never be emitted onto the shared + // Brain socket as an unknown/stale connection frame. + if (sessions.get(session.connectionId) !== session) return + sendEncrypted(session, { type: 'rpcResult', response }) + if (replayResourceId) { + for (const event of detachedEvents.get(replayResourceId) ?? []) sendEncrypted(session, { type: 'event', channel: event.channel, event: event.event }) + detachedEvents.delete(replayResourceId) + } + }) + + relay.on('close', () => { + sessions.clear() + eventSocket.close() + void closeBackend().finally(closeResolve) + }) + await new Promise((resolve, reject) => { relay.once('open', resolve); relay.once('error', reject) }) + await relayReady + + return { + closed, + async close() { + if (closing) return closed + closing = true + relay.close(1000, 'brain stopping') + eventSocket.close() + await closeBackend() + if (relay.readyState === WebSocket.CLOSED) closeResolve() + await closed + }, + } +} diff --git a/src/main/hub-connection-tickets.ts b/src/main/hub-connection-tickets.ts new file mode 100644 index 0000000..3605f91 --- /dev/null +++ b/src/main/hub-connection-tickets.ts @@ -0,0 +1,69 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto' +import { + HUB_CONNECTION_TICKET_TTL_MS, + type BrainAccessScope, +} from '../shared/hub-relay-types' + +interface PendingConnectionTicket { + id: string + secretDigest: Buffer + userId: string + browserSessionId: string + machineId: string + requestedScopes: BrainAccessScope[] + expiresAt: number +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value).digest() +} + +export class HubConnectionTicketIssuer { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + issue(input: { + userId: string + browserSessionId: string + machineId: string + requestedScopes: BrainAccessScope[] + }): { ticket: string; expiresAt: number } { + this.prune() + const id = randomBytes(16).toString('hex') + const secret = randomBytes(32).toString('base64url') + const expiresAt = this.now() + HUB_CONNECTION_TICKET_TTL_MS + this.pending.set(id, { + id, + secretDigest: digest(secret), + userId: input.userId, + browserSessionId: input.browserSessionId, + machineId: input.machineId, + requestedScopes: [...input.requestedScopes], + expiresAt, + }) + return { ticket: `${id}.${secret}`, expiresAt } + } + + consume(token: string): Omit | null { + this.prune() + const separator = token.indexOf('.') + if (separator < 1) return null + const id = token.slice(0, separator) + const pending = this.pending.get(id) + if (!pending) return null + // A ticket id gets one presentation. A wrong secret consumes it to bound + // online guessing and make replay behavior unambiguous. + this.pending.delete(id) + const supplied = digest(token.slice(separator + 1)) + if (supplied.length !== pending.secretDigest.length || !timingSafeEqual(supplied, pending.secretDigest)) return null + if (pending.expiresAt <= this.now()) return null + const { secretDigest: _secretDigest, ...claims } = pending + return claims + } + + private prune(): void { + const now = this.now() + for (const [id, ticket] of this.pending) if (ticket.expiresAt <= now) this.pending.delete(id) + } +} diff --git a/src/main/hub-machine-enrollment.test.ts b/src/main/hub-machine-enrollment.test.ts index ae9bfd9..ab7f546 100644 --- a/src/main/hub-machine-enrollment.test.ts +++ b/src/main/hub-machine-enrollment.test.ts @@ -37,7 +37,11 @@ describe('Hub machine client security', () => { hubOrigin: 'https://hub.example', name: 'cortex', token: undefined, }) expect(() => parseBrainOptions([], 'enroll')).toThrow('requires --hub') - expect(parseBrainOptions([], 'brain')).toMatchObject({ name: expect.any(String) }) + expect(parseBrainOptions([], 'brain')).toMatchObject({ name: expect.any(String), allowedScopes: [], allowedWorkspaceRoots: [] }) + expect(parseBrainOptions(['--workspace-root', '.', '--allow-scope', 'workspace:read', '--allow-scope', 'agent'], 'brain', '/tmp')).toMatchObject({ + allowedWorkspaceRoots: ['/tmp'], allowedScopes: ['workspace:read', 'agent'], + }) + expect(() => parseBrainOptions(['--allow-scope', 'everything'], 'brain')).toThrow('invalid Brain scope') }) it('writes and validates an owner-only machine credential file', () => { diff --git a/src/main/hub-machine-enrollment.ts b/src/main/hub-machine-enrollment.ts index 1e78412..3cb6ab1 100644 --- a/src/main/hub-machine-enrollment.ts +++ b/src/main/hub-machine-enrollment.ts @@ -3,6 +3,8 @@ import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, rmSync, write import { hostname, platform } from 'os' import { dirname, join, resolve } from 'path' import { homedir } from 'os' +import type { BrainAccessScope } from '../shared/hub-relay-types' +import { startBrainRelay } from './hub-brain-relay' export const HUB_ENROLLMENT_TTL_MS = 10 * 60_000 export const HUB_HEARTBEAT_INTERVAL_MS = 30_000 @@ -130,6 +132,8 @@ export interface BrainCliOptions { hubOrigin?: string token?: string name: string + allowedWorkspaceRoots: string[] + allowedScopes: BrainAccessScope[] } function valueAfter(argv: string[], index: number, flag: string): string { @@ -144,17 +148,25 @@ export function parseBrainOptions(argv: string[], command: 'enroll' | 'brain', c let hubOrigin: string | undefined let token: string | undefined let name = hostname() + const allowedWorkspaceRoots: string[] = [] + const allowedScopes: BrainAccessScope[] = [] for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] if (arg === '--data-dir') dataDir = resolve(cwd, valueAfter(argv, index++, arg)) else if (arg === '--hub') hubOrigin = normalizeHubUrl(valueAfter(argv, index++, arg)) else if (arg === '--token') token = valueAfter(argv, index++, arg) else if (arg === '--name') name = valueAfter(argv, index++, arg).trim() + else if (arg === '--workspace-root' && command === 'brain') allowedWorkspaceRoots.push(resolve(cwd, valueAfter(argv, index++, arg))) + else if (arg === '--allow-scope' && command === 'brain') { + const scope = valueAfter(argv, index++, arg) as BrainAccessScope + if (scope !== 'workspace:read' && scope !== 'workspace:write' && scope !== 'terminal' && scope !== 'agent') throw new Error(`invalid Brain scope: ${scope}`) + if (!allowedScopes.includes(scope)) allowedScopes.push(scope) + } else throw new Error(`unknown option: ${arg}`) } if (!name || name.length > 80) throw new Error('machine name must contain 1 to 80 characters') if (command === 'enroll' && !hubOrigin) throw new Error('enroll requires --hub') - return { dataDir, hubOrigin, token, name } + return { dataDir, hubOrigin, token, name, allowedWorkspaceRoots, allowedScopes } } class HubRequestError extends Error { @@ -224,7 +236,7 @@ async function hiddenEnrollmentToken(): Promise { function brainUsage(command: 'enroll' | 'brain'): string { if (command === 'enroll') return `CrewCode machine enrollment\n\nUsage:\n crewcode enroll --hub [--name ] [--data-dir ]\n\nThe enrollment token is requested without echo in an interactive terminal. It is\nsingle-use and expires after ten minutes. --token is available only for controlled\nautomation because command-line arguments may be exposed in process lists/history.` - return `CrewCode outbound brain presence\n\nUsage:\n crewcode brain [--data-dir ]\n\nLoads the enrolled machine credential and sends authenticated outbound presence\nto its Hub. This milestone does not accept or execute remote commands.` + return `CrewCode outbound Brain relay\n\nUsage:\n crewcode brain [--data-dir ] [--workspace-root ] [--allow-scope ]\n\nScopes (repeatable): workspace:read, workspace:write, terminal, agent.\nThe Brain grants no remote RPC scope by default. Workspace roots and scopes are\nBrain-local authorization; signing in to the Hub cannot widen them.` } export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[]): Promise { @@ -236,29 +248,57 @@ export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[ const credential = await enrollMachine(parsed) console.log(`Enrolled machine ${credential.machineId} with ${credential.hubOrigin}.`) console.log(`Credential stored at ${machineCredentialPath(parsed.dataDir)}.`) - console.log('Run `crewcode brain` to maintain outbound presence. Remote command execution is not enabled.') + console.log('Run `crewcode brain` with explicit workspace roots and scopes to enable the outbound relay.') return } + if (parsed.allowedScopes.length > 0 && parsed.allowedWorkspaceRoots.length === 0) { + throw new Error('remote scopes require at least one explicit --workspace-root') + } const credential = readMachineCredential(machineCredentialPath(parsed.dataDir)) let stopped = false + let activeRelay: Awaited> | null = null let wake: (() => void) | undefined - const shutdown = (): void => { stopped = true; wake?.() } + const shutdown = (): void => { + stopped = true + wake?.() + void activeRelay?.close() + } process.once('SIGINT', shutdown) process.once('SIGTERM', shutdown) - console.log(`CrewCode brain presence connecting outbound to ${credential.hubOrigin}.`) - console.log('Remote command execution is not enabled.') + console.log(`CrewCode Brain connecting outbound to ${credential.hubOrigin}.`) + console.log(parsed.allowedScopes.length + ? `Brain-local grants: ${parsed.allowedScopes.join(', ')} under ${parsed.allowedWorkspaceRoots.join(', ')}.` + : 'Brain-local grants: none. Hub users can connect, but all privileged RPC is denied.') + while (!stopped) { - try { await sendHeartbeat(credential) } - catch (error) { - console.error(`Heartbeat failed: ${(error as Error).message}`) + try { + await sendHeartbeat(credential) + activeRelay = await startBrainRelay({ + credential, + dataDir: parsed.dataDir, + allowedWorkspaceRoots: parsed.allowedWorkspaceRoots, + allowedScopes: parsed.allowedScopes, + }) + console.log('Authenticated outbound relay connected.') + const heartbeat = setInterval(() => { + void sendHeartbeat(credential).catch(error => { + console.error(`Heartbeat failed: ${(error as Error).message}`) + if (error instanceof HubRequestError && error.status === 401) void activeRelay?.close() + }) + }, HUB_HEARTBEAT_INTERVAL_MS) + await activeRelay.closed + clearInterval(heartbeat) + activeRelay = null + } catch (error) { + console.error(`Brain relay failed: ${(error as Error).message}`) if (error instanceof HubRequestError && error.status === 401) { - console.error('Machine authority was rejected or revoked; presence is stopping.') + console.error('Machine authority was rejected or revoked; Brain is stopping.') return } } if (!stopped) await new Promise(resolve => { - const timer = setTimeout(resolve, HUB_HEARTBEAT_INTERVAL_MS) + const timer = setTimeout(resolve, 5_000) wake = () => { clearTimeout(timer); resolve() } }) wake = undefined diff --git a/src/main/hub-relay-crypto.ts b/src/main/hub-relay-crypto.ts new file mode 100644 index 0000000..fe770b1 --- /dev/null +++ b/src/main/hub-relay-crypto.ts @@ -0,0 +1,72 @@ +import { + createCipheriv, + createDecipheriv, + createECDH, + createHash, + createPrivateKey, + hkdfSync, + sign, +} from 'crypto' + +function transcript(connectionId: string, clientKey: string, serverKey: string): Buffer { + return Buffer.from(`crewcode-hub-relay-v1\0${connectionId}\0${clientKey}\0${serverKey}`, 'utf8') +} + +function nonce(direction: 'browser' | 'brain', sequence: number): Buffer { + if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error('invalid relay sequence') + const value = Buffer.alloc(12) + value.writeUInt32BE(direction === 'browser' ? 0x42525752 : 0x4252414e, 0) + value.writeBigUInt64BE(BigInt(sequence), 4) + return value +} + +function aad(connectionId: string, direction: 'browser' | 'brain', sequence: number): Buffer { + return Buffer.from(`${connectionId}\0${direction}\0${sequence}`, 'utf8') +} + +export interface BrainRelayCipher { + serverKey: string + signature: string + decryptBrowser(sequence: number, ciphertext: string): string + encryptBrain(sequence: number, plaintext: string): string +} + +export function createBrainRelayCipher(input: { + connectionId: string + clientKey: string + machinePrivateKey: string +}): BrainRelayCipher { + const clientPublicKey = Buffer.from(input.clientKey, 'base64url') + if (clientPublicKey.length !== 65 || clientPublicKey[0] !== 4) throw new Error('invalid browser ephemeral key') + const ecdh = createECDH('prime256v1') + ecdh.generateKeys() + const serverPublicKey = ecdh.getPublicKey() + const serverKey = serverPublicKey.toString('base64url') + const shared = ecdh.computeSecret(clientPublicKey) + const salt = createHash('sha256').update(transcript(input.connectionId, input.clientKey, serverKey)).digest() + const browserKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('browser-to-brain'), 32)) + const brainKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('brain-to-browser'), 32)) + const machineKey = createPrivateKey({ key: Buffer.from(input.machinePrivateKey, 'base64url'), type: 'pkcs8', format: 'der' }) + const signature = sign(null, transcript(input.connectionId, input.clientKey, serverKey), machineKey).toString('base64url') + + return { + serverKey, + signature, + decryptBrowser(sequence, ciphertext) { + const encoded = Buffer.from(ciphertext, 'base64url') + if (encoded.length < 17) throw new Error('invalid encrypted relay frame') + const body = encoded.subarray(0, -16) + const tag = encoded.subarray(-16) + const decipher = createDecipheriv('aes-256-gcm', browserKey, nonce('browser', sequence)) + decipher.setAAD(aad(input.connectionId, 'browser', sequence)) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8') + }, + encryptBrain(sequence, plaintext) { + const cipher = createCipheriv('aes-256-gcm', brainKey, nonce('brain', sequence)) + cipher.setAAD(aad(input.connectionId, 'brain', sequence)) + const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + return Buffer.concat([body, cipher.getAuthTag()]).toString('base64url') + }, + } +} diff --git a/src/main/hub-relay-limits.test.ts b/src/main/hub-relay-limits.test.ts new file mode 100644 index 0000000..8d14b57 --- /dev/null +++ b/src/main/hub-relay-limits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { HubRelayTrafficLimiter } from './hub-relay-limits' + +describe('Hub relay traffic limiter', () => { + it('bounds frame bursts and refills at the sustained rate', () => { + const limiter = new HubRelayTrafficLimiter(1_000, 2, 1, 100, 100) + expect(limiter.consume(1, 1_000)).toBeNull() + expect(limiter.consume(1, 1_000)).toBeNull() + expect(limiter.consume(1, 1_000)).toBe('frame rate limit') + expect(limiter.consume(1, 1_999)).toBe('frame rate limit') + expect(limiter.consume(1, 2_000)).toBeNull() + }) + + it('bounds encoded bytes without consuming a frame token for a rejected frame', () => { + const limiter = new HubRelayTrafficLimiter(1_000, 2, 0, 10, 5) + expect(limiter.consume(6, 1_000)).toBeNull() + expect(limiter.consume(5, 1_000)).toBe('bandwidth limit') + expect(limiter.consume(5, 2_000)).toBeNull() + }) + + it('does not refill when the supplied clock moves backwards', () => { + const limiter = new HubRelayTrafficLimiter(1_000, 1, 1, 10, 10) + expect(limiter.consume(1, 1_000)).toBeNull() + expect(limiter.consume(1, 500)).toBe('frame rate limit') + }) +}) diff --git a/src/main/hub-relay-limits.ts b/src/main/hub-relay-limits.ts new file mode 100644 index 0000000..7cc27af --- /dev/null +++ b/src/main/hub-relay-limits.ts @@ -0,0 +1,43 @@ +export const HUB_RELAY_FRAME_BURST = 240 +export const HUB_RELAY_FRAME_REFILL_PER_SECOND = 60 +export const HUB_RELAY_BYTE_BURST = 8 * 1024 * 1024 +export const HUB_RELAY_BYTE_REFILL_PER_SECOND = 2 * 1024 * 1024 + +export type HubRelayTrafficLimitReason = 'frame rate limit' | 'bandwidth limit' + +/** + * Per-connection token bucket. Frames in both directions share the same budget, + * preventing either peer from making the Hub retain unbounded relay work. The + * burst accommodates normal terminal/event fan-out while sustained traffic is + * bounded independently by frame count and encoded bytes. + */ +export class HubRelayTrafficLimiter { + private frameTokens: number + private byteTokens: number + private lastRefillAt: number + + constructor( + at: number, + private readonly frameCapacity = HUB_RELAY_FRAME_BURST, + private readonly frameRefillPerSecond = HUB_RELAY_FRAME_REFILL_PER_SECOND, + private readonly byteCapacity = HUB_RELAY_BYTE_BURST, + private readonly byteRefillPerSecond = HUB_RELAY_BYTE_REFILL_PER_SECOND, + ) { + this.frameTokens = frameCapacity + this.byteTokens = byteCapacity + this.lastRefillAt = at + } + + consume(bytes: number, at: number): HubRelayTrafficLimitReason | null { + if (!Number.isSafeInteger(bytes) || bytes < 0) return 'bandwidth limit' + const elapsedSeconds = Math.max(0, at - this.lastRefillAt) / 1000 + this.frameTokens = Math.min(this.frameCapacity, this.frameTokens + elapsedSeconds * this.frameRefillPerSecond) + this.byteTokens = Math.min(this.byteCapacity, this.byteTokens + elapsedSeconds * this.byteRefillPerSecond) + this.lastRefillAt = Math.max(this.lastRefillAt, at) + if (this.frameTokens < 1) return 'frame rate limit' + if (this.byteTokens < bytes) return 'bandwidth limit' + this.frameTokens -= 1 + this.byteTokens -= bytes + return null + } +} diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts new file mode 100644 index 0000000..fbfec42 --- /dev/null +++ b/src/main/hub-relay.test.ts @@ -0,0 +1,554 @@ +import { + createCipheriv, + createDecipheriv, + createECDH, + createHash, + createPublicKey, + generateKeyPairSync, + hkdfSync, + verify, +} from 'crypto' +import { mkdtempSync, rmSync } from 'fs' +import { createServer } from 'http' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import WebSocket from 'ws' +import { brainScopeForMethod, startBrainRelay, type RunningBrainRelay } from './hub-brain-relay' +import { HubConnectionTicketIssuer } from './hub-connection-tickets' +import { HUB_CONNECTION_TICKET_TTL_MS } from '../shared/hub-relay-types' +import type { MachineCredentialFile } from './hub-machine-enrollment' +import { startHubServer, type RunningHubServer } from './hub-server' +import { HubStore } from './hub-store' +import type { HubRelayControlFrame, HubTunnelPlaintext } from '../shared/hub-relay-types' + +const cleanups: Array<() => void | Promise> = [] +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.() }) + +function directory(): string { + const value = mkdtempSync(join(tmpdir(), 'crewcode-hub-relay-')) + cleanups.push(() => rmSync(value, { recursive: true, force: true })) + return value +} + +async function fixture(scopes: Array<'workspace:read' | 'workspace:write' | 'terminal' | 'agent'>): Promise<{ + hub: RunningHubServer + brain: RunningBrainRelay + machineId: string + machineToken: string + cookie: string + csrf: string + publicKey: string + workspaceRoot: string +}> { + const hubData = directory() + const brainData = directory() + const workspaceRoot = directory() + const keys = generateKeyPairSync('ed25519') + const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url') + const privateKey = keys.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url') + const store = new HubStore(join(hubData, 'hub.sqlite')) + const owner = store.createOwnerWithCredential({ + username: 'Owner', credential: { id: 'credential', publicKey: new Uint8Array([1]), counter: 0 }, + deviceType: 'singleDevice', backedUp: false, now: 1_000, + }) + const session = store.createSession(owner.id, 1_000, 60_000) + const enrolled = store.createMachine({ userId: owner.id, publicKey, name: 'brain', platform: 'linux', version: 'test', now: 1_000 }) + store.close() + const hub = await startHubServer({ dataDir: hubData, port: 0, now: () => 2_000 }) + cleanups.push(() => hub.close()) + const credential: MachineCredentialFile = { + version: 1, hubOrigin: hub.url, machineId: enrolled.machine.id, token: enrolled.token, + publicKey, privateKey, enrolledAt: 1_000, + } + const brain = await startBrainRelay({ credential, dataDir: brainData, allowedWorkspaceRoots: [workspaceRoot], allowedScopes: scopes }) + cleanups.push(() => brain.close()) + return { hub, brain, machineId: enrolled.machine.id, machineToken: enrolled.token, cookie: `crewcode_hub_session=${encodeURIComponent(session.token)}`, csrf: session.csrf, publicKey, workspaceRoot } +} + +function onceFrame(socket: WebSocket, predicate: (frame: HubRelayControlFrame) => boolean): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('relay frame timeout')), 10_000) + const onMessage = (raw: WebSocket.RawData): void => { + const frame = JSON.parse(raw.toString()) as HubRelayControlFrame + if (!predicate(frame)) return + clearTimeout(timeout) + socket.off('message', onMessage) + resolve(frame) + } + socket.on('message', onMessage) + }) +} + +function relayNonce(direction: 'browser' | 'brain', sequence: number): Buffer { + const value = Buffer.alloc(12) + value.writeUInt32BE(direction === 'browser' ? 0x42525752 : 0x4252414e, 0) + value.writeBigUInt64BE(BigInt(sequence), 4) + return value +} + +async function openEncryptedSession(input: { + hub: RunningHubServer + ticket: string + machineId: string + publicKey: string +}): Promise<{ + rpc(request: { protocolVersion: 1; id: string; method: string; params: Record }): Promise + nextEvent(predicate?: (event: Extract) => boolean): Promise> + close(): Promise +}> { + const socket = new WebSocket(input.hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.browser.v1', input.ticket], { origin: input.hub.publicOrigin }) + const readyFrame = onceFrame(socket, frame => frame.type === 'ready') + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject) }) + const ready = await readyFrame.catch(error => { throw new Error(`browser relay ready failed: ${(error as Error).message}`) }) + if (ready.type !== 'ready') throw new Error('missing ready') + expect(ready.machineId).toBe(input.machineId) + const ecdh = createECDH('prime256v1') + ecdh.generateKeys() + const clientKey = ecdh.getPublicKey().toString('base64url') + const helloFrame = onceFrame(socket, frame => frame.type === 'serverHello') + socket.send(JSON.stringify({ type: 'clientHello', connectionId: ready.connectionId, key: clientKey } satisfies HubRelayControlFrame)) + const hello = await helloFrame.catch(error => { throw new Error(`Brain end-to-end hello failed: ${(error as Error).message}`) }) + if (hello.type !== 'serverHello') throw new Error('missing server hello') + const transcript = Buffer.from(`crewcode-hub-relay-v1\0${ready.connectionId}\0${clientKey}\0${hello.key}`) + const machineKey = createPublicKey({ key: Buffer.from(input.publicKey, 'base64url'), type: 'spki', format: 'der' }) + expect(verify(null, transcript, machineKey, Buffer.from(hello.signature, 'base64url'))).toBe(true) + const shared = ecdh.computeSecret(Buffer.from(hello.key, 'base64url')) + const salt = createHash('sha256').update(transcript).digest() + const browserKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('browser-to-brain'), 32)) + const brainKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('brain-to-browser'), 32)) + let browserSequence = 0 + let expectedBrainSequence = 0 + const pending = new Map void>() + type RelayEvent = Extract + const bufferedEvents: RelayEvent[] = [] + const eventWaiters: Array<{ predicate: (event: RelayEvent) => boolean; resolve: (event: RelayEvent) => void }> = [] + let chain = Promise.resolve() + socket.on('message', raw => { + chain = chain.then(async () => { + const frame = JSON.parse(raw.toString()) as HubRelayControlFrame + if (frame.type !== 'encrypted') return + expect(frame.sequence).toBe(expectedBrainSequence++) + const encoded = Buffer.from(frame.ciphertext, 'base64url') + const decipher = createDecipheriv('aes-256-gcm', brainKey, relayNonce('brain', frame.sequence)) + decipher.setAAD(Buffer.from(`${ready.connectionId}\0brain\0${frame.sequence}`)) + decipher.setAuthTag(encoded.subarray(-16)) + const message = JSON.parse(Buffer.concat([decipher.update(encoded.subarray(0, -16)), decipher.final()]).toString()) as HubTunnelPlaintext + if (message.type === 'rpcResult') { + pending.get(message.response.id)?.(message) + pending.delete(message.response.id) + } else if (message.type === 'event') { + const waiterIndex = eventWaiters.findIndex(waiter => waiter.predicate(message)) + if (waiterIndex === -1) bufferedEvents.push(message) + else eventWaiters.splice(waiterIndex, 1)[0]!.resolve(message) + } + }) + }) + return { + rpc(request) { + const plaintext: HubTunnelPlaintext = { type: 'rpc', request } + const sequence = browserSequence++ + const cipher = createCipheriv('aes-256-gcm', browserKey, relayNonce('browser', sequence)) + cipher.setAAD(Buffer.from(`${ready.connectionId}\0browser\0${sequence}`)) + const body = Buffer.concat([cipher.update(JSON.stringify(plaintext)), cipher.final(), cipher.getAuthTag()]) + return new Promise(resolve => { + pending.set(request.id, resolve) + socket.send(JSON.stringify({ type: 'encrypted', connectionId: ready.connectionId, sequence, ciphertext: body.toString('base64url') } satisfies HubRelayControlFrame)) + }) + }, + nextEvent(predicate = () => true) { + const bufferedIndex = bufferedEvents.findIndex(predicate) + if (bufferedIndex !== -1) return Promise.resolve(bufferedEvents.splice(bufferedIndex, 1)[0]!) + return new Promise((resolve, reject) => { + const waiter = { predicate, resolve: (event: RelayEvent) => { clearTimeout(timeout); resolve(event) } } + const timeout = setTimeout(() => { + const index = eventWaiters.indexOf(waiter) + if (index !== -1) eventWaiters.splice(index, 1) + reject(new Error('encrypted relay event timeout')) + }, 10_000) + eventWaiters.push(waiter) + }) + }, + close: () => new Promise(resolve => { + if (socket.readyState === WebSocket.CLOSED) { resolve(); return } + socket.once('close', () => resolve()) + socket.close(1000, 'test browser disconnected') + }), + } +} + +async function encryptedRpc(input: { + hub: RunningHubServer + ticket: string + machineId: string + publicKey: string + request: { protocolVersion: 1; id: string; method: string; params: Record } +}): Promise { + const socket = new WebSocket(input.hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.browser.v1', input.ticket], { origin: input.hub.publicOrigin }) + const readyFrame = onceFrame(socket, frame => frame.type === 'ready') + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject) }) + const ready = await readyFrame.catch(error => { throw new Error(`browser relay ready failed: ${(error as Error).message}`) }) + if (ready.type !== 'ready') throw new Error('missing ready') + expect(ready.machineId).toBe(input.machineId) + const ecdh = createECDH('prime256v1') + ecdh.generateKeys() + const clientKey = ecdh.getPublicKey().toString('base64url') + const helloFrame = onceFrame(socket, frame => frame.type === 'serverHello') + socket.send(JSON.stringify({ type: 'clientHello', connectionId: ready.connectionId, key: clientKey } satisfies HubRelayControlFrame)) + const hello = await helloFrame.catch(error => { throw new Error(`Brain end-to-end hello failed: ${(error as Error).message}`) }) + if (hello.type !== 'serverHello') throw new Error('missing server hello') + const transcript = Buffer.from(`crewcode-hub-relay-v1\0${ready.connectionId}\0${clientKey}\0${hello.key}`) + const machineKey = createPublicKey({ key: Buffer.from(input.publicKey, 'base64url'), type: 'spki', format: 'der' }) + expect(verify(null, transcript, machineKey, Buffer.from(hello.signature, 'base64url'))).toBe(true) + const shared = ecdh.computeSecret(Buffer.from(hello.key, 'base64url')) + const salt = createHash('sha256').update(transcript).digest() + const browserKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('browser-to-brain'), 32)) + const brainKey = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from('brain-to-browser'), 32)) + const plaintext: HubTunnelPlaintext = { type: 'rpc', request: input.request } + const cipher = createCipheriv('aes-256-gcm', browserKey, relayNonce('browser', 0)) + cipher.setAAD(Buffer.from(`${ready.connectionId}\0browser\0${0}`)) + const body = Buffer.concat([cipher.update(JSON.stringify(plaintext)), cipher.final(), cipher.getAuthTag()]) + const encryptedFrame = onceFrame(socket, frame => frame.type === 'encrypted') + socket.send(JSON.stringify({ type: 'encrypted', connectionId: ready.connectionId, sequence: 0, ciphertext: body.toString('base64url') } satisfies HubRelayControlFrame)) + const encrypted = await encryptedFrame + if (encrypted.type !== 'encrypted') throw new Error('missing encrypted response') + const encoded = Buffer.from(encrypted.ciphertext, 'base64url') + const decipher = createDecipheriv('aes-256-gcm', brainKey, relayNonce('brain', encrypted.sequence)) + decipher.setAAD(Buffer.from(`${ready.connectionId}\0brain\0${encrypted.sequence}`)) + decipher.setAuthTag(encoded.subarray(-16)) + const decoded = Buffer.concat([decipher.update(encoded.subarray(0, -16)), decipher.final()]).toString() + socket.close() + return JSON.parse(decoded) as HubTunnelPlaintext +} + +describe('Brain-local RPC authorization', () => { + it('classifies workspace, terminal, and agent methods without a permissive fallback', () => { + expect(brainScopeForMethod('workspaces.list')).toBe('workspace:read') + expect(brainScopeForMethod('fs.writeFile')).toBe('workspace:write') + expect(brainScopeForMethod('pty.create')).toBe('terminal') + expect(brainScopeForMethod('bridge.prompt')).toBe('agent') + expect(brainScopeForMethod('mcp.list')).toBe('agent') + expect(brainScopeForMethod('voice.transcribe')).toBe('agent') + expect(brainScopeForMethod('github.status')).toBe('workspace:read') + expect(brainScopeForMethod('gh.prMerge')).toBe('workspace:write') + expect(brainScopeForMethod('unknown.execute')).toBeNull() + }) +}) + +describe('Hub connection tickets', () => { + it('are short-lived, one-shot, and consume a guessed id', () => { + let now = 1_000 + const issuer = new HubConnectionTicketIssuer(() => now) + const first = issuer.issue({ userId: 'user', browserSessionId: 'session', machineId: 'machine', requestedScopes: ['workspace:read'] }) + expect(first.expiresAt).toBe(now + HUB_CONNECTION_TICKET_TTL_MS) + expect(HUB_CONNECTION_TICKET_TTL_MS).toBe(60_000) + expect(issuer.consume(first.ticket)).toMatchObject({ userId: 'user', requestedScopes: ['workspace:read'] }) + expect(issuer.consume(first.ticket)).toBeNull() + const guessed = issuer.issue({ userId: 'user', browserSessionId: 'session', machineId: 'machine', requestedScopes: [] }) + expect(issuer.consume(`${guessed.ticket.split('.')[0]}.wrong`)).toBeNull() + expect(issuer.consume(guessed.ticket)).toBeNull() + const expired = issuer.issue({ userId: 'user', browserSessionId: 'session', machineId: 'machine', requestedScopes: [] }) + now = expired.expiresAt + expect(issuer.consume(expired.ticket)).toBeNull() + }) +}) + +describe('authenticated encrypted Hub relay', () => { + it('routes encrypted RPC while the Brain independently denies ungranted scope', async () => { + const { hub, machineId, cookie, csrf, publicKey } = await fixture([]) + const ticketResponse = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:read'] }), + }) + expect(ticketResponse.status).toBe(201) + const { ticket } = await ticketResponse.json() as { ticket: string } + const result = await encryptedRpc({ hub, ticket, machineId, publicKey, request: { protocolVersion: 1, id: 'denied', method: 'workspaces.list', params: {} } }) + expect(result).toMatchObject({ type: 'rpcResult', response: { id: 'denied', ok: false, error: { code: 'FORBIDDEN' } } }) + }) + + it('starts the headless agent boundary without depending on Electron app paths', async () => { + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'agent']) + const issue = async (requestedScopes: Array<'workspace:write' | 'agent'>): Promise => { + const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes }), + }) + return ((await response.json()) as { ticket: string }).ticket + } + const added = await encryptedRpc({ + hub, ticket: await issue(['workspace:write']), machineId, publicKey, + request: { protocolVersion: 1, id: 'add', method: 'workspaces.add', params: { path: workspaceRoot } }, + }) + expect(added).toMatchObject({ type: 'rpcResult', response: { ok: true } }) + const started = await encryptedRpc({ + hub, ticket: await issue(['agent']), machineId, publicKey, + request: { + protocolVersion: 1, id: 'agent', method: 'bridge.start', + params: { bridgeId: 'browser-agent', provider: 'openrouter', cwd: workspaceRoot, conversationScopeKey: 'test-session' }, + }, + }) + expect(started).toMatchObject({ + type: 'rpcResult', + response: { ok: true, result: { error: 'openrouter API key not set' } }, + }) + }) + + it('closes only the abusive logical connection when its frame budget is exhausted', async () => { + const { hub, brain, machineId, machineToken, cookie, csrf } = await fixture([]) + await brain.close() + const rawBrain = new WebSocket(hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.brain.v1', machineToken]) + const brainReady = onceFrame(rawBrain, frame => frame.type === 'brainReady') + await new Promise((resolve, reject) => { rawBrain.once('open', resolve); rawBrain.once('error', reject) }) + await brainReady + + const issue = async (): Promise => { + const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: [] }), + }) + return ((await response.json()) as { ticket: string }).ticket + } + const open = async (ticket: string): Promise<{ socket: WebSocket; ready: Extract }> => { + const socket = new WebSocket(hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.browser.v1', ticket], { origin: hub.publicOrigin }) + const readyFrame = onceFrame(socket, frame => frame.type === 'ready') + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject) }) + return { socket, ready: await readyFrame as Extract } + } + + const first = await open(await issue()) + const closed = new Promise<{ code: number; reason: string }>((resolve, reject) => { + first.socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() })) + first.socket.once('error', reject) + }) + for (let index = 0; index < 300; index += 1) { + first.socket.send(JSON.stringify({ type: 'clientHello', connectionId: first.ready.connectionId, key: 'flood' } satisfies HubRelayControlFrame)) + } + await expect(closed).resolves.toEqual({ code: 4011, reason: 'frame rate limit' }) + + // The Brain WebSocket multiplexes sessions and must survive one browser's + // traffic violation; a fresh ticket can still establish another session. + const second = await open(await issue()) + expect(second.ready.machineId).toBe(machineId) + second.socket.close() + rawBrain.close() + }) + + it('detaches a terminal on browser loss and explicitly reclaims the live process', async () => { + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'terminal']) + const issue = async (): Promise => { + const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:write', 'terminal'] }), + }) + return ((await response.json()) as { ticket: string }).ticket + } + const first = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await expect(first.rpc({ protocolVersion: 1, id: 'add-custody-root', method: 'workspaces.add', params: { path: workspaceRoot } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) + await expect(first.rpc({ protocolVersion: 1, id: 'create-custody-pty', method: 'pty.create', params: { paneId: 'durable-pane', cwd: workspaceRoot, shell: '/bin/sh' } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, pid: expect.any(Number) } } }) + await first.close() + + const second = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await expect(second.rpc({ protocolVersion: 1, id: 'claim-custody-pty', method: 'pty.claim', params: { paneIds: ['durable-pane'] } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { claimed: ['durable-pane'] } } }) + await expect(second.rpc({ protocolVersion: 1, id: 'reattach-custody-pty', method: 'pty.create', params: { paneId: 'durable-pane', cwd: workspaceRoot, shell: '/bin/sh' } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, attached: true } } }) + await second.rpc({ protocolVersion: 1, id: 'stop-custody-pty', method: 'pty.kill', params: { paneId: 'durable-pane' } }) + await second.close() + }) + + it('keeps an agent prompt running after browser loss and replays its reply only after explicit reclaim', async () => { + let releaseReply!: () => void + let requestStartedResolve!: () => void + const replyReleased = new Promise(resolve => { releaseReply = resolve }) + const requestStarted = new Promise(resolve => { requestStartedResolve = resolve }) + let requestCount = 0 + const ollama = createServer(async (request, response) => { + if (request.url !== '/api/chat') { response.writeHead(404).end(); return } + requestCount += 1 + requestStartedResolve() + await replyReleased + response.writeHead(200, { 'content-type': 'application/x-ndjson' }) + response.end([ + JSON.stringify({ message: { role: 'assistant', content: 'finished while detached' }, done: false }), + JSON.stringify({ message: { role: 'assistant', content: '' }, done: true, prompt_eval_count: 3, eval_count: 3 }), + '', + ].join('\n')) + }) + await new Promise((resolve, reject) => { + ollama.once('error', reject) + ollama.listen(0, '127.0.0.1', resolve) + }) + cleanups.push(() => new Promise(resolve => ollama.close(() => resolve()))) + const address = ollama.address() + if (!address || typeof address === 'string') throw new Error('fake Ollama did not bind a TCP port') + const previousOllamaHost = process.env.OLLAMA_HOST + process.env.OLLAMA_HOST = `http://127.0.0.1:${address.port}` + cleanups.push(() => { + if (previousOllamaHost === undefined) delete process.env.OLLAMA_HOST + else process.env.OLLAMA_HOST = previousOllamaHost + }) + + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'agent']) + const issue = async (): Promise => { + const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:write', 'agent'] }), + }) + const body = await response.json() as { ticket?: string; error?: string } + if (!response.ok || !body.ticket) throw new Error(`ticket issuance failed (${response.status}): ${body.error ?? 'missing ticket'}`) + return body.ticket + } + const bridgeId = 'detached-agent' + const first = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await expect(first.rpc({ protocolVersion: 1, id: 'add-detached-root', method: 'workspaces.add', params: { path: workspaceRoot } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) + const started = await first.rpc({ + protocolVersion: 1, id: 'start-detached-agent', method: 'bridge.start', + params: { bridgeId, provider: 'ollama', model: 'fake-model', cwd: workspaceRoot, conversationScopeKey: 'detached-chat' }, + }) + if (started.type !== 'rpcResult' || !started.response.ok) throw new Error(`bridge.start failed: ${JSON.stringify(started)}`) + expect(started).toMatchObject({ type: 'rpcResult', response: { result: { ok: true } } }) + + // Keep the old browser connection open to reproduce a real refresh race: + // the replacement page can connect before Brain observes pagehide/socket + // close. The prompt must transfer without restarting the provider. + void first.rpc({ protocolVersion: 1, id: 'detached-prompt', method: 'bridge.prompt', params: { bridgeId, text: 'complete later' } }) + await requestStarted + + const second = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await expect(second.rpc({ protocolVersion: 1, id: 'claim-detached-agent', method: 'bridge.claim', params: { bridgeIds: [bridgeId] } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { claimed: [bridgeId] } } }) + // A restored renderer can issue its stable start after WebConnectionScreen + // has already claimed the resource. That start must remain an idempotent + // attach; forwarding it would make AgentBridgeService.start stop the live + // provider before replacing it. + await expect(second.rpc({ + protocolVersion: 1, id: 'reattach-claimed-agent', method: 'bridge.start', + params: { bridgeId, provider: 'ollama', model: 'fake-model', cwd: workspaceRoot, conversationScopeKey: 'detached-chat' }, + })).resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, attached: true } } }) + releaseReply() + const replayedText = await second.nextEvent(message => message.channel === 'bridge' + && (message.event as { type?: string; bridgeId?: string; delta?: string }).type === 'text_delta' + && (message.event as { bridgeId?: string }).bridgeId === bridgeId) + expect(replayedText).toMatchObject({ + type: 'event', channel: 'bridge', + event: { type: 'text_delta', bridgeId, delta: 'finished while detached' }, + }) + expect(requestCount).toBe(1) + // Closing the superseded connection after handoff must not detach the new + // owner or make its next operation fail ownership checks. + await first.close() + await expect(second.rpc({ protocolVersion: 1, id: 'compact-after-handoff', method: 'bridge.compact', params: { bridgeId } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) + + // Recovery must not depend exclusively on the page's eager claim pass. A + // restored renderer always reasserts its deterministic bridge.start before + // prompting; that start atomically transfers same-owner custody even while + // the superseded browser still appears attached. + const third = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await expect(third.rpc({ + protocolVersion: 1, id: 'reattach-without-claim', method: 'bridge.start', + params: { bridgeId, provider: 'ollama', model: 'fake-model', cwd: workspaceRoot, conversationScopeKey: 'detached-chat' }, + })).resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, attached: true } } }) + await second.close() + await expect(third.rpc({ protocolVersion: 1, id: 'prompt-after-page-return', method: 'bridge.prompt', params: { bridgeId, text: 'continue working' } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true } } }) + expect(requestCount).toBe(2) + await third.rpc({ protocolVersion: 1, id: 'stop-detached-agent', method: 'bridge.stop', params: { bridgeId } }) + await third.close() + }) + + it('replaces browser-persisted partial text with the Brain snapshot after close', async () => { + let releaseFinal!: () => void + let firstChunkResolve!: () => void + const releaseFinalChunk = new Promise(resolve => { releaseFinal = resolve }) + const firstChunkWritten = new Promise(resolve => { firstChunkResolve = resolve }) + let requestCount = 0 + const ollama = createServer(async (request, response) => { + if (request.url !== '/api/chat') { response.writeHead(404).end(); return } + requestCount += 1 + response.writeHead(200, { 'content-type': 'application/x-ndjson' }) + response.write(JSON.stringify({ message: { role: 'assistant', content: 'The Nephilim are ' }, done: false }) + '\n') + firstChunkResolve() + await releaseFinalChunk + response.end([ + JSON.stringify({ message: { role: 'assistant', content: 'mysterious figures mentioned in ancient texts.' }, done: false }), + JSON.stringify({ message: { role: 'assistant', content: '' }, done: true, prompt_eval_count: 3, eval_count: 8 }), + '', + ].join('\n')) + }) + await new Promise((resolve, reject) => { + ollama.once('error', reject) + ollama.listen(0, '127.0.0.1', resolve) + }) + cleanups.push(() => new Promise(resolve => ollama.close(() => resolve()))) + const address = ollama.address() + if (!address || typeof address === 'string') throw new Error('fake Ollama did not bind a TCP port') + const previousOllamaHost = process.env.OLLAMA_HOST + process.env.OLLAMA_HOST = `http://127.0.0.1:${address.port}` + cleanups.push(() => { + if (previousOllamaHost === undefined) delete process.env.OLLAMA_HOST + else process.env.OLLAMA_HOST = previousOllamaHost + }) + + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'agent']) + const issue = async (): Promise => { + const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:write', 'agent'] }), + }) + return ((await response.json()) as { ticket: string }).ticket + } + const bridgeId = 'partial-text-agent' + const first = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await first.rpc({ protocolVersion: 1, id: 'add-partial-root', method: 'workspaces.add', params: { path: workspaceRoot } }) + await first.rpc({ + protocolVersion: 1, id: 'start-partial-agent', method: 'bridge.start', + params: { bridgeId, provider: 'ollama', model: 'fake-model', cwd: workspaceRoot, conversationScopeKey: 'partial-chat' }, + }) + void first.rpc({ protocolVersion: 1, id: 'partial-prompt', method: 'bridge.prompt', params: { bridgeId, text: 'Explain' } }) + await firstChunkWritten + await expect(first.nextEvent(message => message.channel === 'bridge' + && (message.event as { type?: string }).type === 'text_delta')) + .resolves.toMatchObject({ event: { delta: 'The Nephilim are ' } }) + + // This is the observed browser flow: a partial answer was rendered and + // persisted, then the tab closed while the same provider turn continued. + await first.close() + const second = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + await second.rpc({ protocolVersion: 1, id: 'claim-partial-agent', method: 'bridge.claim', params: { bridgeIds: [bridgeId] } }) + const replacement = await second.nextEvent(message => message.channel === 'bridge' + && (message.event as { type?: string }).type === 'history_agent') + expect(replacement).toMatchObject({ + event: { type: 'history_agent', bridgeId, text: 'The Nephilim are ' }, + }) + + releaseFinal() + const continued = await second.nextEvent(message => message.channel === 'bridge' + && (message.event as { type?: string; delta?: string }).type === 'text_delta' + && (message.event as { delta?: string }).delta?.includes('mysterious figures') === true) + expect(continued).toMatchObject({ + event: { delta: 'mysterious figures mentioned in ancient texts.' }, + }) + expect(requestCount).toBe(1) + await second.rpc({ protocolVersion: 1, id: 'stop-partial-agent', method: 'bridge.stop', params: { bridgeId } }) + await second.close() + }) + + it('executes a scoped read RPC and rejects ticket replay', async () => { + const { hub, machineId, cookie, csrf, publicKey } = await fixture(['workspace:read']) + const issue = () => fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:read'] }), + }) + const ticketResponse = await issue() + const { ticket } = await ticketResponse.json() as { ticket: string } + const result = await encryptedRpc({ hub, ticket, machineId, publicKey, request: { protocolVersion: 1, id: 'list', method: 'workspaces.list', params: {} } }) + expect(result).toEqual({ type: 'rpcResult', response: { protocolVersion: 1, id: 'list', ok: true, result: [] } }) + const replay = new WebSocket(hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.browser.v1', ticket], { origin: hub.publicOrigin }) + const code = await new Promise((resolve, reject) => { replay.once('close', resolve); replay.once('error', reject) }) + expect(code).toBe(4001) + }) +}) diff --git a/src/main/hub-server.test.ts b/src/main/hub-server.test.ts index 234a97b..0106844 100644 --- a/src/main/hub-server.test.ts +++ b/src/main/hub-server.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' -import { startHubServer, type RunningHubServer } from './hub-server' +import { hubRelayExpiryReason, startHubServer, type RunningHubServer } from './hub-server' +import { HUB_RELAY_ABSOLUTE_TIMEOUT_MS, HUB_RELAY_IDLE_TIMEOUT_MS } from '../shared/hub-relay-types' import { HubEnrollmentIssuer, HUB_ENROLLMENT_TTL_MS } from './hub-machine-enrollment' import { HubStore } from './hub-store' @@ -108,6 +109,18 @@ describe('Hub store', () => { }) }) +describe('Hub relay expiry', () => { + it('expires idle connections and enforces an absolute lifetime despite activity', () => { + const connection = { openedAt: 1_000, lastActivityAt: 1_000 } + expect(hubRelayExpiryReason(connection, 1_000 + HUB_RELAY_IDLE_TIMEOUT_MS - 1)).toBeNull() + expect(hubRelayExpiryReason(connection, 1_000 + HUB_RELAY_IDLE_TIMEOUT_MS)).toBe('idle timeout') + + connection.lastActivityAt = 1_000 + HUB_RELAY_ABSOLUTE_TIMEOUT_MS - 1 + expect(hubRelayExpiryReason(connection, 1_000 + HUB_RELAY_ABSOLUTE_TIMEOUT_MS - 1)).toBeNull() + expect(hubRelayExpiryReason(connection, 1_000 + HUB_RELAY_ABSOLUTE_TIMEOUT_MS)).toBe('absolute timeout') + }) +}) + describe('Hub HTTP security boundary', () => { it('does not disclose the one-time bootstrap token through status', async () => { const running = await server() diff --git a/src/main/hub-server.ts b/src/main/hub-server.ts index cb36b0e..fa63883 100644 --- a/src/main/hub-server.ts +++ b/src/main/hub-server.ts @@ -1,8 +1,20 @@ import { createPublicKey } from 'crypto' import { createServer, type IncomingMessage, type ServerResponse } from 'http' -import { join } from 'path' +import { randomBytes } from 'crypto' +import { WebSocket, WebSocketServer } from 'ws' +import { extname, join, normalize, sep } from 'path' +import { existsSync, readFileSync, statSync } from 'fs' import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server' import { remotePeerKey, RemoteAccessRateLimiter } from './remote-access-security' +import { HubConnectionTicketIssuer } from './hub-connection-tickets' +import { HubRelayTrafficLimiter } from './hub-relay-limits' +import { + HUB_RELAY_ABSOLUTE_TIMEOUT_MS, + HUB_RELAY_IDLE_TIMEOUT_MS, + HUB_RELAY_MAX_FRAME_BYTES, + type BrainAccessScope, + type HubRelayControlFrame, +} from '../shared/hub-relay-types' import { HubAuth } from './hub-auth' import { HubEnrollmentIssuer, HUB_MACHINE_ONLINE_WINDOW_MS } from './hub-machine-enrollment' import { HubStore, type HubSession } from './hub-store' @@ -10,14 +22,23 @@ import { HubStore, type HubSession } from './hub-store' const MAX_BODY_BYTES = 1024 * 1024 const HUB_AUTH_ATTEMPTS_PER_MINUTE = 30 const HUB_MACHINE_ATTEMPTS_PER_MINUTE = 60 +const HUB_MAX_CONNECTIONS_PER_MACHINE = 4 +const HUB_MAX_CONNECTIONS_PER_USER = 8 +const HUB_RELAY_EXPIRY_SWEEP_MS = 30_000 +const HUB_LATE_BRAIN_FRAME_GRACE_MS = 30_000 +const HUB_MAX_RECENTLY_RELEASED_CONNECTIONS = 1_000 const SESSION_COOKIE_HTTP = 'crewcode_hub_session' const SESSION_COOKIE_HTTPS = '__Host-crewcode_hub_session' +const HUB_BROWSER_RELAY_PROTOCOL = 'crewcode.browser.v1' +const HUB_BRAIN_RELAY_PROTOCOL = 'crewcode.brain.v1' +const VALID_BRAIN_SCOPES = new Set(['workspace:read', 'workspace:write', 'terminal', 'agent']) export interface HubServerOptions { host?: string port?: number dataDir: string publicOrigin?: string + webRoot?: string now?: () => number } @@ -75,6 +96,22 @@ function boundedString(value: unknown, field: string, maximum: number, nullable return normalized } +export function hubRelayExpiryReason( + connection: { openedAt: number; lastActivityAt: number }, + at: number, +): 'idle timeout' | 'absolute timeout' | null { + if (at - connection.openedAt >= HUB_RELAY_ABSOLUTE_TIMEOUT_MS) return 'absolute timeout' + if (at - connection.lastActivityAt >= HUB_RELAY_IDLE_TIMEOUT_MS) return 'idle timeout' + return null +} + +function requestedBrainScopes(value: unknown): BrainAccessScope[] { + if (!Array.isArray(value) || value.length > VALID_BRAIN_SCOPES.size) throw new Error('requestedScopes must be a bounded array') + const scopes = value.map(item => String(item) as BrainAccessScope) + if (scopes.some(scope => !VALID_BRAIN_SCOPES.has(scope)) || new Set(scopes).size !== scopes.length) throw new Error('requestedScopes contains an invalid or duplicate scope') + return scopes +} + function machinePublicKey(value: unknown): string { const encoded = boundedString(value, 'publicKey', 256) as string if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error('publicKey must be base64url encoded') @@ -142,13 +179,32 @@ const creation=o=>({...o,challenge:bytes(o.challenge),user:{...o.user,id:bytes(o const request=o=>({...o,challenge:bytes(o.challenge),allowCredentials:(o.allowCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); const authError=e=>{const message=e&&e.message?e.message:String(e);if(!window.isSecureContext)return'Passkeys require a secure browser context. Open the exact localhost URL printed by CrewCode, or use the configured HTTPS Hub origin.';if(message.includes('InsecureLocalhostNotAllowed'))return'This browser or passkey provider refuses passkeys over HTTP localhost. For local testing, try current Chrome or Chromium. Otherwise run the Hub at its final HTTPS origin and create the passkey there.';return message}; function view(name){for(const id of ['setup','signin','dashboard'])$(id).hidden=id!==name} -async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};row.append(revoke)}list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} +async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');if(x.status==='online'){const open=document.createElement('button');open.textContent='Open';open.onclick=()=>{location.href='/app?machine='+encodeURIComponent(x.id)};actions.append(open)}if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(revoke)}row.append(actions);list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} $('setup-button').onclick=async()=>{try{error.textContent='';const token=new URLSearchParams(location.hash.slice(1)).get('bootstrap')||'';const username=$('owner').value;const start=await json('/api/v1/hub/bootstrap/options',{method:'POST',body:JSON.stringify({token,username})});const credential=await navigator.credentials.create({publicKey:creation(start.options)});const done=await json('/api/v1/hub/bootstrap/verify',{method:'POST',body:JSON.stringify({token,username,flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;history.replaceState(null,'',location.pathname);await refresh()}catch(e){error.textContent=authError(e)}}; $('signin-button').onclick=async()=>{try{error.textContent='';const start=await json('/api/v1/hub/auth/options',{method:'POST',body:'{}'});const credential=await navigator.credentials.get({publicKey:request(start.options)});const done=await json('/api/v1/hub/auth/verify',{method:'POST',body:JSON.stringify({flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;await refresh()}catch(e){error.textContent=authError(e)}}; $('enrollment-button').onclick=async()=>{try{error.textContent='';const issued=await json('/api/v1/hub/enrollments',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'}),out=$('enrollment');out.hidden=false;out.textContent='Enrollment token (single use; do not share):\\n'+issued.token+'\\n\\nRun on the machine within 10 minutes, then paste the token when prompted:\\ncrewcode enroll --hub '+location.origin}catch(e){error.textContent=e.message}}; $('logout-button').onclick=async()=>{try{await json('/api/v1/hub/logout',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});csrf='';$('enrollment').hidden=true;$('enrollment').textContent='';await refresh()}catch(e){error.textContent=e.message}}; refresh().catch(e=>{status.textContent='Could not connect';error.textContent=e.message});})();` +function serveHubApp(webRoot: string | undefined, pathname: string, response: ServerResponse): boolean { + if (!webRoot || (pathname !== '/app' && !pathname.startsWith('/assets/'))) return false + const candidate = pathname === '/app' ? join(webRoot, 'index.html') : normalize(join(webRoot, pathname.replace(/^\/+/, ''))) + const normalizedRoot = normalize(webRoot) + if (candidate !== normalizedRoot && !candidate.startsWith(normalizedRoot + sep)) return false + if (!existsSync(candidate) || !statSync(candidate).isFile()) return false + const mime: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' } + const body = readFileSync(candidate) + response.writeHead(200, { + 'content-type': mime[extname(candidate)] ?? 'application/octet-stream', + 'content-length': body.byteLength, + 'cache-control': pathname === '/app' ? 'no-store' : 'public, max-age=300', + 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + 'x-content-type-options': 'nosniff', + }) + response.end(body) + return true +} + function serveAsset(pathname: string, response: ServerResponse): boolean { let body: string let type: string @@ -175,6 +231,7 @@ export async function startHubServer(options: HubServerOptions): Promise() + const relaySockets = new Set() + const relayPeer = new WeakMap() + const relayConnections = new Map() + const recentlyReleasedBrowserConnections = new Map() + const rememberReleasedBrowserConnection = (connectionId: string): void => { + recentlyReleasedBrowserConnections.delete(connectionId) + recentlyReleasedBrowserConnections.set(connectionId, now()) + while (recentlyReleasedBrowserConnections.size > HUB_MAX_RECENTLY_RELEASED_CONNECTIONS) { + const oldest = recentlyReleasedBrowserConnections.keys().next().value as string | undefined + if (!oldest) break + recentlyReleasedBrowserConnections.delete(oldest) + } + } + + const terminateRelayConnection = ( + connectionId: string, + connection: (typeof relayConnections extends Map ? Value : never), + code: number, + reason: string, + auditType: string, + metadata: Record = {}, + ): void => { + if (connection.brain.readyState === WebSocket.OPEN) { + connection.brain.send(JSON.stringify({ type: 'close', connectionId, reason } satisfies HubRelayControlFrame)) + } + if (connection.browser.readyState === WebSocket.OPEN) connection.browser.close(code, reason) + relayConnections.delete(connectionId) + store.audit(auditType, connection.userId, connection.machineId, { + connectionId, + browserSessionId: connection.browserSessionId, + ...metadata, + }, now()) + } + const server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? '/', 'http://localhost').pathname @@ -251,6 +352,21 @@ export async function startHubServer(options: HubServerOptions): Promise protocols.has(HUB_BRAIN_RELAY_PROTOCOL) + ? HUB_BRAIN_RELAY_PROTOCOL + : protocols.has(HUB_BROWSER_RELAY_PROTOCOL) ? HUB_BROWSER_RELAY_PROTOCOL : false, + }) + server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname + const protocols = String(request.headers['sec-websocket-protocol'] ?? '').split(',').map(value => value.trim()).filter(Boolean) + const protocol = protocols.find(value => value === HUB_BRAIN_RELAY_PROTOCOL || value === HUB_BROWSER_RELAY_PROTOCOL) + const credential = protocols.find(value => value !== protocol) ?? '' + if (pathname !== '/api/v1/hub/relay' || !protocol || !credential) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') + socket.destroy() + return + } + if (protocol === HUB_BRAIN_RELAY_PROTOCOL) { + const machine = store.authenticateMachine(credential) + if (!machine) { socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); socket.destroy(); return } + relayPeer.set(request, { kind: 'brain', machineId: machine.id }) + } else { + if (!hubBrowserOriginAllowed(request, publicOrigin)) { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); return } + relayPeer.set(request, { kind: 'browser', ticket: credential }) + } + websocketServer.handleUpgrade(request, socket, head, ws => websocketServer.emit('connection', ws, request)) + }) + websocketServer.on('connection', (socket, request) => { + const peer = relayPeer.get(request) + if (!peer) { socket.close(4001, 'relay authentication missing'); return } + relaySockets.add(socket) + if (peer.kind === 'brain') { + const previous = brainSockets.get(peer.machineId) + if (previous && previous !== socket) previous.close(4000, 'replaced by a newer brain relay') + brainSockets.set(peer.machineId, socket) + socket.send(JSON.stringify({ type: 'brainReady', machineId: peer.machineId } satisfies HubRelayControlFrame)) + } else { + const claims = tickets.consume(peer.ticket) + if (!claims) { socket.close(4001, 'invalid or expired connection ticket'); return } + const brain = brainSockets.get(claims.machineId) + const machine = store.machineAuthorityForUser(claims.userId, claims.machineId) + if (!brain || brain.readyState !== WebSocket.OPEN || !machine) { socket.close(4004, 'machine relay is offline'); return } + const activeConnections = [...relayConnections.values()] + if (activeConnections.filter(connection => connection.machineId === claims.machineId).length >= HUB_MAX_CONNECTIONS_PER_MACHINE + || activeConnections.filter(connection => connection.userId === claims.userId).length >= HUB_MAX_CONNECTIONS_PER_USER) { + socket.close(4008, 'relay connection limit reached') + return + } + const connectionId = randomBytes(16).toString('hex') + const openedAt = now() + relayConnections.set(connectionId, { + machineId: claims.machineId, userId: claims.userId, browserSessionId: claims.browserSessionId, + brain, browser: socket, openedAt, lastActivityAt: openedAt, + traffic: new HubRelayTrafficLimiter(openedAt), + }) + const connect: HubRelayControlFrame = { type: 'connect', connectionId, userId: claims.userId, browserSessionId: claims.browserSessionId, requestedScopes: claims.requestedScopes } + const ready: HubRelayControlFrame = { type: 'ready', connectionId, machineId: claims.machineId, machinePublicKey: machine.publicKey, requestedScopes: claims.requestedScopes } + brain.send(JSON.stringify(connect)) + socket.send(JSON.stringify(ready)) + store.audit('hub.connection.opened', claims.userId, claims.machineId, { connectionId, browserSessionId: claims.browserSessionId }, now()) + } + socket.on('message', (raw, binary) => { + const encoded = Array.isArray(raw) ? Buffer.concat(raw) : Buffer.from(raw as ArrayBuffer) + if (binary || encoded.byteLength > HUB_RELAY_MAX_FRAME_BYTES) { socket.close(4009, 'relay frame rejected'); return } + let frame: HubRelayControlFrame + try { frame = JSON.parse(encoded.toString()) as HubRelayControlFrame } catch { socket.close(4002, 'invalid relay frame'); return } + if (!frame || !('connectionId' in frame) || typeof frame.connectionId !== 'string') { socket.close(4002, 'invalid relay frame'); return } + const connectionId = frame.connectionId + const connection = relayConnections.get(connectionId) + if (!connection) { + // A browser close and an in-flight Brain RPC result can cross in + // transit. Drop late opaque frames only for bounded connection ids the + // Hub itself recently released; arbitrary unknown ids still fail closed. + const releasedAt = recentlyReleasedBrowserConnections.get(connectionId) + if (peer.kind === 'brain' && releasedAt !== undefined && now() - releasedAt < HUB_LATE_BRAIN_FRAME_GRACE_MS) return + if (releasedAt !== undefined) recentlyReleasedBrowserConnections.delete(connectionId) + socket.close(4004, 'unknown relay connection') + return + } + const fromBrowser = socket === connection.browser + const allowed = fromBrowser + ? frame.type === 'clientHello' || frame.type === 'encrypted' || frame.type === 'close' + : socket === connection.brain && (frame.type === 'serverHello' || frame.type === 'encrypted' || frame.type === 'close') + if (!allowed) { socket.close(4003, 'relay direction is not allowed'); return } + const receivedAt = now() + const trafficLimit = connection.traffic.consume(encoded.byteLength, receivedAt) + if (trafficLimit) { + terminateRelayConnection(connectionId, connection, 4011, trafficLimit, 'hub.connection.rate-limited', { + reason: trafficLimit, + frameBytes: encoded.byteLength, + direction: fromBrowser ? 'browser-to-brain' : 'brain-to-browser', + }) + return + } + connection.lastActivityAt = receivedAt + const target = fromBrowser ? connection.brain : connection.browser + if (target.readyState !== WebSocket.OPEN || target.bufferedAmount > HUB_RELAY_MAX_FRAME_BYTES * 4) { + socket.close(4010, 'relay backpressure limit reached') + target.close(4010, 'relay backpressure limit reached') + relayConnections.delete(frame.connectionId) + return + } + target.send(encoded.toString()) + if (frame.type === 'close') { + if (fromBrowser) rememberReleasedBrowserConnection(connectionId) + relayConnections.delete(connectionId) + } + }) + socket.on('close', () => { + relaySockets.delete(socket) + if (peer.kind === 'brain' && brainSockets.get(peer.machineId) === socket) brainSockets.delete(peer.machineId) + for (const [connectionId, connection] of relayConnections) { + if (connection.brain !== socket && connection.browser !== socket) continue + if (connection.brain === socket) { + if (connection.browser.readyState === WebSocket.OPEN) connection.browser.close(4000, 'Brain relay disconnected') + } else if (connection.brain.readyState === WebSocket.OPEN) { + // The Brain socket is the machine's persistent outbound transport and + // can multiplex browser sessions. Closing one browser must release + // only that logical session, not disconnect the enrolled machine. + rememberReleasedBrowserConnection(connectionId) + connection.brain.send(JSON.stringify({ type: 'close', connectionId, reason: 'browser disconnected' } satisfies HubRelayControlFrame)) + } + relayConnections.delete(connectionId) + } + }) + }) + + const relayExpirySweep = setInterval(() => { + const at = now() + for (const [connectionId, releasedAt] of recentlyReleasedBrowserConnections) { + if (at - releasedAt >= HUB_LATE_BRAIN_FRAME_GRACE_MS) recentlyReleasedBrowserConnections.delete(connectionId) + } + for (const [connectionId, connection] of relayConnections) { + const reason = hubRelayExpiryReason(connection, at) + if (!reason) continue + terminateRelayConnection(connectionId, connection, 4000, reason, 'hub.connection.expired', { + reason, + openedAt: connection.openedAt, + lastActivityAt: connection.lastActivityAt, + }) + } + }, HUB_RELAY_EXPIRY_SWEEP_MS) + relayExpirySweep.unref() + await new Promise((resolve, reject) => { server.once('error', reject) server.listen(options.port ?? 0, host, () => { server.off('error', reject); resolve() }) @@ -325,9 +591,14 @@ export async function startHubServer(options: HubServerOptions): Promise new Promise((resolve, reject) => server.close(error => { - store.close() - error ? reject(error) : resolve() - })), + close: () => new Promise((resolve, reject) => { + clearInterval(relayExpirySweep) + for (const socket of relaySockets) socket.close(1001, 'Hub shutting down') + websocketServer.close() + server.close(error => { + store.close() + error ? reject(error) : resolve() + }) + }), } } diff --git a/src/main/hub-store.ts b/src/main/hub-store.ts index 069a5f9..1f2c632 100644 --- a/src/main/hub-store.ts +++ b/src/main/hub-store.ts @@ -47,6 +47,10 @@ export interface HubMachineIdentity { revokedAt: number | null } +export interface HubMachineAuthority extends HubMachineIdentity { + publicKey: string +} + interface UserRow { id: string; username: string; role: string; created_at: number } interface CredentialRow { id: string @@ -261,6 +265,12 @@ export class HubStore { return row ? { id: row.id, ownerUserId: row.owner_user_id, revokedAt: row.revoked_at } : null } + machineAuthorityForUser(userId: string, machineId: string): HubMachineAuthority | null { + const row = this.db.prepare('SELECT id, owner_user_id, public_key, revoked_at FROM machines WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL') + .get(machineId, userId) as { id: string; owner_user_id: string; public_key: string; revoked_at: number | null } | undefined + return row ? { id: row.id, ownerUserId: row.owner_user_id, publicKey: row.public_key, revokedAt: row.revoked_at } : null + } + heartbeatMachine(machineId: string, platform: string | null, version: string | null, now: number): boolean { const result = this.db.prepare("UPDATE machines SET status = 'online', platform = ?, version = ?, last_seen_at = ? WHERE id = ? AND revoked_at IS NULL") .run(platform, version, now, machineId) diff --git a/src/main/hub.ts b/src/main/hub.ts index 0853aa7..f751b95 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -1,4 +1,5 @@ import { homedir } from 'os' +import { existsSync } from 'fs' import { join, resolve } from 'path' import { startHubServer } from './hub-server' @@ -72,10 +73,15 @@ export function terminalLink(label: string, url: string, isTerminal = Boolean(pr return isTerminal ? `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007` : url } +function defaultWebRoot(): string | undefined { + const candidates = [resolve(__dirname, '../renderer'), resolve(__dirname, '../../out/renderer')] + return candidates.find(candidate => existsSync(join(candidate, 'index.html'))) +} + export async function runHub(argv = process.argv.slice(2)): Promise { const parsed = parseHubOptions(argv) if ('help' in parsed) { console.log(usage()); return } - const hub = await startHubServer(parsed) + const hub = await startHubServer({ ...parsed, webRoot: defaultWebRoot() }) console.log(`CrewCode Hub listening on ${hub.url}`) console.log(`Hub browser origin: ${hub.publicOrigin}`) if (hub.bootstrapUrl) { diff --git a/src/main/index.ts b/src/main/index.ts index c12df95..9bc4cce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import { getAgentKey, setAgentKey, hasAgentKey } from './agents/agent-keys' import { listModels } from './agents/model-detect' import { registerUpdaterIpc } from './updater' import { registerGhIpc, killActiveGhLogin } from './gh' +import { getGitHubStatus } from './github-service' import { registerSshIpc, resolveSshAgentAtStartup, killManagedSshAgent } from './ssh' import { registerRemoteIpc, disconnectAllRemotes } from './remote/remote-ipc' import { registerCustomCommandsIpc } from './customCommands' @@ -619,68 +620,4 @@ ipcMain.handle('browser:extractHover', (_e, args) => browserGrabManager.extractH // ─── GitHub status ──────────────────────────────────────────────────────────── -interface GitHubPR { - number: number - title: string - state: 'OPEN' | 'CLOSED' | 'MERGED' - branch: string - url: string -} - -interface GitHubRun { - id: number - name: string - status: 'queued' | 'in_progress' | 'completed' - conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null - branch: string -} - -function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} - -ipcMain.handle('github:status', (_e, repoPath: string) => { - const ghCheck = spawnSync('which', ['gh'], { encoding: 'utf8' }) - if (!ghCheck.stdout?.trim()) return { error: 'gh CLI not found' } - - const cwd = expandHome(repoPath) - - const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }) - const remoteUrl = remoteResult.stdout?.trim() ?? '' - - if (!remoteUrl.includes('github.com')) return { error: 'not a GitHub repo' } - - const parsed = parseGitHubRemote(remoteUrl) - if (!parsed) return { error: 'could not parse GitHub remote URL' } - const { owner, repo } = parsed - - let prs: GitHubPR[] = [] - const prResult = spawnSync('gh', ['pr', 'list', '--json', 'number,title,headRefName,state,url', '--limit', '20'], { cwd, encoding: 'utf8' }) - if (prResult.status === 0 && prResult.stdout) { - try { - const raw = JSON.parse(prResult.stdout) as Array<{ number: number; title: string; headRefName: string; state: string; url: string }> - prs = raw.map(pr => ({ number: pr.number, title: pr.title, state: pr.state as GitHubPR['state'], branch: pr.headRefName, url: pr.url })) - } catch (_err) { /* leave prs = [] */ } - } - - let runs: GitHubRun[] = [] - const runResult = spawnSync('gh', ['run', 'list', '--json', 'databaseId,name,status,conclusion,headBranch', '--limit', '10'], { cwd, encoding: 'utf8' }) - if (runResult.status === 0 && runResult.stdout) { - try { - const raw = JSON.parse(runResult.stdout) as Array<{ databaseId: number; name: string; status: string; conclusion: string | null; headBranch: string }> - runs = raw.map(r => ({ id: r.databaseId, name: r.name, status: r.status as GitHubRun['status'], conclusion: r.conclusion as GitHubRun['conclusion'], branch: r.headBranch })) - } catch (_err) { /* leave runs = [] */ } - } - - let issues = 0 - const issueResult = spawnSync('gh', ['issue', 'list', '--state', 'open', '--json', 'number', '--limit', '100'], { cwd, encoding: 'utf8' }) - if (issueResult.status === 0 && issueResult.stdout) { - try { - issues = (JSON.parse(issueResult.stdout) as unknown[]).length - } catch (_err) { /* leave issues = 0 */ } - } - - return { owner, repo, prs, runs, issues } -}) +ipcMain.handle('github:status', (_e, repoPath: string) => getGitHubStatus(expandHome(repoPath))) diff --git a/src/main/mcp-config-service.ts b/src/main/mcp-config-service.ts new file mode 100644 index 0000000..91c490d --- /dev/null +++ b/src/main/mcp-config-service.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import os from 'os' + +import { parseMcpConfig, type ParsedMcpConfig } from './mcp-config-parse' +import type { McpFileSnapshot } from '../shared/mcp-types' + +/** Electron-free MCP registry reader shared by desktop IPC and the headless Brain. */ +export function crewcodeMcpDir(): string { + return join(os.homedir(), '.crewcode') +} + +export function mcpConfigPath(): string { + return join(crewcodeMcpDir(), 'mcp.json') +} + +export function readMcpConfig(): McpFileSnapshot { + const path = mcpConfigPath() + if (!existsSync(path)) return { path, exists: false, servers: [], errors: [] } + + let parsed: ParsedMcpConfig + try { + parsed = parseMcpConfig(JSON.parse(readFileSync(path, 'utf8'))) + } catch (error) { + return { path, exists: true, servers: [], errors: [`mcp.json: ${(error as Error).message}`] } + } + return { path, exists: true, servers: parsed.servers, errors: parsed.errors } +} diff --git a/src/main/mcpConfig.ts b/src/main/mcpConfig.ts index ca4e600..23d7e57 100644 --- a/src/main/mcpConfig.ts +++ b/src/main/mcpConfig.ts @@ -1,9 +1,6 @@ import { ipcMain, shell, BrowserWindow } from 'electron' -import { existsSync, mkdirSync, readFileSync, writeFileSync, watch, type FSWatcher } from 'fs' -import { join } from 'path' -import os from 'os' -import { parseMcpConfig, type ParsedMcpConfig } from './mcp-config-parse' -import type { McpFileSnapshot } from '../shared/mcp-types' +import { existsSync, mkdirSync, writeFileSync, watch, type FSWatcher } from 'fs' +import { crewcodeMcpDir, mcpConfigPath, readMcpConfig } from './mcp-config-service' /** * ~/.crewcode/mcp.json is a user-editable MCP server registry. Settings reads it @@ -12,14 +9,6 @@ import type { McpFileSnapshot } from '../shared/mcp-types' * watches the file and broadcasts changes so Settings refreshes live. */ -function crewcodeDir(): string { - return join(os.homedir(), '.crewcode') -} - -function mcpConfigPath(): string { - return join(crewcodeDir(), 'mcp.json') -} - const TEMPLATE = `{ "mcpServers": { "filesystem": { @@ -31,28 +20,13 @@ const TEMPLATE = `{ ` function ensureConfigFile(): string { - const dir = crewcodeDir() + const dir = crewcodeMcpDir() if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) const file = mcpConfigPath() if (!existsSync(file)) writeFileSync(file, TEMPLATE) return file } -export function readMcpConfig(): McpFileSnapshot { - const path = mcpConfigPath() - if (!existsSync(path)) { - return { path, exists: false, servers: [], errors: [] } - } - let parsed: ParsedMcpConfig - try { - const raw = readFileSync(path, 'utf8') - parsed = parseMcpConfig(JSON.parse(raw)) - } catch (err) { - return { path, exists: true, servers: [], errors: [`mcp.json: ${(err as Error).message}`] } - } - return { path, exists: true, servers: parsed.servers, errors: parsed.errors } -} - let mcpWatcher: FSWatcher | null = null function broadcastMcpChanged(): void { @@ -64,7 +38,7 @@ function broadcastMcpChanged(): void { function startMcpWatcher(): void { if (mcpWatcher) return - const dir = crewcodeDir() + const dir = crewcodeMcpDir() if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) try { // Watch the directory (not the file) so create/delete/rename of mcp.json is diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index a50c924..48fc480 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -23,6 +23,9 @@ import { WorkspaceService } from './workspace-service' import { PtyService } from './pty-service' import { AgentBridgeService, type AgentPathResolver } from './agents/bridge-service' import { headlessAgentRegistry, listHeadlessAgentModels } from './headless-agent-resolver' +import { readMcpConfig } from './mcp-config-service' +import { getGhStatus, getGitHubStatus, runGh } from './github-service' +import { createVoiceClientSecret, synthesizeRemoteVoiceText, transcribeRemoteVoiceAudio, voiceProviderAvailability } from './voice-provider-auth' import { TranscriptService, type TranscriptBatchEntry } from './transcript-service' import { parsePorcelainWorktrees } from './worktree-list-parse' import { addWorktree, removeWorktree } from './worktree-ops' @@ -57,6 +60,19 @@ export interface RunningRemoteAccessServer { type RpcHandler = (params: Record) => unknown | Promise +const MAX_REMOTE_VOICE_AUDIO_BYTES = 8 * 1024 * 1024 + +function decodeRemoteVoiceAudio(value: unknown): Uint8Array | null { + if (typeof value !== 'string' || value.length === 0 || value.length > Math.ceil(MAX_REMOTE_VOICE_AUDIO_BYTES * 4 / 3) + 8) return null + const bytes = Buffer.from(value, 'base64') + return bytes.byteLength > 0 && bytes.byteLength <= MAX_REMOTE_VOICE_AUDIO_BYTES ? new Uint8Array(bytes) : null +} + +function remoteMcpServers(value: unknown) { + const ids = new Set(Array.isArray(value) ? value.map(String) : []) + return readMcpConfig().servers.filter(server => ids.has(server.id)) +} + function remoteError(code: CrewCodeRemoteError['code'], message: string): CrewCodeRemoteError { return { code, message } } @@ -99,7 +115,10 @@ function capabilitySnapshot(): CrewCodeServerCapabilities { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, runtime: 'server', platform: process.platform, - features: { workspaces: true, filesystem: true, git: true, terminals: true, agents: true }, + features: { + workspaces: true, filesystem: true, git: true, terminals: true, agents: true, + attachments: true, mcp: true, github: true, voice: true, editorFormat: true, + }, } } @@ -201,6 +220,27 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['workspaces.setFolder', params => workspaceService.setFolder(String(params.id ?? ''), typeof params.folder === 'string' ? params.folder : null)], ['agents.registry', () => headlessAgentRegistry()], ['agents.listModels', params => listHeadlessAgentModels(String(params.provider ?? ''))], + // Browser clients may inspect the Brain-owned registry, but never submit + // executable MCP commands or environment values of their own. + ['mcp.list', () => readMcpConfig()], + ['voice.availability', () => { + const availability = voiceProviderAvailability() + return { ...availability, local: { configured: false, available: false, reason: 'Brain-local voice is not exposed to browser clients yet.' } } + }], + ['voice.clientSecret', params => createVoiceClientSecret(params.request as Parameters[0])], + ['voice.transcribe', params => { + const provider = params.provider === 'openai' || params.provider === 'xai' ? params.provider : null + const audio = decodeRemoteVoiceAudio(params.audioBase64) + if (!provider || !audio) return { ok: false, error: 'Invalid remote dictation request.' } + return transcribeRemoteVoiceAudio(provider, audio) + }], + ['voice.synthesize', async params => { + const provider = params.provider === 'openai' || params.provider === 'xai' ? params.provider : null + if (!provider || typeof params.text !== 'string' || typeof params.voice !== 'string') return { ok: false, error: 'Invalid remote speech request.' } + const result = await synthesizeRemoteVoiceText(provider, params.text, params.voice) + if (!result.ok || !result.audio) return result + return { ...result, audio: Buffer.from(result.audio).toString('base64') } + }], ['transcripts.loadAll', () => transcriptService.loadAll()], ['transcripts.mtimes', () => transcriptService.mtimes()], ['transcripts.save', params => transcriptService.save(String(params.scopeId ?? ''), params.messages)], @@ -227,6 +267,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['fs.readFile', params => filesystemService.readFile(registeredRoot(params), String(params.sub ?? ''))], ['fs.readDataUrl', params => filesystemService.readDataUrl(registeredRoot(params), String(params.sub ?? ''))], ['fs.writeFile', params => filesystemService.writeFile(registeredRoot(params), String(params.sub ?? ''), String(params.text ?? ''))], + ['fs.format', params => filesystemService.format(registeredRoot(params), String(params.sub ?? ''), String(params.text ?? ''))], ['fs.mkdir', params => filesystemService.mkdir(registeredRoot(params), String(params.sub ?? ''))], ['fs.delete', params => filesystemService.delete(registeredRoot(params), String(params.sub ?? ''))], ['fs.rename', params => filesystemService.rename(registeredRoot(params), String(params.sub ?? ''), String(params.newName ?? ''))], @@ -244,6 +285,11 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['git.pull', params => gitService.simple(registeredRoot({ root: params.cwd }), 'pull')], ['git.fetch', params => gitService.simple(registeredRoot({ root: params.cwd }), 'fetch')], ['git.init', params => gitService.simple(registeredRoot({ root: params.cwd }), 'init')], + ['github.status', params => getGitHubStatus(registeredRoot({ root: params.cwd }))], + ['gh.status', () => getGhStatus()], + ['gh.prCreate', params => runGh(registeredRoot({ root: params.cwd }), ['pr', 'create', '--fill'])], + ['gh.prMerge', params => runGh(registeredRoot({ root: params.cwd }), ['pr', 'merge', String(Number(params.number)), '--squash'])], + ['gh.prApprove', params => runGh(registeredRoot({ root: params.cwd }), ['pr', 'review', String(Number(params.number)), '--approve'])], ['git.checkout', params => gitService.checkout(registeredRoot({ root: params.cwd }), String(params.branch ?? ''), false)], ['git.createBranch', params => gitService.checkout(registeredRoot({ root: params.cwd }), String(params.name ?? ''), true)], ['git.merge', params => gitService.merge(registeredRoot({ root: params.cwd }), String(params.ref ?? ''))], @@ -276,6 +322,10 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions conversationKey: typeof params.conversationScopeKey === 'string' ? `web:${params.conversationScopeKey}` : undefined, freshSession: params.freshSession === true, suppressProviderHistoryReplay: params.suppressProviderHistoryReplay === true, + // Resolve opaque selections against the Brain's local registry. This is + // intentionally not `params.mcpServers`: accepting command/env objects + // from a browser would turn bridge.start into arbitrary execution. + mcpServers: remoteMcpServers(params.mcpServerIds), } return agentService.start(opts) }], diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 885be7e..8ad532d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -94,6 +94,8 @@ import { useCodeEditorSessions } from './hooks/useEditorSessions' import { terminalTabDisplay } from './terminal-tab-display' import { playNotificationSound, usesNativeNotificationSound } from './notifications/notification-sounds' import { playSelectionSpeech, useSelectionSpeechState } from './voice/selection-speech-playback' +import { resolveSelectedWorktree, worktreeSelectionKey } from './surface-worktree-selection' +import { isSurfaceOpen, setSurfaceOpen, type SurfaceOpenState } from './surface-ui-state' import type { Message, TweakConfig, AgentInfo, AgentProviderId, ModeLevel, Session, Tab, GitHubStatus, Command } from './types' import type { PluginOpenContext, RegisteredPluginBrowserAction, RegisteredPluginChatAction, RegisteredPluginChatHeaderItem, RegisteredPluginEditorAction, RegisteredPluginGitLens, RegisteredPluginMissionWidget, RegisteredPluginSidebarPanel, RegisteredPluginStatusItem, RegisteredPluginTab, RegisteredPluginTerminalWatcher } from '../../shared/plugin-types' @@ -121,12 +123,12 @@ interface CanvasPaneState { } const ACTIVE_WORKSPACE_STORAGE = 'crewcode:activeWorkspaceId' -const ACTIVE_WORKTREE_STORAGE = 'crewcode:activeWorktreeIds:v1' +const SURFACE_WORKTREE_STORAGE = 'crewcode:surfaceWorktreeIds:v1' const GIT_OPEN_STORAGE = 'crewcode:gitOpenByTab:v1' const GIT_WIDTH_STORAGE = 'crewcode:gitWidthByTab:v1' const CHAT_UI_STORAGE = 'crewcode:chatUiByTab:v1' const WORKBENCH_PANES_STORAGE = 'crewcode:workbenchPanesByTab:v1' -const CHANGES_DRAWER_STORAGE = 'crewcode:changesDrawerOpenByWorkspace:v1' +const CHANGES_DRAWER_STORAGE = 'crewcode:changesDrawerOpenBySurface:v1' function readLastActiveWorkspaceId(): string { try { return localStorage.getItem(ACTIVE_WORKSPACE_STORAGE) ?? '' } catch { return '' } @@ -300,13 +302,10 @@ export default function App() { const activeWorkspace = ws.workspaces.find(w => w.id === activeWs) ?? EMPTY_WS - // ── Worktree selection per workspace ───────────────────────────────────── - const [activeWorktreeIds, setActiveWorktreeIds] = useLocalStorageJsonState>(ACTIVE_WORKTREE_STORAGE, {}) - const activeWorktreeId = activeWorktreeIds[activeWs] ?? null - const activeWorktree = activeWorkspace.worktrees?.find(wt => wt.id === activeWorktreeId) ?? null - const effectivePath = activeWorktree?.path ?? activeWorkspace.path - const effectiveBranch = activeWorktree?.branch ?? activeWorkspace.branch ?? '—' - const effectiveDirty = activeWorktree?.dirty ?? activeWorkspace.dirty ?? 0 + // Worktree choices are keyed by the surface that owns them (chat session or + // non-chat tab), never by workspace. A missing selection means the primary + // checkout, so newly-added workspaces naturally start on their main branch. + const [surfaceWorktreeIds, setSurfaceWorktreeIds] = useLocalStorageJsonState>(SURFACE_WORKTREE_STORAGE, {}) const [gitOpenByTab, setGitOpenByTab] = useLocalStorageJsonState>(GIT_OPEN_STORAGE, {}) const setGitOpenForTab = useCallback((tabId: string, next: boolean | ((prev: boolean) => boolean)) => { @@ -326,11 +325,6 @@ export default function App() { return { ...prev, [tabId]: value } }) }, [setGitWidthByTab]) - const selectWorktreeForActiveWs = useCallback( - (id: string | null) => setActiveWorktreeIds(prev => ({ ...prev, [activeWs]: id })), - [activeWs], - ) - // ── Tabs per workspace ─────────────────────────────────────────────────── const { tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, getActiveTabIdForWorkspace, selectWorkspace, openTab: handleNewTab, openTabInWorkspace, openPluginTab, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, @@ -506,6 +500,34 @@ export default function App() { const sessions = chatSessions.getSessions(activeTabId) const sessActive = chatSessions.getActiveId(activeTabId) const activeSession = chatSessions.getActiveSession(activeTabId) + const worktreeSurfaceId = worktreeSelectionKey(activeTabId, activeTab?.kind, sessActive) + const requestedWorktreeId = worktreeSurfaceId ? surfaceWorktreeIds[worktreeSurfaceId] : null + const activeWorktree = resolveSelectedWorktree(requestedWorktreeId, activeWorkspace.worktrees ?? []) + const activeWorktreeId = activeWorktree?.id ?? null + const effectivePath = activeWorktree?.path ?? activeWorkspace.path + const effectiveBranch = activeWorktree?.branch ?? activeWorkspace.branch ?? '—' + const effectiveDirty = activeWorktree?.dirty ?? activeWorkspace.dirty ?? 0 + const selectWorktreeForActiveSurface = useCallback((id: string | null) => { + if (!worktreeSurfaceId) return + setSurfaceWorktreeIds(prev => ({ ...prev, [worktreeSurfaceId]: id })) + }, [setSurfaceWorktreeIds, worktreeSurfaceId]) + const worktreeForChatSurface = useCallback((surfaceTabId: string) => { + const sessionId = chatSessions.getActiveId(surfaceTabId) + const key = worktreeSelectionKey(surfaceTabId, 'chat', sessionId) + const selected = resolveSelectedWorktree(surfaceWorktreeIds[key], activeWorkspace.worktrees ?? []) + return { + key, + id: selected?.id ?? null, + path: selected?.path ?? activeWorkspace.path, + branch: selected?.branch ?? activeWorkspace.branch ?? '—', + worktreeBranch: selected?.branch ?? null, + dirty: selected?.dirty ?? activeWorkspace.dirty ?? 0, + } + }, [activeWorkspace.branch, activeWorkspace.dirty, activeWorkspace.path, activeWorkspace.worktrees, chatSessions, surfaceWorktreeIds]) + const selectWorktreeForKey = useCallback((key: string, id: string | null) => { + if (!key) return + setSurfaceWorktreeIds(prev => prev[key] === id ? prev : { ...prev, [key]: id }) + }, [setSurfaceWorktreeIds]) useEffect(() => { if (!activeWs || !activeTabId) return const visit = { @@ -633,12 +655,10 @@ export default function App() { const codeEditors = useCodeEditorSessions() const [pendingGitDiff, setPendingGitDiff] = useState<{ title: string; diff: string } | null>(null) const [editorInitialFile, setEditorInitialFile] = useState(null) - const [changesDrawerOpenByWorkspace, setChangesDrawerOpenByWorkspace] = useLocalStorageJsonState>(CHANGES_DRAWER_STORAGE, {}) - const changesDrawerOpen = activeWs ? (changesDrawerOpenByWorkspace[activeWs] ?? false) : false - const setChangesDrawerOpen = useCallback((open: boolean) => { - if (!activeWs) return - setChangesDrawerOpenByWorkspace(prev => ({ ...prev, [activeWs]: open })) - }, [activeWs]) + const [changesDrawerOpenBySurface, setChangesDrawerOpenBySurface] = useLocalStorageJsonState(CHANGES_DRAWER_STORAGE, {}) + const setChangesDrawerOpenForSurface = useCallback((surfaceId: string, open: boolean) => { + setChangesDrawerOpenBySurface(prev => setSurfaceOpen(prev, surfaceId, open)) + }, [setChangesDrawerOpenBySurface]) const [github, setGithub] = useState(null) useEffect(() => { @@ -681,24 +701,27 @@ export default function App() { }, []) // Git sidebar — conflict resolution drops a prompt into the composer. + const handleGitAskAgent = useCallback((text: string, targetTabId?: string) => { + // Route the conflict prompt: a fresh chat tab, a chosen existing one (focus + // it), or — when no target is given (crew path) — the currently active tab. + let destId: string | undefined + if (targetTabId === NEW_CHAT_TARGET) destId = handleNewTab('chat') + else if (targetTabId) { setActiveTabId(targetTabId); destId = targetTabId } + else destId = activeTabIdRef.current + if (!destId) return + composerDraftActions().set(destId, text) + }, [handleNewTab, setActiveTabId]) const git = useGitSidebar({ repoPath: effectivePath, workspacePath: activeWorkspace.path, mainBranch: activeWorkspace.branch ?? 'main', currentWorktreeId: activeWorktreeId, - enabled: (activeTabGitOpen || activeTab?.kind === 'git') && !!activeWs, - onSwitchWorktree: selectWorktreeForActiveWs, - onAskAgent: (text, targetTabId) => { - // Route the conflict prompt: a fresh chat tab, a chosen existing one (focus - // it), or — when no target is given (crew path) — the currently active tab. - let destId: string | undefined - if (targetTabId === NEW_CHAT_TARGET) destId = handleNewTab('chat') - else if (targetTabId) { setActiveTabId(targetTabId); destId = targetTabId } - else destId = activeTabIdRef.current - if (!destId) return - composerDraftActions().set(destId, text) - }, - onWorktreesChanged: () => { if (activeWs) ws.refreshWorktrees(activeWs) }, + // Chat, writer, and Workbench panes own path-scoped Git controllers below. + // Keep this shared controller dormant for those surfaces to avoid duplicate polling. + enabled: (activeTab?.kind === 'git' || (activeTabGitOpen && !['chat', 'crew', 'writer', 'canvas'].includes(activeTab?.kind ?? ''))) && !!activeWs, + onSwitchWorktree: selectWorktreeForActiveSurface, + onAskAgent: handleGitAskAgent, + onWorktreesChanged: () => activeWs ? ws.refreshWorktrees(activeWs) : undefined, onRequestGitAuth: requestGitAuth, onRequestSigningPassphrase: requestSigningPassphrase, alwaysCommitUnsigned: settings.alwaysCommitUnsigned, @@ -2256,8 +2279,10 @@ export default function App() { const setTabGitOpen = (open: boolean) => setGitOpenForTab(tabId, open) const setTabGitWidth = (next: number | ((prev: number) => number)) => setGitWidthForTab(tabId, next) - // Tab-specific session lookups + // Tab-specific session and worktree lookups. Split/workbench surfaces may + // render alongside the active outer tab, so they must never inherit its cwd. const tabActiveSession = chatSessions.getActiveSession(tabId) + const tabWorktree = worktreeForChatSurface(tabId) // Tab-specific chat state const tabAgentId = tabActiveSession?.agentId ?? settings.defaultAgent ?? 'pi' @@ -2332,7 +2357,9 @@ export default function App() { workspaceName={activeWorkspace.name} openChatCount={canvasPanes.filter(pane => pane.kind === 'chat').length} openTerminalCount={canvasPanes.filter(pane => pane.kind === 'terminal').length} - panes={canvasPanes.map(pane => ({ + panes={canvasPanes.map(pane => { + const paneWorktree = worktreeForChatSurface(pane.id) + return { id: pane.id, kind: pane.kind, title: pane.title, @@ -2343,9 +2370,9 @@ export default function App() { tabId={pane.id} activeWs={activeWs} workspace={activeWorkspace} - effectivePath={effectivePath} - effectiveBranch={effectiveBranch} - worktreeBranch={activeWorktree?.branch} + effectivePath={paneWorktree.path} + effectiveBranch={paneWorktree.branch} + worktreeBranch={paneWorktree.worktreeBranch} agents={agents} chatSessions={chatSessions} bridges={bridges} @@ -2385,8 +2412,13 @@ export default function App() { gitOpen={gitOpenByTab[pane.id] ?? false} setGitOpen={(open) => setGitOpenForTab(pane.id, open)} github={github} - dirtyCount={effectiveDirty} - git={git} + dirtyCount={paneWorktree.dirty} + currentWorktreeId={paneWorktree.id} + onSwitchWorktree={(id) => selectWorktreeForKey(paneWorktree.key, id)} + onGitAskAgent={handleGitAskAgent} + onRequestGitAuth={requestGitAuth} + onRequestSigningPassphrase={requestSigningPassphrase} + alwaysCommitUnsigned={settings.alwaysCommitUnsigned} gitWidth={gitWidthByTab[pane.id] ?? 380} setGitWidth={(w) => setGitWidthForTab(pane.id, w)} onOpenGitFileDiff={openGitFileDiff} @@ -2407,8 +2439,8 @@ export default function App() { onPluginTerminalWatcher={(target, paneId) => runPluginActionTarget(target, { source: 'terminal-watcher', terminalPaneId: paneId })} setPendingGitDiff={setPendingGitDiff} onWorktreesChanged={() => ws.refreshWorktrees(activeWs)} - changesDrawerOpen={changesDrawerOpen} - setChangesDrawerOpen={setChangesDrawerOpen} + changesDrawerOpen={isSurfaceOpen(changesDrawerOpenBySurface, pane.id)} + setChangesDrawerOpen={(open) => setChangesDrawerOpenForSurface(pane.id, open)} /> ) : (
@@ -2431,7 +2463,8 @@ export default function App() { />
), - }))} + } + })} onNewChat={() => addCanvasPane(tabId, 'chat')} onNewTerminal={() => addCanvasPane(tabId, 'terminal')} onClosePane={(paneId) => closeCanvasPane(tabId, paneId)} @@ -2445,9 +2478,9 @@ export default function App() { tabId={tabId} activeWs={activeWs} workspace={activeWorkspace} - effectivePath={effectivePath} - effectiveBranch={effectiveBranch} - worktreeBranch={activeWorktree?.branch} + effectivePath={tabWorktree.path} + effectiveBranch={tabWorktree.branch} + worktreeBranch={tabWorktree.worktreeBranch} agents={agents} chatSessions={chatSessions} bridges={bridges} @@ -2486,8 +2519,13 @@ export default function App() { gitOpen={tabGitOpen} setGitOpen={setTabGitOpen} github={github} - dirtyCount={effectiveDirty} - git={git} + dirtyCount={tabWorktree.dirty} + currentWorktreeId={tabWorktree.id} + onSwitchWorktree={(id) => selectWorktreeForKey(tabWorktree.key, id)} + onGitAskAgent={handleGitAskAgent} + onRequestGitAuth={requestGitAuth} + onRequestSigningPassphrase={requestSigningPassphrase} + alwaysCommitUnsigned={settings.alwaysCommitUnsigned} gitWidth={tabGitWidth} setGitWidth={setTabGitWidth} onOpenGitFileDiff={openGitFileDiff} @@ -2508,8 +2546,8 @@ export default function App() { onPluginTerminalWatcher={(target, paneId) => runPluginActionTarget(target, { source: 'terminal-watcher', terminalPaneId: paneId })} setPendingGitDiff={setPendingGitDiff} onWorktreesChanged={() => ws.refreshWorktrees(activeWs)} - changesDrawerOpen={changesDrawerOpen} - setChangesDrawerOpen={setChangesDrawerOpen} + changesDrawerOpen={isSurfaceOpen(changesDrawerOpenBySurface, tabId)} + setChangesDrawerOpen={(open) => setChangesDrawerOpenForSurface(tabId, open)} /> ) } @@ -2633,9 +2671,9 @@ export default function App() { tabId={tabId} activeWs={activeWs} workspace={activeWorkspace} - effectivePath={effectivePath} - effectiveBranch={effectiveBranch} - worktreeBranch={activeWorktree?.branch} + effectivePath={tabWorktree.path} + effectiveBranch={tabWorktree.branch} + worktreeBranch={tabWorktree.worktreeBranch} agents={agents} chatSessions={chatSessions} bridges={bridges} @@ -2670,8 +2708,13 @@ export default function App() { gitOpen={tabGitOpen} setGitOpen={setTabGitOpen} github={github} - dirtyCount={effectiveDirty} - git={git} + dirtyCount={tabWorktree.dirty} + currentWorktreeId={tabWorktree.id} + onSwitchWorktree={(id) => selectWorktreeForKey(tabWorktree.key, id)} + onGitAskAgent={handleGitAskAgent} + onRequestGitAuth={requestGitAuth} + onRequestSigningPassphrase={requestSigningPassphrase} + alwaysCommitUnsigned={settings.alwaysCommitUnsigned} gitWidth={tabGitWidth} setGitWidth={setTabGitWidth} onOpenGitFileDiff={openGitFileDiff} @@ -2685,8 +2728,8 @@ export default function App() { termLayout={pty.getTabLayout(tabId)} onTermLayoutChange={(layout) => pty.setTabLayout(tabId, layout)} setPendingGitDiff={setPendingGitDiff} - changesDrawerOpen={changesDrawerOpen} - setChangesDrawerOpen={setChangesDrawerOpen} + changesDrawerOpen={isSurfaceOpen(changesDrawerOpenBySurface, tabId)} + setChangesDrawerOpen={(open) => setChangesDrawerOpenForSurface(tabId, open)} /> ) } diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index 14bd83f..a933213 100644 --- a/src/renderer/src/components/chat/ChatPane.tsx +++ b/src/renderer/src/components/chat/ChatPane.tsx @@ -9,7 +9,7 @@ import { ExternalDirectoriesModal } from './ExternalDirectoriesModal' import { CrewBranch } from './CrewBranch' import { GitSidebar } from '../git/GitSidebar' import { TurnChangesDrawer } from '../thread/TurnChangesDrawer' -import type { useGitSidebar } from '../../hooks/useGitSidebar' +import { useGitSidebar, type GitAuthCredentials, type GitAuthRequest, type GitSigningRequest } from '../../hooks/useGitSidebar' import { MODE_FROM_SETTINGS, MODE_TO_LEVEL, normalizeModeLevel } from '../../app-constants' import { titleFromFirstMessage } from '../../hooks/useChatSessions' import { useComposerSend } from '../../hooks/useComposerSend' @@ -86,7 +86,12 @@ interface ChatPaneProps { setGitOpen: (open: boolean) => void github?: GitHubStatus | null dirtyCount?: number - git: ReturnType + currentWorktreeId: string | null + onSwitchWorktree: (id: string | null) => void + onGitAskAgent?: (text: string, targetTabId?: string) => void + onRequestGitAuth?: (request: GitAuthRequest) => Promise + onRequestSigningPassphrase?: (request: GitSigningRequest) => Promise + alwaysCommitUnsigned?: boolean gitWidth: number setGitWidth: React.Dispatch> onOpenGitFileDiff: (path: string, staged: boolean) => void @@ -159,7 +164,12 @@ export function ChatPane({ setGitOpen, github, dirtyCount = 0, - git, + currentWorktreeId, + onSwitchWorktree, + onGitAskAgent, + onRequestGitAuth, + onRequestSigningPassphrase, + alwaysCommitUnsigned, gitWidth, setGitWidth, onOpenGitFileDiff, @@ -191,6 +201,21 @@ export function ChatPane({ const termWidth = externalTermWidth ?? fallbackTermWidth const setTermWidth = externalSetTermWidth ?? setFallbackTermWidth const threadRef = useRef(null) + // Each mounted chat owns a path-scoped Git controller. It stays dormant while + // its sidebar is closed, avoiding polling/fetch work for hidden Workbench panes. + const git = useGitSidebar({ + repoPath: effectivePath, + workspacePath: workspace.path, + mainBranch: workspace.branch ?? 'main', + currentWorktreeId, + enabled: gitOpen, + onSwitchWorktree, + onAskAgent: onGitAskAgent, + onWorktreesChanged, + onRequestGitAuth, + onRequestSigningPassphrase, + alwaysCommitUnsigned, + }) const appliedSkills = useAppliedSkillsBySession() const appliedModes = useAppliedModesBySession() @@ -560,7 +585,9 @@ export function ChatPane({ }) }, [bridges, sessActive, activeAgentId, setMessages]) - const currentGitBranch = git.state.branch || effectiveBranch + // The selected worktree is authoritative. A sibling pane's Git refresh must + // never override this pane's composer branch label. + const currentGitBranch = worktreeBranch ?? effectiveBranch const openBranchInWorktree = useCallback(async (ref: string, opts?: { createFrom?: string }) => { const branch = ref.replace(/^origin\//, '') diff --git a/src/renderer/src/components/writer/WriterWorkspace.tsx b/src/renderer/src/components/writer/WriterWorkspace.tsx index d861be3..beabfa2 100644 --- a/src/renderer/src/components/writer/WriterWorkspace.tsx +++ b/src/renderer/src/components/writer/WriterWorkspace.tsx @@ -40,7 +40,7 @@ import { PierreDiff } from '../diff/PierreDiff' import { Icon } from '../ui/Icon' import type { AgentInfo, GitHubStatus, Message, Workspace } from '../../types' import type { CustomCommand, Prompt, Skill } from '../../types/prompts' -import type { useGitSidebar } from '../../hooks/useGitSidebar' +import type { GitAuthCredentials, GitAuthRequest, GitSigningRequest } from '../../hooks/useGitSidebar' import type { Layout } from '../../hooks/useTerminalSessions' const WRITER_PROMPT_STORAGE = 'crewcode:writerWorkspace:systemPrompt:v1' @@ -197,7 +197,12 @@ interface WriterWorkspaceProps { setGitOpen: (open: boolean) => void github?: GitHubStatus | null dirtyCount?: number - git: ReturnType + currentWorktreeId: string | null + onSwitchWorktree: (id: string | null) => void + onGitAskAgent?: (text: string, targetTabId?: string) => void + onRequestGitAuth?: (request: GitAuthRequest) => Promise + onRequestSigningPassphrase?: (request: GitSigningRequest) => Promise + alwaysCommitUnsigned?: boolean gitWidth: number setGitWidth: React.Dispatch> onOpenGitFileDiff: (path: string, staged: boolean) => void diff --git a/src/renderer/src/hooks/useAgentBridge.ts b/src/renderer/src/hooks/useAgentBridge.ts index e937e08..20cd699 100644 --- a/src/renderer/src/hooks/useAgentBridge.ts +++ b/src/renderer/src/hooks/useAgentBridge.ts @@ -405,17 +405,41 @@ export function useAgentBridge({ setMessagesForTab, bridgeToTab, bridgeToCwd, br case 'history_agent': { closeThinkingSegment(ev.turnId) closeAgentSegment(ev.turnId) - setMessagesForTab(tabId, m => [...m, { - kind: 'agent', - time: nowTime(), - blocks: [], - text: ev.text, - chunks: appendStreamChunk(undefined, ev.text), - turnId: ev.turnId, - processId: `${ev.turnId}-agent-history`, - streaming: false, - mode: bridgeToModeRef.current[ev.bridgeId], - }]) + setMessagesForTab(tabId, m => { + // A Brain claim sends a semantic replacement snapshot because final + // deltas can race the old browser socket closing. Collapse every + // persisted/detached fragment for this turn into one authoritative + // bubble, then let later live deltas append to that same bubble. + const existingIndex = m.findIndex(message => message.kind === 'agent' && message.turnId === ev.turnId) + if (existingIndex !== -1) { + const existing = m[existingIndex]! + if (existing.kind !== 'agent') return m + const next = m.filter((message, index) => index === existingIndex + || message.kind !== 'agent' + || message.turnId !== ev.turnId) + const replacementIndex = next.indexOf(existing) + next[replacementIndex] = { + ...existing, + text: ev.text, + chunks: appendStreamChunk(undefined, ev.text), + streaming: false, + } + st.agentBubbleByTurn[ev.turnId] = replacementIndex + return next + } + st.agentBubbleByTurn[ev.turnId] = m.length + return [...m, { + kind: 'agent', + time: nowTime(), + blocks: [], + text: ev.text, + chunks: appendStreamChunk(undefined, ev.text), + turnId: ev.turnId, + processId: `${ev.turnId}-agent-history`, + streaming: false, + mode: bridgeToModeRef.current[ev.bridgeId], + }] + }) return } @@ -795,6 +819,13 @@ export function useAgentBridge({ setMessagesForTab, bridgeToTab, bridgeToCwd, br } }, [setMessagesForTab, show]) + /** Install event routing synchronously before a remote bridge can emit. */ + const registerRoute = useCallback((bridgeId: string, tabId: string, cwd: string, mode?: ModeLevel) => { + bridgeToTabRef.current = { ...bridgeToTabRef.current, [bridgeId]: tabId } + bridgeToCwdRef.current = { ...bridgeToCwdRef.current, [bridgeId]: cwd } + if (mode) bridgeToModeRef.current = { ...bridgeToModeRef.current, [bridgeId]: mode } + }, []) + const start = useCallback(async ( bridgeId: string, provider: AgentProviderId, @@ -812,7 +843,11 @@ export function useAgentBridge({ setMessagesForTab, bridgeToTab, bridgeToCwd, br stoppedBridgesRef.current.delete(bridgeId) const api = window.electronAPI if (!api) return { ok: false, error: 'electronAPI unavailable' } - return api.bridgeStart({ bridgeId, provider, cwd, externalDirectories, model, mode, toolPolicy, thinking, sessionKey, conversationScopeKey, freshSession, mcpServers }) + try { + return await api.bridgeStart({ bridgeId, provider, cwd, externalDirectories, model, mode, toolPolicy, thinking, sessionKey, conversationScopeKey, freshSession, mcpServers }) + } catch (error) { + return { ok: false, error: (error as Error).message } + } }, []) const prompt = useCallback(async (bridgeId: string, text: string, options?: ChatPromptOptions) => { @@ -821,20 +856,17 @@ export function useAgentBridge({ setMessagesForTab, bridgeToTab, bridgeToCwd, br // provider's final text stream; some bridges emit turn_start after setup. st.pendingPromptStartedAt = Date.now() onRunningChange?.(bridgeId, true) - let queuedFollowUp = false try { const result = await (window.electronAPI?.bridgePrompt(bridgeId, text, options) ?? { ok: false, error: 'electronAPI unavailable' }) - // An accepted follow-up resolves immediately while the current turn is - // still streaming (the bridge queues it). Clearing running here would - // flip the composer idle mid-turn and wipe pending permission overlays, - // so the next send goes out unqueued and the bridge rejects it. - queuedFollowUp = options?.streamingBehavior === 'followUp' && result.ok === true + // Browser RPC and some provider bridges acknowledge an accepted prompt + // before the turn finishes. Keep Stop available until an authoritative + // turn_end/error/closed event arrives; an RPC acknowledgement is not proof + // that execution settled. Rejections have no terminal turn to clear them. + if (!result.ok) onRunningChange?.(bridgeId, false) return result - } finally { - // The prompt IPC resolves after the provider's turn promise settles. Clear - // the UI's Stop state even if a terminal bridge event was dropped/raced. - // Queued follow-ups are the exception: turn_end owns the flag for them. - if (!queuedFollowUp) onRunningChange?.(bridgeId, false) + } catch (error) { + onRunningChange?.(bridgeId, false) + return { ok: false, error: (error as Error).message } } }, [onRunningChange]) @@ -872,5 +904,5 @@ export function useAgentBridge({ setMessagesForTab, bridgeToTab, bridgeToCwd, br delete stateRef.current[bridgeId] }, [onRunningChange]) - return { start, prompt, compact, setMode, abort, stop, removeFollowUp } + return { start, prompt, compact, setMode, abort, stop, removeFollowUp, registerRoute } } diff --git a/src/renderer/src/hooks/useBridgeRegistry.test.ts b/src/renderer/src/hooks/useBridgeRegistry.test.ts index bae05c6..f4929ce 100644 --- a/src/renderer/src/hooks/useBridgeRegistry.test.ts +++ b/src/renderer/src/hooks/useBridgeRegistry.test.ts @@ -3,7 +3,7 @@ import TestRenderer, { act } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NotificationsProvider } from './useNotifications' -import { useBridgeRegistry } from './useBridgeRegistry' +import { bridgeRuntimeId, useBridgeRegistry } from './useBridgeRegistry' import { bridgeActivity, useBridgeActivityStore } from '../stores/bridge-activity-store' // The activity store is a module singleton — reset it so state can't leak. @@ -15,11 +15,11 @@ function deferred() { return { promise, resolve } } -function renderRegistry() { +function renderRegistry(setMessagesForTab = vi.fn()) { const result = { current: undefined as unknown as ReturnType } function Probe(): null { - result.current = useBridgeRegistry({ setMessagesForTab: vi.fn() }) + result.current = useBridgeRegistry({ setMessagesForTab }) return null } @@ -36,12 +36,68 @@ function renderRegistry() { } } +describe('bridge runtime identity', () => { + it('keeps remote ids stable across browser runtimes without weakening desktop uniqueness', () => { + expect(bridgeRuntimeId('thread', 'claude', true, 1)).toBe(bridgeRuntimeId('thread', 'claude', true, 2)) + expect(bridgeRuntimeId('thread', 'claude', false, 1)).not.toBe(bridgeRuntimeId('thread', 'claude', false, 2)) + }) +}) + describe('useBridgeRegistry navigation keepalive', () => { afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) + it('routes provider events emitted before React commits bridge map state', async () => { + let emitEvent!: (ev: unknown) => void + const setMessagesForTab = vi.fn((_tabId: string, updater: (messages: unknown[]) => unknown[]) => updater([])) + vi.stubGlobal('window', { + electronAPI: { + onBridgeEvent: vi.fn((cb: (ev: unknown) => void) => { emitEvent = cb; return vi.fn() }), + bridgeStart: vi.fn(async (opts: { bridgeId: string }) => { + emitEvent({ type: 'turn_start', bridgeId: opts.bridgeId, turnId: 'fast-turn' }) + emitEvent({ type: 'text_delta', bridgeId: opts.bridgeId, turnId: 'fast-turn', delta: 'fast reply' }) + emitEvent({ type: 'turn_end', bridgeId: opts.bridgeId, turnId: 'fast-turn' }) + return { ok: true } + }), + bridgeStop: vi.fn(), + bridgeSetMode: vi.fn(), + }, + }) + + const hook = renderRegistry(setMessagesForTab) + await act(async () => { + await hook.result.current.ensureBridge('sess-fast', 'codex', 'codex', '/repo', undefined, 'medium', 'build') + }) + + expect(setMessagesForTab).toHaveBeenCalled() + expect(setMessagesForTab.mock.calls.every(call => call[0] === 'sess-fast')).toBe(true) + hook.unmount() + }) + + it('deduplicates the same recovered Brain history event', async () => { + let emitEvent!: (ev: unknown) => void + let messages: unknown[] = [] + const setMessagesForTab = vi.fn((_tabId: string, updater: (current: unknown[]) => unknown[]) => { messages = updater(messages) }) + vi.stubGlobal('window', { + electronAPI: { + onBridgeEvent: vi.fn((cb: (ev: unknown) => void) => { emitEvent = cb; return vi.fn() }), + bridgeStart: vi.fn(async () => ({ ok: true })), + bridgeStop: vi.fn(), + bridgeSetMode: vi.fn(), + }, + }) + const hook = renderRegistry(setMessagesForTab) + await act(async () => { await hook.result.current.ensureBridge('sess-history', 'codex', 'codex', '/repo') }) + const bridgeId = hook.result.current.getBridgeId('sess-history', 'codex')! + const event = { type: 'history_agent', bridgeId, turnId: 'recovered-1', text: 'finished remotely' } + + act(() => { emitEvent(event); emitEvent(event) }) + expect(messages).toHaveLength(1) + hook.unmount() + }) + it('does not stop a bridge that is still starting unless explicitly forced', async () => { const start = deferred<{ ok: boolean }>() const bridgeStop = vi.fn() @@ -75,17 +131,72 @@ describe('useBridgeRegistry navigation keepalive', () => { hook.unmount() }) + it('returns rejected browser prompt RPCs as semantic failures', async () => { + vi.stubGlobal('window', { + electronAPI: { + onBridgeEvent: vi.fn(() => vi.fn()), + bridgeStart: vi.fn(async () => ({ ok: true })), + bridgePrompt: vi.fn(async () => { throw new Error('Brain session does not own this terminal or agent resource') }), + bridgeStop: vi.fn(), + bridgeSetMode: vi.fn(), + }, + }) + + const hook = renderRegistry() + await act(async () => { + await hook.result.current.ensureBridge('sess-1', 'codex', 'codex', '/repo') + }) + const bridgeId = hook.result.current.getBridgeId('sess-1', 'codex')! + await expect(hook.result.current.prompt(bridgeId, 'hello')).resolves.toEqual({ + ok: false, + error: 'Brain session does not own this terminal or agent resource', + }) + hook.unmount() + }) + + it('keeps an accepted browser prompt stoppable until a terminal bridge event arrives', async () => { + let emitEvent!: (ev: unknown) => void + vi.stubGlobal('window', { + electronAPI: { + onBridgeEvent: vi.fn((cb: (ev: unknown) => void) => { emitEvent = cb; return vi.fn() }), + bridgeStart: vi.fn(async () => ({ ok: true })), + // Browser RPC acknowledges prompt acceptance before provider execution + // has necessarily finished. + bridgePrompt: vi.fn(async () => ({ ok: true })), + bridgeStop: vi.fn(), + bridgeSetMode: vi.fn(), + }, + }) + + const hook = renderRegistry() + await act(async () => { + await hook.result.current.ensureBridge('sess-1', 'claude', 'claude', '/repo', undefined, 'medium', 'build') + }) + const bridgeId = hook.result.current.getBridgeId('sess-1', 'claude')! + + await act(async () => { + await hook.result.current.prompt(bridgeId, 'keep working') + }) + expect(hook.result.current.isBridgeRunning('sess-1', 'claude')).toBe(true) + + act(() => emitEvent({ type: 'turn_end', bridgeId, turnId: 'turn-1' })) + expect(hook.result.current.isBridgeRunning('sess-1', 'claude')).toBe(false) + + hook.unmount() + }) + it('keeps the bridge marked running after a queued follow-up resolves early', async () => { // A follow-up sent mid-turn resolves immediately ({ok:true} from the // provider queue) while the first prompt's IPC is still pending. The // running flag must not be cleared by the follow-up's resolution, or the // composer flips idle mid-turn and the next send goes out unqueued. const firstTurn = deferred<{ ok: boolean }>() + let emitEvent!: (ev: unknown) => void const bridgePrompt = vi.fn((_id: string, _text: string, options?: { streamingBehavior?: string }) => options?.streamingBehavior === 'followUp' ? Promise.resolve({ ok: true }) : firstTurn.promise) vi.stubGlobal('window', { electronAPI: { - onBridgeEvent: vi.fn(() => vi.fn()), + onBridgeEvent: vi.fn((cb: (ev: unknown) => void) => { emitEvent = cb; return vi.fn() }), bridgeStart: vi.fn(async () => ({ ok: true })), bridgeStop: vi.fn(), bridgeSetMode: vi.fn(), @@ -115,6 +226,10 @@ describe('useBridgeRegistry navigation keepalive', () => { firstTurn.resolve({ ok: true }) await firstP }) + // Prompt promise settlement is only an acknowledgement; the terminal event + // remains authoritative for whether the request can still be stopped. + expect(hook.result.current.isBridgeRunning('sess-1', 'claude')).toBe(true) + act(() => emitEvent({ type: 'turn_end', bridgeId, turnId: 'turn-1' })) expect(hook.result.current.isBridgeRunning('sess-1', 'claude')).toBe(false) hook.unmount() diff --git a/src/renderer/src/hooks/useBridgeRegistry.ts b/src/renderer/src/hooks/useBridgeRegistry.ts index 5b74730..e16cb4f 100644 --- a/src/renderer/src/hooks/useBridgeRegistry.ts +++ b/src/renderer/src/hooks/useBridgeRegistry.ts @@ -10,23 +10,41 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useAgentBridge } from './useAgentBridge' import { bridgeActivity, useRunningByBridge } from '../stores/bridge-activity-store' +import { getCrewCodeRuntime } from '../runtime/crewcode-client' +import { claimedWebBridgeRoutes, forgetWebBridgeRoute, rememberWebBridgeRoutes, webBridgeRoutes } from '../runtime/web-bridge-routes' import type { AgentProviderId, AgentUserResponse, BridgeEvent, ChatPromptOptions, Message, ModeLevel } from '../types' import type { EffortLevel } from '../components/composer/EffortPicker' import type { McpServerConfig } from './useSettings' type BridgeToolPolicy = 'default' | 'read-only' +function isWebRuntime(): boolean { + try { return getCrewCodeRuntime().kind === 'web' } catch { return false } +} + +export function bridgeRuntimeId(tabId: string, agentId: string, web: boolean, at = Date.now()): string { + return web ? `br-${tabId}-${agentId}-remote` : `br-${tabId}-${agentId}-${at.toString(36)}` +} + interface UseBridgeRegistryOpts { setMessagesForTab: (tabId: string, updater: (prev: Message[]) => Message[]) => void } export function useBridgeRegistry({ setMessagesForTab }: UseBridgeRegistryOpts) { + // Runtime kind is immutable for the life of this mounted application. Capture + // it once so page teardown cannot accidentally fall through to desktop stop + // behavior if another parent cleanup has already dismantled web runtime state. + const webRuntime = useRef(isWebRuntime()).current // "tabId:agentId" → bridgeId, and the reverse bridgeId → tabId for routing. - const [bridgesByKey, setBridgesByKey] = useState>({}) - const [bridgeToTab, setBridgeToTab] = useState>({}) + const recoveredRoutes = useRef(webRuntime ? webBridgeRoutes() : []).current + const claimedRoutes = useRef(webRuntime ? claimedWebBridgeRoutes() : []).current + const [bridgesByKey, setBridgesByKey] = useState>(() => Object.fromEntries( + claimedRoutes.filter(route => route.provider).map(route => [`${route.tabId}:${route.provider}`, route.bridgeId]), + )) + const [bridgeToTab, setBridgeToTab] = useState>(() => Object.fromEntries(recoveredRoutes.map(route => [route.bridgeId, route.tabId]))) // bridgeId → cwd, so the bridge event hook can read pre/post file snapshots // for the per-turn change tracker. - const [bridgeToCwd, setBridgeToCwd] = useState>({}) + const [bridgeToCwd, setBridgeToCwd] = useState>(() => Object.fromEntries(recoveredRoutes.filter(route => route.cwd).map(route => [route.bridgeId, route.cwd!]))) // bridgeId → mode that was in effect when the bridge was spawned. Used to // tag streamed agent bubbles so Plan-mode replies get the format toggle. const [bridgeToMode, setBridgeToMode] = useState>({}) @@ -127,8 +145,12 @@ export function useBridgeRegistry({ setMessagesForTab }: UseBridgeRegistryOpts) const liveRef = useRef>({}) liveRef.current = bridgesByKey useEffect(() => () => { + // Closing a remote browser detaches from Brain-owned executions. Explicit + // tab/session removal still calls bridge.stop, but page teardown must not + // turn a temporary network lifecycle into an execution lifecycle. + if (webRuntime) return for (const id of Object.values(liveRef.current)) window.electronAPI?.bridgeStop(id) - }, []) + }, [webRuntime]) /** * Return the bridge for (tabId, agentId), starting one if none exists. @@ -158,6 +180,16 @@ export function useBridgeRegistry({ setMessagesForTab }: UseBridgeRegistryOpts) const existing = bridgesByKey[key] const forceFresh = force || freshSession if (existing && !forceFresh) { + if (webRuntime) { + // Reassert the stable bridge before each explicit web prompt. Brain + // treats this as an idempotent attach while the execution exists; if + // Brain restarted and lost its process-local owner map, it creates the + // replacement bridge instead. The interrupted prompt is never replayed. + bridge.registerRoute(existing, tabId, cwd, mode) + const attached = await bridge.start(existing, provider, cwd, model, mode, effort, toolPolicy, key, tabId, mcpServers, false, externalDirectories) + if (attached.custodyHalt) bridgeActivity.setCustodyHalt(tabId, attached.custodyHalt) + if (attached.error) return { error: attached.error } + } if (mode) { setBridgeToMode(prev => prev[existing] === mode ? prev : { ...prev, [existing]: mode }) bridge.setMode(existing, mode) @@ -166,7 +198,14 @@ export function useBridgeRegistry({ setMessagesForTab }: UseBridgeRegistryOpts) } if (existing && forceFresh) bridge.stop(existing) - const bridgeId = `br-${tabId}-${agentId}-${Date.now().toString(36)}` + // A stable remote id lets a newly authenticated browser runtime explicitly + // reclaim the same Brain-side execution without replaying its start/prompt. + const bridgeId = bridgeRuntimeId(tabId, agentId, webRuntime) + // React may batch the state updates below until after bridge.start has + // already emitted ready/turn/text events. Install routing synchronously so + // a fast remote provider cannot complete into an unmapped event sink. + bridge.registerRoute(bridgeId, tabId, cwd, mode) + if (webRuntime) rememberWebBridgeRoutes([{ bridgeId, tabId, cwd, provider }]) setBridgeToTab(prev => ({ ...prev, [bridgeId]: tabId })) setBridgeToCwd(prev => ({ ...prev, [bridgeId]: cwd })) if (mode) setBridgeToMode(prev => ({ ...prev, [bridgeId]: mode })) @@ -189,10 +228,11 @@ export function useBridgeRegistry({ setMessagesForTab }: UseBridgeRegistryOpts) setBridgeToCwd(prev => { const n = { ...prev }; delete n[bridgeId]; return n }) setBridgeToMode(prev => { const n = { ...prev }; delete n[bridgeId]; return n }) bridgeActivity.clearBridges([bridgeId]) + if (webRuntime) forgetWebBridgeRoute(bridgeId) return { error: r.error } } return { bridgeId } - }, [bridge, bridgesByKey]) + }, [bridge, bridgesByKey, webRuntime]) /** Stop and forget one bridge — used when model/effort change forces a respawn. */ const dropBridge = useCallback((tabId: string, agentId: string) => { diff --git a/src/renderer/src/hooks/useBridgeRegistry.web-teardown.test.ts b/src/renderer/src/hooks/useBridgeRegistry.web-teardown.test.ts new file mode 100644 index 0000000..25c65e3 --- /dev/null +++ b/src/renderer/src/hooks/useBridgeRegistry.web-teardown.test.ts @@ -0,0 +1,95 @@ +import { createElement } from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +import { installCrewCodeRuntime, type CrewCodeClient } from '../runtime/crewcode-client' +import { markClaimedWebBridgeRoutes, rememberWebBridgeRoutes } from '../runtime/web-bridge-routes' +import { useMessagesStore } from '../stores/chat-messages-store' +import { NotificationsProvider } from './useNotifications' +import { useBridgeRegistry } from './useBridgeRegistry' + +describe('web bridge registry execution custody', () => { + it('idempotently reasserts a claimed Brain bridge without stopping it on page lifecycle', async () => { + const bridgeStop = vi.fn() + const bridgeStart = vi.fn(async () => ({ ok: true })) + let emitBridgeEvent!: (event: unknown) => void + const client = { + onBridgeEvent: vi.fn((listener: (event: unknown) => void) => { emitBridgeEvent = listener; return vi.fn() }), + bridgeStart, + bridgeStop, + bridgeSetMode: vi.fn(), + } as unknown as CrewCodeClient + vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() }) + vi.stubGlobal('window', {}) + installCrewCodeRuntime({ kind: 'web', client }) + useMessagesStore.getState().setMessagesByTab({}) + rememberWebBridgeRoutes([{ + bridgeId: 'br-remote-chat-codex-remote', + tabId: 'remote-chat', + cwd: '/workspace', + provider: 'codex', + }]) + markClaimedWebBridgeRoutes(['br-remote-chat-codex-remote']) + + const result = { current: undefined as unknown as ReturnType } + function Probe(): null { + result.current = useBridgeRegistry({ setMessagesForTab: useMessagesStore.getState().setMessagesForTab }) + return null + } + + let renderer!: TestRenderer.ReactTestRenderer + act(() => { + renderer = TestRenderer.create(createElement(NotificationsProvider, null, createElement(Probe))) + }) + await act(async () => { + await result.current.ensureBridge('remote-chat', 'codex', 'codex', '/workspace') + }) + expect(result.current.getBridgeId('remote-chat', 'codex')).toBe('br-remote-chat-codex-remote') + // This attach RPC is idempotent while Brain is alive and recreates only the + // provider bridge (not the interrupted prompt) if Brain restarted. + expect(bridgeStart).toHaveBeenCalledWith(expect.objectContaining({ + bridgeId: 'br-remote-chat-codex-remote', + provider: 'codex', + cwd: '/workspace', + conversationScopeKey: 'remote-chat', + freshSession: false, + })) + useMessagesStore.getState().setMessagesForTab('remote-chat', () => [{ + kind: 'agent', + blocks: [], + text: 'reply completed', + time: '12:00', + turnId: 'recovered-turn', + streaming: true, + }, { + kind: 'agent', + blocks: [], + text: ' while the link was closed', + time: '12:01', + turnId: 'recovered-turn', + streaming: true, + }]) + act(() => emitBridgeEvent({ + type: 'history_agent', + bridgeId: 'br-remote-chat-codex-remote', + turnId: 'recovered-turn', + text: 'reply completed while the link was closed', + })) + act(() => emitBridgeEvent({ + type: 'text_delta', + bridgeId: 'br-remote-chat-codex-remote', + turnId: 'recovered-turn', + delta: ' More arrived after reclaim.', + })) + await act(async () => { await new Promise(resolve => setTimeout(resolve, 60)) }) + expect(useMessagesStore.getState().messagesByTab['remote-chat']).toHaveLength(1) + expect(useMessagesStore.getState().messagesByTab['remote-chat']?.at(-1)).toMatchObject({ + kind: 'agent', + text: 'reply completed while the link was closed More arrived after reclaim.', + streaming: true, + }) + act(() => renderer.unmount()) + + expect(bridgeStop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useComposerSend.test.ts b/src/renderer/src/hooks/useComposerSend.test.ts index 9f760c2..dbd48b5 100644 --- a/src/renderer/src/hooks/useComposerSend.test.ts +++ b/src/renderer/src/hooks/useComposerSend.test.ts @@ -74,6 +74,18 @@ describe('useComposerSend session continuity', () => { h.unmount() }) + it('respawns the same session when its selected worktree path changes', () => { + const opts = makeOpts() + const h = renderHook(useComposerSend, opts) + + h.rerender(makeOpts({ bridges: opts.bridges, effectivePath: '/repo-worktrees/feature' })) + + expect(opts.bridges.dropBridge).toHaveBeenCalledOnce() + expect(opts.bridges.dropBridge).toHaveBeenCalledWith('sess-1', 'pi') + + h.unmount() + }) + it('respawns on launch-flag changes but keeps mode changes in the same live session', () => { const opts = makeOpts() const h = renderHook(useComposerSend, opts) diff --git a/src/renderer/src/hooks/useComposerSend.ts b/src/renderer/src/hooks/useComposerSend.ts index bff9c6c..7caf3cc 100644 --- a/src/renderer/src/hooks/useComposerSend.ts +++ b/src/renderer/src/hooks/useComposerSend.ts @@ -29,8 +29,9 @@ interface BridgesLike { } interface PtyLike { - addAgent: (wsId: string, tabId: string, agentId: string, name: string, cwd: string, shell?: string | null) => { paneId: string; live?: boolean } + addAgent: (wsId: string, tabId: string, agentId: string, name: string, cwd: string, shell?: string | null) => { paneId: string; live?: boolean; cwd?: string } write: (paneId: string, text: string) => void + close?: (paneId: string) => void } export interface UseComposerSendOpts { @@ -48,7 +49,7 @@ export interface UseComposerSendOpts { effectivePath: string bridges: BridgesLike pty: PtyLike - activeAgentPane: { paneId: string; live?: boolean } | null + activeAgentPane: { paneId: string; live?: boolean; cwd?: string } | null /** Skills currently enabled in the library (any kind, all agents). */ enabledSkills: Skill[] @@ -132,6 +133,20 @@ export function useComposerSend(opts: UseComposerSendOpts) { // Mode is per-turn behavior. Do not respawn the bridge here: the next send // updates the live bridge's mode and injects a mode-change instruction. + // A session's selected worktree is part of its runtime identity. Reusing a + // bridge launched in a sibling/previous cwd would make the UI say one branch + // while tools still edit another checkout. + const prevPathRef = useRef({ sessActive, activeAgentId, effectivePath }) + useEffect(() => { + const prev = prevPathRef.current + const sameRuntime = prev.sessActive === sessActive && prev.activeAgentId === activeAgentId + prevPathRef.current = { sessActive, activeAgentId, effectivePath } + if (sessActive && sameRuntime && prev.effectivePath !== effectivePath) { + bridges.dropBridge(sessActive, activeAgentId) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectivePath, sessActive, activeAgentId]) + // Effort is just a launch flag; respawn the bridge but keep provider context. const prevEffortRef = useRef({ sessActive, activeAgentId, effort }) useEffect(() => { @@ -245,6 +260,12 @@ export function useComposerSend(opts: UseComposerSendOpts) { return } let pane = activeAgentPane + // Terminal providers cannot change cwd in place. Retire an agent pane + // launched for the old worktree before forwarding the next command. + if (pane?.live && pane.cwd && pane.cwd !== effectivePath) { + pty.close?.(pane.paneId) + pane = null + } if (!pane || !pane.live) pane = pty.addAgent(activeWs, activeTabId, agent.id, agent.name, effectivePath, agent.path) setMessages(m => [...m, { kind: 'system', time, tone: 'info', text: `${agent.name} compaction requested. Continue after the provider reports completion.` }]) pty.write(pane.paneId, '/compact\n') diff --git a/src/renderer/src/hooks/useGitSidebar.test.ts b/src/renderer/src/hooks/useGitSidebar.test.ts new file mode 100644 index 0000000..6a45ba0 --- /dev/null +++ b/src/renderer/src/hooks/useGitSidebar.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { act, flush, renderHook } from './hook-test-host' +import { useGitSidebar } from './useGitSidebar' + +function apiStub() { + return { + worktreeCreate: vi.fn(async () => ({ ok: true, path: '/repo/.worktrees/feature' })), + worktreeList: vi.fn(async () => ({ worktrees: [ + { id: 'feature-wt', path: '/repo/.worktrees/feature', branch: 'feature', head: 'abc', locked: false, dirty: 0 }, + ] })), + gitCheckout: vi.fn(async () => ({ ok: true })), + gitStatus: vi.fn(async () => ({ branch: 'main', staged: [], unstaged: [], untracked: [], ahead: 0, behind: 0 })), + gitLog: vi.fn(async () => ({ commits: [] })), + gitBranches: vi.fn(async () => ({ branches: [] })), + ghStatus: vi.fn(async () => ({})), + gitRemotes: vi.fn(async () => ({ isRepo: true, remotes: [], remoteUrls: [] })), + } +} + +describe('useGitSidebar isolated branch switching', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('opens a new branch in a worktree instead of checking out the shared directory', async () => { + const api = apiStub() + vi.stubGlobal('window', { electronAPI: api }) + const onSwitchWorktree = vi.fn() + const onWorktreesChanged = vi.fn(async () => {}) + const hook = renderHook(useGitSidebar, { + repoPath: '/repo', + workspacePath: '/repo', + mainBranch: 'main', + currentWorktreeId: null, + enabled: false, + onSwitchWorktree, + onWorktreesChanged, + }) + + act(() => hook.result.current.handlers.onCheckoutBranch?.('feature')) + await flush() + + expect(api.worktreeCreate).toHaveBeenCalledWith('/repo', 'feature', undefined, undefined) + expect(api.gitCheckout).not.toHaveBeenCalled() + expect(onWorktreesChanged).toHaveBeenCalled() + expect(onSwitchWorktree).toHaveBeenCalledWith('feature-wt') + + hook.unmount() + }) +}) diff --git a/src/renderer/src/hooks/useGitSidebar.ts b/src/renderer/src/hooks/useGitSidebar.ts index 343a4d6..b861fac 100644 --- a/src/renderer/src/hooks/useGitSidebar.ts +++ b/src/renderer/src/hooks/useGitSidebar.ts @@ -82,7 +82,7 @@ export interface UseGitSidebarArgs { enabled: boolean // only fetch while the sidebar is open onSwitchWorktree: (id: string | null) => void // App owns worktree selection onAskAgent?: (text: string, targetTabId?: string) => void // delegate a task to a chat tab's agent - onWorktreesChanged?: () => void // worktree added/removed — refresh app state + onWorktreesChanged?: () => void | Promise // worktree added/removed — refresh app state onRequestGitAuth?: (request: GitAuthRequest) => Promise // Resolve a commit signing-key passphrase, or null if the user declines signing. onRequestSigningPassphrase?: (request: GitSigningRequest) => Promise @@ -322,8 +322,6 @@ export function useGitSidebar(args: UseGitSidebarArgs): UseGitSidebarResult { const branch = ref.replace(/^origin\//, '') const wt = worktreesRef.current.find(w => w.branch === branch || w.branch === ref) if (wt) { - // Branches with registered worktrees should behave like VS Code: open - // that checkout instead of mutating the currently visible one. onSwitchWorktree(wt.id) showBanner({ kind: '', text: `opened worktree ${wt.branch}`, auto: 2500 }) return @@ -333,10 +331,43 @@ export function useGitSidebar(args: UseGitSidebarArgs): UseGitSidebarResult { showBanner({ kind: '', text: `opened ${mainBranch}`, auto: 2500 }) return } - runAction(`checkout ${ref}…`, () => window.electronAPI!.gitCheckout(repoPath, ref), `on ${ref}`) + // Never checkout another branch into this surface's current directory: + // that directory may be the primary checkout or belong to another chat. + // Materialize/open a worktree so branch changes remain surface-local. + runAction( + `opening ${ref} in a worktree…`, + async () => { + const created = await window.electronAPI!.worktreeCreate( + workspacePath, + branch, + undefined, + ref.startsWith('origin/') ? ref : undefined, + ) + if (created.error || !created.path) return created + const listed = await window.electronAPI!.worktreeList(workspacePath) + const next = listed.worktrees?.find(candidate => candidate.path === created.path || candidate.branch === branch) + await onWorktreesChanged?.() + if (next) onSwitchWorktree(next.id) + return next ? { ok: true } : { error: `created ${branch}, but could not resolve its worktree` } + }, + `opened ${branch} in its own worktree`, + ) + }, + onCreateBranch: (name) => { + runAction( + `creating ${name} in a worktree…`, + async () => { + const created = await window.electronAPI!.worktreeCreate(workspacePath, name) + if (created.error || !created.path) return created + const listed = await window.electronAPI!.worktreeList(workspacePath) + const next = listed.worktrees?.find(candidate => candidate.path === created.path || candidate.branch === name) + await onWorktreesChanged?.() + if (next) onSwitchWorktree(next.id) + return next ? { ok: true } : { error: `created ${name}, but could not resolve its worktree` } + }, + `created ${name} in its own worktree`, + ) }, - onCreateBranch: (name) => - runAction(`creating ${name}…`, () => window.electronAPI!.gitCreateBranch(repoPath, name), `on ${name}`), onStageFile: (p) => runAction('staging…', () => window.electronAPI!.gitStage(repoPath, [p]), 'staged'), onUnstageFile: (p) => runAction('unstaging…', () => window.electronAPI!.gitUnstage(repoPath, [p]), 'unstaged'), diff --git a/src/renderer/src/hooks/useProviderModels.test.ts b/src/renderer/src/hooks/useProviderModels.test.ts new file mode 100644 index 0000000..65e32f7 --- /dev/null +++ b/src/renderer/src/hooks/useProviderModels.test.ts @@ -0,0 +1,37 @@ +import { act } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { renderHook } from './hook-test-host' +import { FALLBACK_CATALOG, useProviderModels } from './useProviderModels' + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useProviderModels browser fallback', () => { + it('keeps dynamic-provider models visible while remote discovery is pending', async () => { + let resolveModels!: (models: never[]) => void + const pending = new Promise(resolve => { resolveModels = resolve }) + vi.stubGlobal('window', { electronAPI: { agentListModels: vi.fn(() => pending) } }) + + const hook = renderHook(useProviderModels, 'hermes') + + expect(hook.result.current.loading).toBe(true) + expect(hook.result.current.list).toEqual(FALLBACK_CATALOG.hermes) + + await act(async () => { + resolveModels([]) + await pending + }) + expect(hook.result.current.list).toEqual(FALLBACK_CATALOG.hermes) + + hook.unmount() + }) + + it('has a usable browser fallback for every built-in dynamic provider', () => { + for (const provider of ['pi', 'opencode', 'claude', 'hermes', 'crewcoder', 'grok', 'ollama', 'openrouter']) { + expect(FALLBACK_CATALOG[provider]?.length, provider).toBeGreaterThan(0) + } + }) +}) diff --git a/src/renderer/src/hooks/useProviderModels.ts b/src/renderer/src/hooks/useProviderModels.ts index 3fa6e06..8be44e3 100644 --- a/src/renderer/src/hooks/useProviderModels.ts +++ b/src/renderer/src/hooks/useProviderModels.ts @@ -46,6 +46,13 @@ export const FALLBACK_CATALOG: Record = { crewcoder: [ { id: '', label: 'default (CrewCoder config)', provider: 'crewcoder' }, ], + grok: [ + { id: '', label: 'default (Grok config)', provider: 'grok' }, + { id: 'grok-4.5', label: 'Grok 4.5', provider: 'xai', contextWindow: 500_000 }, + ], + hermes: [ + { id: '', label: 'default (Hermes config)', provider: 'hermes' }, + ], claude: [ { id: '', label: 'default (CLI default)', provider: 'anthropic' }, // { id: 'fable', label: 'Fable (latest)', provider: 'anthropic' }, @@ -151,7 +158,11 @@ export function useProviderModels(provider: string, enabled = true, refreshKey: const list = useMemo(() => { if (DYNAMIC_PROVIDERS.has(provider)) { - if (detected === null) return [] + // Keep the curated catalog visible while remote/CLI discovery is in + // flight. Browser RPC is asynchronous, and returning [] here made every + // dynamic provider look model-less until its request completed; Codex was + // the only provider unaffected because its catalog is static. + if (detected === null) return FALLBACK_CATALOG[provider] ?? [] if (detected.length > 0) { // Ollama / OpenRouter require an explicit model (no server-side default), // so don't offer the empty "default" sentinel — only the detected list. diff --git a/src/renderer/src/runtime/WebAgentChat.test.ts b/src/renderer/src/runtime/WebAgentChat.test.ts new file mode 100644 index 0000000..a8c10e5 --- /dev/null +++ b/src/renderer/src/runtime/WebAgentChat.test.ts @@ -0,0 +1,35 @@ +import { createElement } from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +import { installCrewCodeRuntime, type CrewCodeClient } from './crewcode-client' +import { WebAgentChat } from './WebAgentChat' + +describe('WebAgentChat execution custody', () => { + it('replays the detached reply and unmounts without stopping the Brain bridge', () => { + let listener!: (event: unknown) => void + const off = vi.fn() + const bridgeStop = vi.fn() + const client = { + onBridgeEvent: vi.fn((next: (event: unknown) => void) => { listener = next; return off }), + bridgeStop, + } as unknown as CrewCodeClient + vi.stubGlobal('window', {}) + installCrewCodeRuntime({ kind: 'web', client }) + + let renderer!: TestRenderer.ReactTestRenderer + act(() => { + renderer = TestRenderer.create(createElement(WebAgentChat, { + workspacePath: '/workspace', workspaceId: 'workspace-id', onClose: vi.fn(), + })) + }) + act(() => listener({ + type: 'history_agent', bridgeId: 'web-chat-workspace-id', turnId: 'remote-turn', text: 'finished while the page was closed', + })) + expect(renderer.root.findAllByType('div').some(node => node.children.includes('finished while the page was closed'))).toBe(true) + + act(() => renderer.unmount()) + expect(off).toHaveBeenCalledTimes(1) + expect(bridgeStop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/WebAgentChat.tsx b/src/renderer/src/runtime/WebAgentChat.tsx index cf69003..9c6098e 100644 --- a/src/renderer/src/runtime/WebAgentChat.tsx +++ b/src/renderer/src/runtime/WebAgentChat.tsx @@ -13,7 +13,9 @@ const PROVIDERS: Array<{ id: AgentProviderId; label: string }> = [ ] export function WebAgentChat({ workspacePath, workspaceId, onClose }: { workspacePath: string; workspaceId: string; onClose: () => void }) { - const bridgeId = useRef(`web-chat-${workspaceId}-${Date.now().toString(36)}`) + // Stable browser identity allows a fresh authenticated page to reclaim the + // same Brain-owned execution instead of spawning or stopping it on teardown. + const bridgeId = useRef(`web-chat-${workspaceId}`) const [provider, setProvider] = useState('crewcoder') const [rows, setRows] = useState([]) const [draft, setDraft] = useState('') @@ -27,7 +29,16 @@ export function WebAgentChat({ workspacePath, workspaceId, onClose }: { workspac const eventBridgeId = event.type === 'user_request' ? event.request.bridgeId : event.bridgeId if (eventBridgeId !== bridgeId.current) return if (event.type === 'turn_start') setRunning(true) - else if (event.type === 'text_delta') { + else if (event.type === 'history_agent') { + // Reclaim sends an authoritative snapshot because final deltas may have + // raced the old page closing. Replace that turn rather than appending a + // duplicate or preserving only the partial text rendered before close. + setRows(current => { + const existing = current.findIndex(row => row.role === 'agent' && row.id === event.turnId) + if (existing === -1) return [...current, { id: event.turnId, role: 'agent', text: event.text }] + return current.map((row, index) => index === existing ? { ...row, text: event.text } : row) + }) + } else if (event.type === 'text_delta') { setRows(current => { const last = current[current.length - 1] return last?.role === 'agent' ? [...current.slice(0, -1), { ...last, text: last.text + event.delta }] : [...current, { id: event.turnId, role: 'agent', text: event.delta }] @@ -39,7 +50,9 @@ export function WebAgentChat({ workspacePath, workspaceId, onClose }: { workspac else if (event.type === 'user_request') setRequest(event.request) else if (event.type === 'user_request_resolved') setRequest(current => current?.requestId === event.requestId ? null : current) }) - return () => { off(); api.bridgeStop(bridgeId.current) } + // Browser/component teardown only detaches event observation. Execution + // lifecycle belongs to Brain; explicit Stop/reset/removal actions terminate. + return () => { off() } }, []) const send = async () => { diff --git a/src/renderer/src/runtime/WebConnectionScreen.tsx b/src/renderer/src/runtime/WebConnectionScreen.tsx index 9686638..73fc175 100644 --- a/src/renderer/src/runtime/WebConnectionScreen.tsx +++ b/src/renderer/src/runtime/WebConnectionScreen.tsx @@ -3,7 +3,14 @@ import type { CrewCodeServerCapabilities } from '../../../shared/remote-access-t import App from '../App' import { SettingsProvider } from '../hooks/useSettings' import { NotificationsProvider } from '../hooks/useNotifications' +import { hydrateMessagesFromBackend } from '../stores/chat-messages-store' import { installCrewCodeRuntime } from './crewcode-client' +import { clearClaimedWebBridgeRoutes, markClaimedWebBridgeRoutes, rememberWebBridgeRoutes, webBridgeRoutes } from './web-bridge-routes' +import { + connectHubRelayTransport, + type HubRelayConnectionStatus, + type ManagedHubRelayTransport, +} from './hub-relay-client' import { clearWebSession, createWebCrewCodeClient, @@ -13,6 +20,17 @@ import { webRpc, } from './web-rpc-client' +interface BrainExecutionSummary { + bridgeId: string + status: 'idle' | 'running' | 'completed' | 'blocked' | 'failed' | 'interrupted' + attached: boolean + provider?: string + cwd?: string + conversationScopeKey?: string + lastEventAt: number + droppedEvents: number +} + function pairingToken(): string { const params = new URLSearchParams(window.location.hash.replace(/^#/, '')) return params.get('token') ?? '' @@ -22,11 +40,85 @@ export function WebConnectionScreen() { const [status, setStatus] = useState('Checking CrewCode server…') const [capabilities, setCapabilities] = useState(null) const [connected, setConnected] = useState(false) + const [relay, setRelay] = useState(null) + const [relayStatus, setRelayStatus] = useState(null) + const [brainExecutions, setBrainExecutions] = useState([]) useEffect(() => { let cancelled = false + let activeRelay: ManagedHubRelayTransport | null = null + let disposeRelayStatus: (() => void) | null = null + let executionPoll: ReturnType | null = null + let initialRelayRefreshComplete = false void (async () => { try { + const machineId = new URLSearchParams(window.location.search).get('machine') + if (machineId) { + setStatus('Establishing an end-to-end encrypted Brain tunnel…') + const connectedRelay = await connectHubRelayTransport(machineId, ['workspace:read', 'workspace:write', 'terminal', 'agent']) + if (cancelled) { connectedRelay.close(); return } + activeRelay = connectedRelay + clearClaimedWebBridgeRoutes() + const refreshExecutions = async (claimDetached = false): Promise => { + try { + const result = await connectedRelay.transport.rpc<{ executions: BrainExecutionSummary[] }>('bridge.list', {}) + rememberWebBridgeRoutes(result.executions.flatMap(execution => execution.conversationScopeKey + ? [{ bridgeId: execution.bridgeId, tabId: execution.conversationScopeKey, cwd: execution.cwd, provider: execution.provider }] + : [])) + let executions = result.executions + if (claimDetached) { + // Claim every stable chat execution, not only ones already + // marked detached. During refresh Brain may not have observed + // the old page closing yet; claim performs the same-owner + // encrypted-session handoff atomically. + const bridgeIds = executions.filter(execution => execution.conversationScopeKey).map(execution => execution.bridgeId) + if (bridgeIds.length) { + const claimed = await connectedRelay.transport.rpc<{ claimed: string[] }>('bridge.claim', { bridgeIds }) + const attached = new Set(claimed.claimed) + markClaimedWebBridgeRoutes(attached) + executions = executions.map(execution => attached.has(execution.bridgeId) ? { ...execution, attached: true } : execution) + } + } + if (claimDetached) { + // replayHistory covers a live process; recoverHistory covers the + // same owner after a Brain restart erased its resource map but + // left the Brain-local conversation shard intact. + for (const execution of executions) { + if (execution.status !== 'completed' || !execution.conversationScopeKey) continue + await connectedRelay.transport.rpc('bridge.replayHistory', { bridgeId: execution.bridgeId }) + } + for (const route of webBridgeRoutes()) { + await connectedRelay.transport.rpc('bridge.recoverHistory', { + bridgeId: route.bridgeId, + conversationScopeKey: route.tabId, + }) + } + } + if (!cancelled) setBrainExecutions(executions) + } catch { /* disconnect banner reports transport failure */ } + } + disposeRelayStatus = connectedRelay.onStatus(next => { + setRelayStatus(next) + if (next.state === 'connected' && initialRelayRefreshComplete) void refreshExecutions(true) + }) + executionPoll = setInterval(() => { void refreshExecutions() }, 10_000) + setRelay(connectedRelay) + // Discover routes and reclaim detached executions before App mounts. + // The managed transport buffers their replay until the bridge event + // subscriber is installed, so a completed reply survives page reload. + await refreshExecutions(true) + initialRelayRefreshComplete = true + const client = createWebCrewCodeClient(connectedRelay.transport) + await client.workspacesList() + installCrewCodeRuntime({ kind: 'web', client }) + // App and its message store are statically imported before the web + // runtime exists. Retry the desktop-style authoritative transcript + // hydration now that encrypted Brain RPC is available. + await hydrateMessagesFromBackend() + setStatus(`Connected with Brain-local scopes: ${connectedRelay.grantedScopes.join(', ') || 'none'}`) + setConnected(true) + return + } const nextCapabilities = await fetchServerCapabilities() if (cancelled) return setCapabilities(nextCapabilities) @@ -45,6 +137,7 @@ export function WebConnectionScreen() { await webRpc(session, 'workspaces.list', {}) if (cancelled) return installCrewCodeRuntime({ kind: 'web', client: createWebCrewCodeClient(session) }) + await hydrateMessagesFromBackend() setConnected(true) } catch (error) { // A rejected restored session should return to the pairing state rather @@ -53,14 +146,46 @@ export function WebConnectionScreen() { if (!cancelled) setStatus(`Could not connect: ${(error as Error).message}`) } })() - return () => { cancelled = true } + return () => { + cancelled = true + disposeRelayStatus?.() + if (executionPoll) clearInterval(executionPoll) + activeRelay?.close() + } }, []) if (connected) { + const relayInterrupted = relayStatus?.state === 'disconnected' return ( + {brainExecutions.length > 0 && ( + + )} + {relayStatus && relayStatus.state !== 'connected' && ( + + )} ) diff --git a/src/renderer/src/runtime/hub-relay-client.test.ts b/src/renderer/src/runtime/hub-relay-client.test.ts new file mode 100644 index 0000000..97a5707 --- /dev/null +++ b/src/renderer/src/runtime/hub-relay-client.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import type { BrainAccessScope } from '../../../shared/hub-relay-types' +import { connectHubRelayTransport, type OpenHubRelayTransport } from './hub-relay-client' +import type { WebClientTransport, WebEventEnvelope } from './web-rpc-client' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(next => { resolve = next }) + return { promise, resolve } +} + +function connection(label: string, scopes: BrainAccessScope[] = ['workspace:read']) { + const ended = deferred<{ code: number; reason: string; error: Error }>() + const eventListeners = new Set<(event: WebEventEnvelope) => void>() + const rpc = vi.fn(async (method: string, _params?: unknown): Promise => `${label}:${method}`) + const transport: WebClientTransport = { + rpc: (method: string, params?: unknown) => rpc(method, params) as Promise, + subscribe(listener) { + eventListeners.add(listener) + return () => eventListeners.delete(listener) + }, + } + const value: OpenHubRelayTransport = { + transport, + grantedScopes: scopes, + closed: ended.promise, + close: vi.fn(), + } + return { + value, + rpc, + disconnect: (reason = 'network lost') => ended.resolve({ code: 1006, reason, error: new Error(reason) }), + event: (event: WebEventEnvelope) => { for (const listener of eventListeners) listener(event) }, + } +} + +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('managed Hub relay transport', () => { + it('requires explicit fresh-ticket reconnection and never queues disconnected RPC', async () => { + const first = connection('first') + const second = connection('second', ['workspace:read', 'terminal']) + const open = vi.fn() + .mockResolvedValueOnce(first.value) + .mockResolvedValueOnce(second.value) + const managed = await connectHubRelayTransport('machine', ['workspace:read'], { open }) + const statuses: string[] = [] + managed.onStatus(status => statuses.push(status.state)) + + expect(await managed.transport.rpc('workspaces.list', {})).toBe('first:workspaces.list') + await managed.transport.rpc('bridge.start', { bridgeId: 'durable-agent' }) + first.disconnect() + await settle() + await expect(managed.transport.rpc('workspaces.list', {})).rejects.toThrow('reconnect before retrying') + expect(open).toHaveBeenCalledTimes(1) + + await managed.reconnect() + expect(open).toHaveBeenCalledTimes(2) + expect(second.rpc).toHaveBeenCalledWith('bridge.claim', { bridgeIds: ['durable-agent'] }) + expect(managed.grantedScopes).toEqual(['workspace:read', 'terminal']) + expect(await managed.transport.rpc('workspaces.list', {})).toBe('second:workspaces.list') + expect(statuses).toEqual(['connected', 'disconnected', 'connecting', 'connected']) + }) + + it('drops stale ownership when a restarted Brain cannot reclaim a resource', async () => { + const first = connection('first') + const second = connection('second') + const third = connection('third') + second.rpc.mockImplementation(async method => method === 'bridge.claim' ? { claimed: [] } : `second:${method}`) + const open = vi.fn() + .mockResolvedValueOnce(first.value) + .mockResolvedValueOnce(second.value) + .mockResolvedValueOnce(third.value) + const managed = await connectHubRelayTransport('machine', ['agent'], { open }) + + await managed.transport.rpc('bridge.start', { bridgeId: 'lost-on-brain-restart' }) + first.disconnect('brain restarted') + await settle() + await managed.reconnect() + expect(second.rpc).toHaveBeenCalledWith('bridge.claim', { bridgeIds: ['lost-on-brain-restart'] }) + + second.disconnect('network lost again') + await settle() + await managed.reconnect() + expect(third.rpc).not.toHaveBeenCalledWith('bridge.claim', expect.anything()) + }) + + it('buffers reclaimed events until App installs its event subscriber', async () => { + const first = connection('first') + const managed = await connectHubRelayTransport('machine', ['agent'], { open: vi.fn().mockResolvedValue(first.value) }) + const event = { channel: 'bridge', event: { type: 'text_delta', bridgeId: 'detached', turnId: 'turn', delta: 'recovered reply' } } as const + + first.event(event) + const listener = vi.fn() + managed.transport.subscribe(listener) + await settle() + + expect(listener).toHaveBeenCalledWith(event) + }) + + it('keeps event subscriptions attached across a reconnect', async () => { + const first = connection('first') + const second = connection('second') + const open = vi.fn().mockResolvedValueOnce(first.value).mockResolvedValueOnce(second.value) + const managed = await connectHubRelayTransport('machine', ['workspace:read'], { open }) + const listener = vi.fn() + managed.transport.subscribe(listener) + const event = { channel: 'pty', event: { type: 'data', paneId: 'one', data: 'hello' } } as const + + first.event(event) + first.disconnect() + await settle() + await managed.reconnect() + second.event(event) + expect(listener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/runtime/hub-relay-client.ts b/src/renderer/src/runtime/hub-relay-client.ts new file mode 100644 index 0000000..196a075 --- /dev/null +++ b/src/renderer/src/runtime/hub-relay-client.ts @@ -0,0 +1,368 @@ +import { + CREWCODE_REMOTE_PROTOCOL_VERSION, + type CrewCodeRemoteRequest, + type CrewCodeRemoteResponse, +} from '../../../shared/remote-access-types' +import type { + BrainAccessScope, + HubConnectionTicketResponse, + HubRelayControlFrame, + HubTunnelPlaintext, +} from '../../../shared/hub-relay-types' +import type { WebClientTransport, WebEventEnvelope } from './web-rpc-client' +import { WebRpcError } from './web-rpc-client' + +const encoder = new TextEncoder() +const decoder = new TextDecoder() +let requestCounter = 0 + +function ownedBytes(value: Uint8Array): Uint8Array { + const result = new Uint8Array(new ArrayBuffer(value.byteLength)) + result.set(value) + return result +} + +function decodeBase64Url(value: string): Uint8Array { + let normalized = value.replace(/-/g, '+').replace(/_/g, '/') + while (normalized.length % 4) normalized += '=' + const raw = atob(normalized) + const result = new Uint8Array(new ArrayBuffer(raw.length)) + for (let index = 0; index < raw.length; index += 1) result[index] = raw.charCodeAt(index) + return result +} + +function encodeBase64Url(value: ArrayBuffer | Uint8Array): string { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value) + let raw = '' + for (const byte of bytes) raw += String.fromCharCode(byte) + return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function transcript(connectionId: string, clientKey: string, serverKey: string): Uint8Array { + return ownedBytes(encoder.encode(`crewcode-hub-relay-v1\0${connectionId}\0${clientKey}\0${serverKey}`)) +} + +function nonce(direction: 'browser' | 'brain', sequence: number): Uint8Array { + if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error('invalid relay sequence') + const value = new Uint8Array(new ArrayBuffer(12)) + const view = new DataView(value.buffer) + view.setUint32(0, direction === 'browser' ? 0x42525752 : 0x4252414e) + view.setBigUint64(4, BigInt(sequence)) + return value +} + +function aad(connectionId: string, direction: 'browser' | 'brain', sequence: number): Uint8Array { + return ownedBytes(encoder.encode(`${connectionId}\0${direction}\0${sequence}`)) +} + +async function responseJson(response: Response): Promise> { + return await response.json().catch(() => ({})) as Record +} + +export interface OpenHubRelayTransport { + transport: WebClientTransport + grantedScopes: BrainAccessScope[] + closed: Promise<{ code: number; reason: string; error: Error }> + close(): void +} + +export type HubRelayConnectionStatus = + | { state: 'connected'; grantedScopes: BrainAccessScope[] } + | { state: 'connecting' } + | { state: 'disconnected'; message: string; code?: number; reason?: string } + +export interface ManagedHubRelayTransport { + transport: WebClientTransport + grantedScopes: BrainAccessScope[] + reconnect(): Promise + onStatus(listener: (status: HubRelayConnectionStatus) => void): () => void + close(): void +} + +async function openHubRelayTransport(machineId: string, requestedScopes: BrainAccessScope[]): Promise { + const sessionResponse = await fetch('/api/v1/hub/session', { cache: 'no-store' }) + const session = await responseJson(sessionResponse) + if (!sessionResponse.ok || typeof session.csrf !== 'string') throw new WebRpcError(String(session.error ?? 'valid Hub session required'), 'UNAUTHENTICATED', sessionResponse.status) + const ticketResponse = await fetch(`/api/v1/hub/machines/${encodeURIComponent(machineId)}/tickets`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-crewcode-csrf': session.csrf }, + body: JSON.stringify({ requestedScopes }), + }) + const ticketBody = await responseJson(ticketResponse) as unknown as HubConnectionTicketResponse & { error?: string } + if (!ticketResponse.ok || !ticketBody.ticket || !ticketBody.machinePublicKey) throw new WebRpcError(ticketBody.error ?? 'connection ticket rejected', 'FORBIDDEN', ticketResponse.status) + + const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) + const clientKey = encodeBase64Url(await crypto.subtle.exportKey('raw', keyPair.publicKey)) + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket(`${protocol}//${location.host}/api/v1/hub/relay`, ['crewcode.browser.v1', ticketBody.ticket]) + const listeners = new Set<(event: WebEventEnvelope) => void>() + const pending = new Map() + let connectionId = '' + let browserKey: CryptoKey | null = null + let brainKey: CryptoKey | null = null + let browserSequence = 0 + let expectedBrainSequence = 0 + let grantedScopes: BrainAccessScope[] = [] + let messageChain = Promise.resolve() + let sendChain = Promise.resolve() + let readyResolve!: () => void + let readyReject!: (error: Error) => void + let closedResolve!: (value: { code: number; reason: string; error: Error }) => void + let failure: Error | null = null + let failed = false + const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject }) + const closed = new Promise<{ code: number; reason: string; error: Error }>(resolve => { closedResolve = resolve }) + + const fail = (error: Error): void => { + failure ??= error + if (failed) return + failed = true + readyReject(error) + for (const item of pending.values()) item.reject(error) + pending.clear() + } + + const handleMessage = async (frame: HubRelayControlFrame): Promise => { + if (frame.type === 'ready') { + if (frame.machineId !== ticketBody.machineId || frame.machinePublicKey !== ticketBody.machinePublicKey) throw new Error('Hub returned mismatched machine identity') + connectionId = frame.connectionId + socket.send(JSON.stringify({ type: 'clientHello', connectionId, key: clientKey } satisfies HubRelayControlFrame)) + return + } + if (frame.type === 'serverHello') { + if (!connectionId || frame.connectionId !== connectionId) throw new Error('Brain handshake has a mismatched connection id') + const machineKey = await crypto.subtle.importKey('spki', decodeBase64Url(ticketBody.machinePublicKey), { name: 'Ed25519' }, false, ['verify']) + const verified = await crypto.subtle.verify('Ed25519', machineKey, decodeBase64Url(frame.signature), transcript(connectionId, clientKey, frame.key)) + if (!verified) throw new Error('Brain machine identity signature was rejected') + const serverKey = await crypto.subtle.importKey('raw', decodeBase64Url(frame.key), { name: 'ECDH', namedCurve: 'P-256' }, false, []) + const shared = await crypto.subtle.deriveBits({ name: 'ECDH', public: serverKey }, keyPair.privateKey, 256) + const salt = await crypto.subtle.digest('SHA-256', transcript(connectionId, clientKey, frame.key)) + const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']) + browserKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: ownedBytes(encoder.encode('browser-to-brain')) }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']) + brainKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: ownedBytes(encoder.encode('brain-to-browser')) }, material, { name: 'AES-GCM', length: 256 }, false, ['decrypt']) + grantedScopes = frame.grantedScopes + readyResolve() + return + } + if (frame.type === 'close') throw new Error(`Brain closed the tunnel: ${frame.reason}`) + if (frame.type !== 'encrypted' || !brainKey || frame.connectionId !== connectionId) return + if (frame.sequence !== expectedBrainSequence) throw new Error('Brain encrypted frame sequence was rejected') + expectedBrainSequence += 1 + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce('brain', frame.sequence), additionalData: aad(connectionId, 'brain', frame.sequence), tagLength: 128 }, + brainKey, + decodeBase64Url(frame.ciphertext), + ) + const message = JSON.parse(decoder.decode(plaintext)) as HubTunnelPlaintext + if (message.type === 'event') { + if (message.channel === 'pty' || message.channel === 'bridge') { + for (const listener of listeners) listener({ channel: message.channel, event: message.event } as WebEventEnvelope) + } + return + } + if (message.type !== 'rpcResult') return + const awaiting = pending.get(message.response.id) + if (!awaiting) return + pending.delete(message.response.id) + if (message.response.ok) awaiting.resolve(message.response.result) + else awaiting.reject(new WebRpcError(message.response.error.message, message.response.error.code)) + } + + socket.addEventListener('message', event => { + messageChain = messageChain.then(async () => handleMessage(JSON.parse(String(event.data)) as HubRelayControlFrame)).catch(error => { + fail(error as Error) + socket.close(4002, 'encrypted tunnel rejected') + }) + }) + socket.addEventListener('error', () => fail(new WebRpcError('Hub relay connection failed', 'UNAUTHENTICATED'))) + socket.addEventListener('close', event => { + const detail = event.reason ? `: ${event.reason}` : '' + const error = failure ?? new WebRpcError(`Hub relay disconnected${detail}; unobserved operations are interrupted`, 'UNAUTHENTICATED') + fail(error) + closedResolve({ code: event.code, reason: event.reason, error }) + }) + await ready + + const transport: WebClientTransport = { + async rpc(method: string, params: Record): Promise { + if (!browserKey || socket.readyState !== WebSocket.OPEN) throw new WebRpcError('encrypted Brain tunnel is not connected', 'UNAUTHENTICATED') + requestCounter += 1 + const id = `hub-${Date.now().toString(36)}-${requestCounter.toString(36)}` + const request: CrewCodeRemoteRequest = { protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id, method, params } + const result = new Promise((resolve, reject) => pending.set(id, { resolve: value => resolve(value as T), reject })) + // WebCrypto promises from concurrent RPCs may settle out of order. Keep + // encryption and socket.send in one chain so sequence order is also wire + // order; the Brain rejects gaps and replayed/out-of-order frames. + const send = sendChain.then(async () => { + if (!browserKey || socket.readyState !== WebSocket.OPEN) throw new WebRpcError('encrypted Brain tunnel is not connected', 'UNAUTHENTICATED') + const sequence = browserSequence++ + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce('browser', sequence), additionalData: aad(connectionId, 'browser', sequence), tagLength: 128 }, + browserKey, + ownedBytes(encoder.encode(JSON.stringify({ type: 'rpc', request } satisfies HubTunnelPlaintext))), + ) + socket.send(JSON.stringify({ type: 'encrypted', connectionId, sequence, ciphertext: encodeBase64Url(ciphertext) } satisfies HubRelayControlFrame)) + }) + sendChain = send.catch(() => undefined) + try { + await send + } catch (error) { + fail(error as Error) + socket.close(4002, 'encrypted tunnel send failed') + throw error + } + return result + }, + subscribe(onEvent) { + listeners.add(onEvent) + return () => listeners.delete(onEvent) + }, + } + return { transport, grantedScopes, closed, close: () => socket.close(1000, 'browser disconnected') } +} + +/** + * Keeps one stable transport installed in the browser runtime while requiring an + * explicit user action to reconnect with a fresh ticket. Interrupted RPCs are + * rejected by the old connection and are never queued or replayed. + */ +export async function connectHubRelayTransport( + machineId: string, + requestedScopes: BrainAccessScope[], + options: { open?: () => Promise } = {}, +): Promise { + const open = options.open ?? (() => openHubRelayTransport(machineId, requestedScopes)) + const statusListeners = new Set<(status: HubRelayConnectionStatus) => void>() + const eventListeners = new Set<(event: WebEventEnvelope) => void>() + const pendingEvents: WebEventEnvelope[] = [] + const MAX_PENDING_EVENTS = 1_000 + let active: OpenHubRelayTransport | null = null + let activeEventDisposer: (() => void) | null = null + let stopped = false + let reconnecting: Promise | null = null + let grantedScopes: BrainAccessScope[] = [] + const bridgeIds = new Set() + const paneIds = new Set() + + const emitStatus = (status: HubRelayConnectionStatus): void => { + for (const listener of statusListeners) listener(status) + } + const activate = (connection: OpenHubRelayTransport, announce = true): void => { + activeEventDisposer?.() + active = connection + grantedScopes = connection.grantedScopes + activeEventDisposer = connection.transport.subscribe(event => { + if (!eventListeners.size) { + pendingEvents.push(event) + if (pendingEvents.length > MAX_PENDING_EVENTS) pendingEvents.shift() + return + } + for (const listener of eventListeners) listener(event) + }) + if (announce) emitStatus({ state: 'connected', grantedScopes }) + void connection.closed.then(disconnect => { + if (active !== connection) return + activeEventDisposer?.() + activeEventDisposer = null + active = null + if (!stopped) emitStatus({ + state: 'disconnected', + message: disconnect.error.message, + code: disconnect.code, + reason: disconnect.reason, + }) + }) + } + const reconnect = async (): Promise => { + if (stopped) throw new WebRpcError('Hub relay transport is closed', 'UNAUTHENTICATED') + if (active) return + if (reconnecting) return reconnecting + emitStatus({ state: 'connecting' }) + reconnecting = open().then(connection => { + if (stopped) { + connection.close() + throw new WebRpcError('Hub relay transport is closed', 'UNAUTHENTICATED') + } + activate(connection, false) + const requestedBridgeIds = [...bridgeIds] + const requestedPaneIds = [...paneIds] + return Promise.all([ + requestedBridgeIds.length + ? connection.transport.rpc<{ claimed: string[] }>('bridge.claim', { bridgeIds: requestedBridgeIds }) + : Promise.resolve({ claimed: [] }), + requestedPaneIds.length + ? connection.transport.rpc<{ claimed: string[] }>('pty.claim', { paneIds: requestedPaneIds }) + : Promise.resolve({ claimed: [] }), + ]).then(([bridgeClaims, paneClaims]) => { + // A Brain restart legitimately returns an empty claim set because its + // execution registry is process-local. Keep only ownership that the new + // encrypted session actually proved; stale ids must not be presented as + // attached or blindly re-claimed on every later reconnect. + bridgeIds.clear() + for (const id of Array.isArray(bridgeClaims.claimed) ? bridgeClaims.claimed.map(String) : []) bridgeIds.add(id) + paneIds.clear() + for (const id of Array.isArray(paneClaims.claimed) ? paneClaims.claimed.map(String) : []) paneIds.add(id) + emitStatus({ state: 'connected', grantedScopes }) + }) + }).catch(error => { + activeEventDisposer?.() + activeEventDisposer = null + active?.close() + active = null + if (!stopped) emitStatus({ state: 'disconnected', message: (error as Error).message }) + throw error + }).finally(() => { reconnecting = null }) + return reconnecting + } + + activate(await open()) + const transport: WebClientTransport = { + async rpc(method: string, params: Record): Promise { + if (!active) throw new WebRpcError('Hub relay is disconnected; reconnect before retrying', 'UNAUTHENTICATED') + const result = await active.transport.rpc(method, params) + const bridgeId = typeof params.bridgeId === 'string' ? params.bridgeId : '' + const paneId = typeof params.paneId === 'string' ? params.paneId : '' + const semantic = result && typeof result === 'object' ? result as { ok?: boolean; error?: unknown; claimed?: unknown } : null + const succeeded = semantic?.ok !== false && !semantic?.error + const claimed = Array.isArray(semantic?.claimed) ? semantic.claimed.map(String) : [] + if (method === 'bridge.start' && bridgeId && succeeded) bridgeIds.add(bridgeId) + else if (method === 'bridge.stop' && bridgeId && succeeded) bridgeIds.delete(bridgeId) + else if (method === 'bridge.claim' && succeeded) { + for (const id of claimed) bridgeIds.add(id) + } else if (method === 'pty.create' && paneId && succeeded) paneIds.add(paneId) + else if (method === 'pty.kill' && paneId && succeeded) paneIds.delete(paneId) + else if (method === 'pty.claim' && succeeded) { + for (const id of claimed) paneIds.add(id) + } + return result + }, + subscribe(listener) { + eventListeners.add(listener) + if (pendingEvents.length) { + const replay = pendingEvents.splice(0) + queueMicrotask(() => { + if (!eventListeners.has(listener)) return + for (const event of replay) listener(event) + }) + } + return () => eventListeners.delete(listener) + }, + } + return { + transport, + get grantedScopes() { return grantedScopes }, + reconnect, + onStatus(listener) { + statusListeners.add(listener) + listener(active ? { state: 'connected', grantedScopes } : { state: 'disconnected', message: 'Hub relay is disconnected' }) + return () => statusListeners.delete(listener) + }, + close() { + stopped = true + activeEventDisposer?.() + activeEventDisposer = null + active?.close() + active = null + }, + } +} diff --git a/src/renderer/src/runtime/web-bridge-routes.test.ts b/src/renderer/src/runtime/web-bridge-routes.test.ts new file mode 100644 index 0000000..200d39e --- /dev/null +++ b/src/renderer/src/runtime/web-bridge-routes.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const values = new Map() + +beforeEach(() => { + values.clear() + vi.resetModules() + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { values.set(key, value) }), + }) +}) + +describe('web bridge recovery routes', () => { + it('survives a full browser module reload without storing authority', async () => { + const first = await import('./web-bridge-routes') + first.rememberWebBridgeRoutes([{ bridgeId: 'br-thread-codex-remote', tabId: 'thread', cwd: '/workspace', provider: 'codex' }]) + + vi.resetModules() + const reopened = await import('./web-bridge-routes') + expect(reopened.webBridgeRoutes()).toEqual([{ bridgeId: 'br-thread-codex-remote', tabId: 'thread', cwd: '/workspace', provider: 'codex' }]) + // Persisted routing is not execution ownership. A fresh encrypted page must + // receive this id in bridge.claim's confirmed result before prompting it. + expect(reopened.claimedWebBridgeRoutes()).toEqual([]) + reopened.markClaimedWebBridgeRoutes(['br-thread-codex-remote']) + expect(reopened.claimedWebBridgeRoutes()).toEqual(reopened.webBridgeRoutes()) + reopened.clearClaimedWebBridgeRoutes() + expect(reopened.claimedWebBridgeRoutes()).toEqual([]) + expect([...values.values()].join(' ')).not.toContain('ticket') + expect([...values.values()].join(' ')).not.toContain('token') + }) +}) diff --git a/src/renderer/src/runtime/web-bridge-routes.ts b/src/renderer/src/runtime/web-bridge-routes.ts new file mode 100644 index 0000000..2c23af8 --- /dev/null +++ b/src/renderer/src/runtime/web-bridge-routes.ts @@ -0,0 +1,73 @@ +export interface WebBridgeRoute { + bridgeId: string + tabId: string + cwd?: string + provider?: string +} + +const STORAGE_KEY = 'crewcode:web-bridge-routes:v1' +const MAX_ROUTES = 100 + +function loadRoutes(): Map { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown + if (!Array.isArray(parsed)) return new Map() + return new Map(parsed.slice(-MAX_ROUTES).flatMap(value => { + if (!value || typeof value !== 'object') return [] + const route = value as Partial + if (typeof route.bridgeId !== 'string' || !route.bridgeId || typeof route.tabId !== 'string' || !route.tabId) return [] + return [[route.bridgeId, { + bridgeId: route.bridgeId, + tabId: route.tabId, + cwd: typeof route.cwd === 'string' ? route.cwd : undefined, + provider: typeof route.provider === 'string' ? route.provider : undefined, + }]] + })) + } catch { return new Map() } +} + +function persistRoutes(): void { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...routes.values()].slice(-MAX_ROUTES))) } catch { /* recovery remains available in this page */ } +} + +// This stores only opaque browser chat/resource ids and an optional workspace +// path. It grants no authority: every recovery still needs a fresh ticket, +// authenticated owner, E2EE tunnel, and Brain-local agent scope. +const routes = loadRoutes() +// Process-local proof that this page's encrypted session successfully claimed a +// Brain execution. Persisted route metadata is never ownership authority. +const claimedRouteIds = new Set() + +export function markClaimedWebBridgeRoutes(bridgeIds: Iterable): void { + for (const bridgeId of bridgeIds) if (routes.has(bridgeId)) claimedRouteIds.add(bridgeId) +} + +export function clearClaimedWebBridgeRoutes(): void { + claimedRouteIds.clear() +} + +export function claimedWebBridgeRoutes(): WebBridgeRoute[] { + return [...claimedRouteIds].flatMap(bridgeId => { + const route = routes.get(bridgeId) + return route ? [route] : [] + }) +} + +export function rememberWebBridgeRoutes(next: WebBridgeRoute[]): void { + for (const route of next) { + if (!route.bridgeId || !route.tabId) continue + routes.delete(route.bridgeId) + routes.set(route.bridgeId, route) + } + persistRoutes() +} + +export function webBridgeRoutes(): WebBridgeRoute[] { + return [...routes.values()] +} + +export function forgetWebBridgeRoute(bridgeId: string): void { + claimedRouteIds.delete(bridgeId) + routes.delete(bridgeId) + persistRoutes() +} diff --git a/src/renderer/src/runtime/web-rpc-client.test.ts b/src/renderer/src/runtime/web-rpc-client.test.ts index bd5bd24..c66d9d6 100644 --- a/src/renderer/src/runtime/web-rpc-client.test.ts +++ b/src/renderer/src/runtime/web-rpc-client.test.ts @@ -33,15 +33,46 @@ describe('web RPC client', () => { expect(JSON.parse(init.body)).toMatchObject({ protocolVersion: 1, method: 'workspaces.list', params: {} }) }) + it('sends only MCP registry ids when starting a remote bridge', async () => { + const rpc = vi.fn<(method: string, params: Record) => void>() + const client = createWebCrewCodeClient({ + rpc: async (method: string, params: Record) => { + rpc(method, params) + return { ok: true } as T + }, + subscribe: () => () => undefined, + }) + + await client.bridgeStart({ + bridgeId: 'bridge-1', provider: 'hermes', cwd: '/repo', + mcpServers: [{ id: 'filesystem', name: 'Filesystem', command: 'browser-must-not-send-this', env: { SECRET: 'no' } }], + }) + + expect(rpc).toHaveBeenCalledWith('bridge.start', expect.objectContaining({ mcpServerIds: ['filesystem'] })) + expect(rpc.mock.calls[0]?.[1]).not.toHaveProperty('mcpServers') + }) + it('maps supported client calls and rejects unavailable features', async () => { vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, init: RequestInit) => { - const request = JSON.parse(String(init.body)) as { id: string } - return Promise.resolve(new Response(JSON.stringify({ protocolVersion: 1, id: request.id, ok: true, result: [] }), { status: 200 })) + const request = JSON.parse(String(init.body)) as { id: string; method: string } + const result = request.method === 'voice.availability' + ? { + off: { configured: true, available: true }, + fake: { configured: false, available: false }, + openai: { configured: false, available: false }, + xai: { configured: false, available: false }, + local: { configured: false, available: false }, + } + : request.method === 'mcp.list' + ? { path: '/brain/.crewcode/mcp.json', exists: true, servers: [], errors: [] } + : [] + return Promise.resolve(new Response(JSON.stringify({ protocolVersion: 1, id: request.id, ok: true, result }), { status: 200 })) })) const client = createWebCrewCodeClient('session') expect(await client.workspacesList()).toEqual([]) expect(await client.agentRegistry()).toEqual([]) - expect(await client.mcpList()).toEqual({ path: '', exists: false, servers: [], errors: [] }) + expect(await client.agentListModels('claude')).toEqual([]) + expect(await client.mcpList()).toMatchObject({ exists: true, servers: [] }) expect(await client.workspacesPickFolder()).toMatchObject({ canceled: true }) expect(await client.keybindsWrite({})).toEqual({ ok: true }) expect(client.onRemoteStatus(() => undefined)).toEqual(expect.any(Function)) @@ -49,6 +80,16 @@ describe('web RPC client', () => { expect(client.onNotificationClick(() => undefined)).toEqual(expect.any(Function)) expect(client.onDelegationRequest(() => undefined)).toEqual(expect.any(Function)) expect(client.onKeybindsChanged(() => undefined)).toEqual(expect.any(Function)) + expect(await client.delegationDisable('session')).toEqual({ ok: true }) + expect(await client.delegationEnable('session', { allowFullAccess: false, parentMode: 'build', maxConcurrent: 1, remote: false })) + .toMatchObject({ ok: false }) + expect(client.editorWatchAdd('/workspace', 'file.ts')).toBeUndefined() + expect(client.editorWatchRemove('/workspace', 'file.ts')).toBeUndefined() + expect(await client.voiceProviderAvailability()).toMatchObject({ + off: { available: true }, + openai: { available: false }, + local: { available: false }, + }) await expect(client.pluginsList()).rejects.toEqual(expect.objectContaining({ code: 'UNSUPPORTED' })) }) }) diff --git a/src/renderer/src/runtime/web-rpc-client.ts b/src/renderer/src/runtime/web-rpc-client.ts index a6a3255..a65adaa 100644 --- a/src/renderer/src/runtime/web-rpc-client.ts +++ b/src/renderer/src/runtime/web-rpc-client.ts @@ -9,7 +9,7 @@ import type { BridgeEvent } from '../types' const SESSION_KEY = 'crewcode:remote-session:v1' let requestCounter = 0 -type WebEventEnvelope = +export type WebEventEnvelope = | { channel: 'pty'; event: { type: 'data'; paneId: string; data: string } | { type: 'exit'; paneId: string; exitCode: number; signal?: number } } | { channel: 'bridge'; event: BridgeEvent } @@ -79,16 +79,50 @@ function createEventSocket(sessionToken: string, onEvent: (envelope: WebEventEnv return socket } +function bytesToBase64(value: Uint8Array): string { + let binary = '' + const chunkSize = 32 * 1024 + for (let offset = 0; offset < value.byteLength; offset += chunkSize) { + binary += String.fromCharCode(...value.subarray(offset, Math.min(value.byteLength, offset + chunkSize))) + } + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +export interface WebClientTransport { + rpc(method: string, params: Record): Promise + subscribe(onEvent: (envelope: WebEventEnvelope) => void): () => void + uploadAttachment?(root: string, name: string, body: ArrayBuffer): Promise + saveSyncBatch?(entries: unknown): boolean +} + /** A Proxy keeps unsupported privileged methods explicit while the web surface * grows; supported calls retain the desktop client's exact TypeScript shape. */ -export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { +export function createWebCrewCodeClient(sessionOrTransport: string | WebClientTransport): CrewCodeClient { + const directSession = typeof sessionOrTransport === 'string' ? sessionOrTransport : null + const transport: WebClientTransport = typeof sessionOrTransport === 'string' + ? { + rpc: (method, params) => webRpc(sessionOrTransport, method, params), + subscribe: onEvent => { + const socket = createEventSocket(sessionOrTransport, onEvent) + return () => socket.close() + }, + } + : sessionOrTransport + const rpc = (method: string, params: Record): Promise => transport.rpc(method, params) const dataListeners = new Set<(event: { paneId: string; data: string }) => void>() const exitListeners = new Set<(event: { paneId: string; exitCode: number; signal?: number }) => void>() const bridgeListeners = new Set<(event: BridgeEvent) => void>() - let eventSocket: WebSocket | null = null + let eventDisposer: (() => void) | null = null const ensureEvents = (): void => { - if (eventSocket) return - eventSocket = createEventSocket(sessionToken, envelope => { + if (eventDisposer) return + eventDisposer = transport.subscribe(envelope => { if (envelope.channel === 'bridge') { for (const listener of bridgeListeners) listener(envelope.event) } else if (envelope.event.type === 'data') { @@ -100,24 +134,24 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { } const noSubscription = (): (() => void) => () => undefined const supported: Partial = { - workspacesList: () => webRpc(sessionToken, 'workspaces.list', {}), - workspacesAdd: path => webRpc(sessionToken, 'workspaces.add', { path }), - workspacesRemove: id => webRpc(sessionToken, 'workspaces.remove', { id }), - workspacesPin: (id, pinned) => webRpc(sessionToken, 'workspaces.pin', { id, pinned }), - workspacesRename: (id, name) => webRpc(sessionToken, 'workspaces.rename', { id, name }), - workspacesSetFolder: (id, folder) => webRpc(sessionToken, 'workspaces.setFolder', { id, folder }), + workspacesList: () => rpc('workspaces.list', {}), + workspacesAdd: path => rpc('workspaces.add', { path }), + workspacesRemove: id => rpc('workspaces.remove', { id }), + workspacesPin: (id, pinned) => rpc('workspaces.pin', { id, pinned }), + workspacesRename: (id, name) => rpc('workspaces.rename', { id, name }), + workspacesSetFolder: (id, folder) => rpc('workspaces.setFolder', { id, folder }), // Browser replacement for the host-native picker. The entered server path // is canonicalized and checked against server-configured workspace roots. workspacesPickFolder: async () => { const selected = window.prompt('Enter a folder path on the CrewCode server:')?.trim() if (!selected) return { ok: true, canceled: true } - const result = await webRpc<{ ok: true; path: string }>(sessionToken, 'workspaces.inspectPath', { path: selected }) + const result = await rpc<{ ok: true; path: string }>('workspaces.inspectPath', { path: selected }) return { ok: true, canceled: false, path: result.path } }, - workspacesCloneRepo: (url, parentDir, folderName) => webRpc(sessionToken, 'workspaces.clone', { url, parentDir, folderName }), - workspacesInitProject: (parentDir, folderName, asGit) => webRpc(sessionToken, 'workspaces.initProject', { parentDir, folderName, asGit }), - agentRegistry: () => webRpc(sessionToken, 'agents.registry', {}), - agentListModels: provider => webRpc(sessionToken, 'agents.listModels', { provider }), + workspacesCloneRepo: (url, parentDir, folderName) => rpc('workspaces.clone', { url, parentDir, folderName }), + workspacesInitProject: (parentDir, folderName, asGit) => rpc('workspaces.initProject', { parentDir, folderName, asGit }), + agentRegistry: () => rpc('agents.registry', {}), + agentListModels: provider => rpc('agents.listModels', { provider }), // Browser-safe platform equivalents. They deliberately do not grant new // server privileges and keep shared App startup independent of Electron. openExternal: async url => { window.open(url, '_blank', 'noopener,noreferrer'); return { ok: true } }, @@ -142,7 +176,31 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { onEditorLanguageServerStatus: noSubscription, onGhAuthEvent: noSubscription, onUpdaterEvent: noSubscription, - mcpList: async () => ({ path: '', exists: false, servers: [], errors: [] }), + // These desktop integrations are deliberately inert in a browser. Defining + // them explicitly matters because the Proxy fallback is a function, so + // optional method checks would otherwise invoke a rejected Promise. + delegationEnable: async () => ({ ok: false, error: 'Agent delegation is unavailable in browser mode' }), + delegationDisable: async () => ({ ok: true }), + editorWatchAdd: () => undefined, + editorWatchRemove: () => undefined, + voiceProviderAvailability: () => rpc('voice.availability', {}), + voiceCreateClientSecret: request => rpc('voice.clientSecret', { request }), + voiceTranscribe: request => { + if (request.provider !== 'openai' && request.provider !== 'xai') return Promise.resolve({ ok: false, error: 'Only Brain-configured GPT and xAI dictation are available remotely.' }) + return rpc('voice.transcribe', { provider: request.provider, audioBase64: bytesToBase64(request.audio) }) + }, + voiceSynthesize: async request => { + if (request.provider !== 'openai' && request.provider !== 'xai') return { ok: false, error: 'Only Brain-configured GPT and xAI speech is available remotely.' } + const result = await rpc<{ ok: boolean; audio?: string; contentType?: string; error?: string }>('voice.synthesize', { + provider: request.provider, text: request.text, voice: request.voice, + }) + return result.ok && result.audio + ? { ok: true, audio: base64ToBytes(result.audio), contentType: result.contentType } + : { ok: false, error: result.error ?? 'Remote speech failed.' } + }, + voiceSetProviderKey: async provider => ({ ok: false, error: `Configure the ${provider} voice key on the CrewCode Brain` }), + mcpList: () => rpc('mcp.list', {}), + mcpOpenFile: async () => ({ ok: false, error: 'Edit ~/.crewcode/mcp.json on the CrewCode Brain' }), sshListConfig: async () => [], keybindsRead: async () => ({ ok: true, data: null }), // Browser shortcuts persist through SettingsProvider localStorage. The @@ -155,13 +213,13 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { setPollingInterval: async () => undefined, onUpdate: () => () => undefined, }, - transcriptsLoadAll: () => webRpc(sessionToken, 'transcripts.loadAll', {}), - transcriptsMtimes: () => webRpc(sessionToken, 'transcripts.mtimes', {}), - transcriptsSave: (scopeId, messages) => webRpc(sessionToken, 'transcripts.save', { scopeId, messages }), - transcriptsRemove: scopeId => webRpc(sessionToken, 'transcripts.remove', { scopeId }), - worktreeList: repoPath => webRpc(sessionToken, 'worktrees.list', { repoPath }), - worktreeCreate: (repoPath, branch, worktreePath, startPoint) => webRpc(sessionToken, 'worktrees.create', { repoPath, branch, worktreePath, startPoint }), - worktreeRemove: worktreePath => webRpc(sessionToken, 'worktrees.remove', { worktreePath }), + transcriptsLoadAll: () => rpc('transcripts.loadAll', {}), + transcriptsMtimes: () => rpc('transcripts.mtimes', {}), + transcriptsSave: (scopeId, messages) => rpc('transcripts.save', { scopeId, messages }), + transcriptsRemove: scopeId => rpc('transcripts.remove', { scopeId }), + worktreeList: repoPath => rpc('worktrees.list', { repoPath }), + worktreeCreate: (repoPath, branch, worktreePath, startPoint) => rpc('worktrees.create', { repoPath, branch, worktreePath, startPoint }), + worktreeRemove: worktreePath => rpc('worktrees.remove', { worktreePath }), attachmentsPick: async () => ({ canceled: true, filePaths: [] }), attachmentsImport: async (root, items) => { const rels: string[] = [] @@ -169,8 +227,13 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { const source = item.data instanceof ArrayBuffer ? new Uint8Array(item.data) : item.data const bytes = new Uint8Array(source.byteLength) bytes.set(source) + if (transport.uploadAttachment) { + rels.push(await transport.uploadAttachment(root, item.name, bytes.buffer)) + continue + } + if (!directSession) return { error: 'attachment upload is unavailable through this remote transport' } const response = await fetch(`/api/v1/attachments?root=${encodeURIComponent(root)}&name=${encodeURIComponent(item.name)}`, { - method: 'POST', headers: { authorization: `Bearer ${sessionToken}` }, body: bytes.buffer, + method: 'POST', headers: { authorization: `Bearer ${directSession}` }, body: bytes.buffer, }) const result = await response.json() as { rel?: string; error?: { message?: string } } if (!response.ok || !result.rel) return { error: result.error?.message ?? `attachment upload failed with ${response.status}` } @@ -181,49 +244,60 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { // There is no synchronous network transport. Start a keepalive request so // page teardown can still hand the final settled transcript to the server. transcriptsSaveSyncBatch: entries => { + if (transport.saveSyncBatch) return transport.saveSyncBatch(entries) + if (!directSession) return false const id = `web-teardown-${Date.now().toString(36)}` void fetch('/api/v1/rpc', { method: 'POST', keepalive: true, - headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + headers: { 'content-type': 'application/json', authorization: `Bearer ${directSession}` }, body: JSON.stringify({ protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id, method: 'transcripts.saveBatch', params: { entries } }), }).catch(() => undefined) return true }, - fsReadDir: (root, sub = '') => webRpc(sessionToken, 'fs.readDir', { root, sub }), - fsReadFile: (root, sub) => webRpc(sessionToken, 'fs.readFile', { root, sub }), - fsReadDataUrl: (root, sub) => webRpc(sessionToken, 'fs.readDataUrl', { root, sub }), - fsWriteFile: (root, sub, text) => webRpc(sessionToken, 'fs.writeFile', { root, sub, text }), - fsMkdir: (root, sub) => webRpc(sessionToken, 'fs.mkdir', { root, sub }), - fsDelete: (root, sub) => webRpc(sessionToken, 'fs.delete', { root, sub }), - fsRename: (root, sub, newName) => webRpc(sessionToken, 'fs.rename', { root, sub, newName }), - fsListFiles: root => webRpc(sessionToken, 'fs.listFiles', { root }), - gitStatus: cwd => webRpc(sessionToken, 'git.status', { cwd }), - gitStage: (cwd, paths) => webRpc(sessionToken, 'git.stage', { cwd, paths }), - gitStageAll: cwd => webRpc(sessionToken, 'git.stageAll', { cwd }), - gitUnstage: (cwd, paths) => webRpc(sessionToken, 'git.unstage', { cwd, paths }), - gitDiff: (cwd, path, staged) => webRpc(sessionToken, 'git.diff', { cwd, path, staged }), - gitLog: (cwd, limit = 20) => webRpc(sessionToken, 'git.log', { cwd, limit }), - gitBranches: cwd => webRpc(sessionToken, 'git.branches', { cwd }), - gitRemotes: cwd => webRpc(sessionToken, 'git.remotes', { cwd }), - gitCommit: (cwd, message, amend, noSign) => webRpc(sessionToken, 'git.commit', { cwd, message, amend, noSign }), - gitPush: cwd => webRpc(sessionToken, 'git.push', { cwd }), - gitPull: cwd => webRpc(sessionToken, 'git.pull', { cwd }), - gitFetch: cwd => webRpc(sessionToken, 'git.fetch', { cwd }), - gitCheckout: (cwd, branch) => webRpc(sessionToken, 'git.checkout', { cwd, branch }), - gitCreateBranch: (cwd, name) => webRpc(sessionToken, 'git.createBranch', { cwd, name }), - gitMerge: (cwd, ref) => webRpc(sessionToken, 'git.merge', { cwd, ref }), - gitMergeAbort: cwd => webRpc(sessionToken, 'git.mergeAbort', { cwd }), - gitMergeContinue: cwd => webRpc(sessionToken, 'git.mergeContinue', { cwd }), - gitResolveConflict: (cwd, file, strategy) => webRpc(sessionToken, 'git.resolveConflict', { cwd, file, strategy }), - gitInit: cwd => webRpc(sessionToken, 'git.init', { cwd }), - // GitHub/credential operations are intentionally unavailable remotely. Empty - // status values keep the shared Git surface functional without exposing auth. - githubStatus: async () => ({ error: 'GitHub integration is unavailable remotely' }), - ghStatus: async () => ({ available: false, loggedIn: false, user: null, host: null, raw: '', error: 'GitHub integration is unavailable remotely' }), - ptyCreate: opts => webRpc(sessionToken, 'pty.create', { ...opts }), - ptyWrite: (paneId, data) => { void webRpc(sessionToken, 'pty.write', { paneId, data }) }, - ptyResize: (paneId, cols, rows) => { void webRpc(sessionToken, 'pty.resize', { paneId, cols, rows }) }, - ptyKill: paneId => { void webRpc(sessionToken, 'pty.kill', { paneId }) }, + fsReadDir: (root, sub = '') => rpc('fs.readDir', { root, sub }), + fsReadFile: (root, sub) => rpc('fs.readFile', { root, sub }), + fsReadDataUrl: (root, sub) => rpc('fs.readDataUrl', { root, sub }), + fsWriteFile: (root, sub, text) => rpc('fs.writeFile', { root, sub, text }), + fsFormat: (root, sub, text) => rpc('fs.format', { root, sub, text }), + fsMkdir: (root, sub) => rpc('fs.mkdir', { root, sub }), + fsDelete: (root, sub) => rpc('fs.delete', { root, sub }), + fsRename: (root, sub, newName) => rpc('fs.rename', { root, sub, newName }), + fsListFiles: root => rpc('fs.listFiles', { root }), + gitStatus: cwd => rpc('git.status', { cwd }), + gitStage: (cwd, paths) => rpc('git.stage', { cwd, paths }), + gitStageAll: cwd => rpc('git.stageAll', { cwd }), + gitUnstage: (cwd, paths) => rpc('git.unstage', { cwd, paths }), + gitDiff: (cwd, path, staged) => rpc('git.diff', { cwd, path, staged }), + gitLog: (cwd, limit = 20) => rpc('git.log', { cwd, limit }), + gitBranches: cwd => rpc('git.branches', { cwd }), + gitRemotes: cwd => rpc('git.remotes', { cwd }), + gitCommit: (cwd, message, amend, noSign) => rpc('git.commit', { cwd, message, amend, noSign }), + gitPush: cwd => rpc('git.push', { cwd }), + gitPull: cwd => rpc('git.pull', { cwd }), + gitFetch: cwd => rpc('git.fetch', { cwd }), + gitCheckout: (cwd, branch) => rpc('git.checkout', { cwd, branch }), + gitCreateBranch: (cwd, name) => rpc('git.createBranch', { cwd, name }), + gitMerge: (cwd, ref) => rpc('git.merge', { cwd, ref }), + gitMergeAbort: cwd => rpc('git.mergeAbort', { cwd }), + gitMergeContinue: cwd => rpc('git.mergeContinue', { cwd }), + gitResolveConflict: (cwd, file, strategy) => rpc('git.resolveConflict', { cwd, file, strategy }), + gitInit: cwd => rpc('git.init', { cwd }), + // GitHub commands execute with the Brain's existing gh CLI identity. Browser + // clients never receive its token, and every repo operation remains confined + // to a registered workspace root. + githubStatus: repoPath => rpc('github.status', { cwd: repoPath }), + ghStatus: () => rpc('gh.status', {}), + ghPrCreate: cwd => rpc('gh.prCreate', { cwd }), + ghPrMerge: (cwd, number) => rpc('gh.prMerge', { cwd, number }), + ghPrApprove: (cwd, number) => rpc('gh.prApprove', { cwd, number }), + ghLoginStart: async () => ({ ok: false, error: 'Authenticate gh from a Brain terminal before using GitHub UI' }), + ghLoginCancel: async () => ({ ok: true }), + ghLogout: async () => ({ ok: false, error: 'Remote logout is disabled; manage gh credentials from the Brain' }), + ghRepoCreate: async () => ({ ok: false, output: '', error: 'Remote repository publishing is not enabled yet' }), + ptyCreate: opts => rpc('pty.create', { ...opts }), + ptyWrite: (paneId, data) => { void rpc('pty.write', { paneId, data }) }, + ptyResize: (paneId, cols, rows) => { void rpc('pty.resize', { paneId, cols, rows }) }, + ptyKill: paneId => { void rpc('pty.kill', { paneId }) }, onPtyData: callback => { dataListeners.add(callback) ensureEvents() @@ -240,19 +314,22 @@ export function createWebCrewCodeClient(sessionToken: string): CrewCodeClient { ensureEvents() return () => exitListeners.delete(callback) }, - bridgeStart: opts => webRpc(sessionToken, 'bridge.start', { + bridgeStart: opts => rpc('bridge.start', { bridgeId: opts.bridgeId, provider: opts.provider, cwd: opts.cwd, model: opts.model, mode: opts.mode, toolPolicy: opts.toolPolicy, thinking: opts.thinking, conversationScopeKey: opts.conversationScopeKey, freshSession: opts.freshSession, suppressProviderHistoryReplay: opts.suppressProviderHistoryReplay, + // Send references only. The Brain resolves these against its own registry + // and never trusts browser-supplied MCP command/env definitions. + mcpServerIds: opts.mcpServers?.map(server => server.id), }), - bridgePrompt: (bridgeId, text, options) => webRpc(sessionToken, 'bridge.prompt', { bridgeId, text, options }), - bridgeCompact: bridgeId => webRpc(sessionToken, 'bridge.compact', { bridgeId }), - bridgeRemoveFollowUp: (bridgeId, followUpId) => webRpc(sessionToken, 'bridge.removeFollowUp', { bridgeId, followUpId }), - bridgeRespondUserRequest: response => webRpc(sessionToken, 'bridge.respondUserRequest', { response }), - bridgeSetMode: (bridgeId, mode) => { void webRpc(sessionToken, 'bridge.setMode', { bridgeId, mode }) }, - bridgeAbort: bridgeId => { void webRpc(sessionToken, 'bridge.abort', { bridgeId }) }, - bridgeStop: bridgeId => { void webRpc(sessionToken, 'bridge.stop', { bridgeId }) }, + bridgePrompt: (bridgeId, text, options) => rpc('bridge.prompt', { bridgeId, text, options }), + bridgeCompact: bridgeId => rpc('bridge.compact', { bridgeId }), + bridgeRemoveFollowUp: (bridgeId, followUpId) => rpc('bridge.removeFollowUp', { bridgeId, followUpId }), + bridgeRespondUserRequest: response => rpc('bridge.respondUserRequest', { response }), + bridgeSetMode: (bridgeId, mode) => { void rpc('bridge.setMode', { bridgeId, mode }) }, + bridgeAbort: bridgeId => { void rpc('bridge.abort', { bridgeId }) }, + bridgeStop: bridgeId => { void rpc('bridge.stop', { bridgeId }) }, onBridgeEvent: callback => { bridgeListeners.add(callback) ensureEvents() diff --git a/src/renderer/src/stores/chat-messages-store.test.ts b/src/renderer/src/stores/chat-messages-store.test.ts index f76361b..c54943e 100644 --- a/src/renderer/src/stores/chat-messages-store.test.ts +++ b/src/renderer/src/stores/chat-messages-store.test.ts @@ -283,6 +283,25 @@ describe('chat-messages-store persistence', () => { }) }) + it('retries authoritative hydration after a browser runtime is installed', async () => { + vi.useRealTimers() + installLocalStorage() + installLifecycleGlobals() // App modules load before browser RPC is installed. + const { hydrateMessagesFromBackend, useMessagesStore } = await loadStore() + expect(useMessagesStore.getState().messagesByTab['remote-session']).toBeUndefined() + + const api = installElectronApi({ + 'remote-session': [{ kind: 'agent', blocks: [], text: 'finished while browser was away', time: '5:02 PM' }], + }) + ;(window as unknown as { electronAPI?: ElectronApiStub }).electronAPI = api + await hydrateMessagesFromBackend() + + expect(api.transcriptsLoadAll).toHaveBeenCalledTimes(1) + expect(useMessagesStore.getState().messagesByTab['remote-session']?.[0]).toMatchObject({ + text: 'finished while browser was away', + }) + }) + it('hydration never clobbers an in-memory scope that is already longer', async () => { vi.useRealTimers() installLocalStorage() diff --git a/src/renderer/src/stores/chat-messages-store.ts b/src/renderer/src/stores/chat-messages-store.ts index 77bd854..8814d25 100644 --- a/src/renderer/src/stores/chat-messages-store.ts +++ b/src/renderer/src/stores/chat-messages-store.ts @@ -368,7 +368,7 @@ useMessagesStore.subscribe((state, prev) => { // from localStorage; disk fills any scope L1 evicted and restores full history // for scopes L1 trimmed. Never clobber an in-memory scope that is already longer // (a turn that arrived — and is only in L1 — during this async load wins). -async function hydrateFromDisk(): Promise { +export async function hydrateMessagesFromBackend(): Promise { const api = transcriptApi() if (!api?.transcriptsLoadAll) return let disk: Record @@ -413,7 +413,7 @@ function flushAllOnTeardown(): void { if (typeof window !== 'undefined') { window.addEventListener('beforeunload', flushAllOnTeardown) window.addEventListener('pagehide', flushAllOnTeardown) - hydrateFromDisk() + void hydrateMessagesFromBackend() } if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { diff --git a/src/renderer/src/surface-ui-state.test.ts b/src/renderer/src/surface-ui-state.test.ts new file mode 100644 index 0000000..8fba0f4 --- /dev/null +++ b/src/renderer/src/surface-ui-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { isSurfaceOpen, setSurfaceOpen } from './surface-ui-state' + +describe('surface UI state', () => { + it('keeps an open drawer isolated to the chat that opened it', () => { + const state = setSurfaceOpen({}, 'chat-one', true) + + expect(isSurfaceOpen(state, 'chat-one')).toBe(true) + expect(isSurfaceOpen(state, 'chat-two')).toBe(false) + }) + + it('preserves independent drawer state while switching chats', () => { + const firstOpen = setSurfaceOpen({}, 'chat-one', true) + const bothOpen = setSurfaceOpen(firstOpen, 'chat-two', true) + const secondClosed = setSurfaceOpen(bothOpen, 'chat-two', false) + + expect(isSurfaceOpen(secondClosed, 'chat-one')).toBe(true) + expect(isSurfaceOpen(secondClosed, 'chat-two')).toBe(false) + }) +}) diff --git a/src/renderer/src/surface-ui-state.ts b/src/renderer/src/surface-ui-state.ts new file mode 100644 index 0000000..8520436 --- /dev/null +++ b/src/renderer/src/surface-ui-state.ts @@ -0,0 +1,14 @@ +export type SurfaceOpenState = Record + +export function isSurfaceOpen(state: SurfaceOpenState, surfaceId: string): boolean { + return state[surfaceId] ?? false +} + +export function setSurfaceOpen( + state: SurfaceOpenState, + surfaceId: string, + open: boolean, +): SurfaceOpenState { + if ((state[surfaceId] ?? false) === open) return state + return { ...state, [surfaceId]: open } +} diff --git a/src/renderer/src/surface-worktree-selection.test.ts b/src/renderer/src/surface-worktree-selection.test.ts new file mode 100644 index 0000000..6062186 --- /dev/null +++ b/src/renderer/src/surface-worktree-selection.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { resolveSelectedWorktree, worktreeSelectionKey } from './surface-worktree-selection' +import type { Worktree } from './types' + +const worktrees: Worktree[] = [ + { id: 'feature', path: '/repo-feature', branch: 'feature', head: 'abc', locked: false, dirty: 0 }, +] + +describe('surface worktree selection', () => { + it('isolates each chat session even when sessions share a tab', () => { + expect(worktreeSelectionKey('chat-tab', 'chat', 'thread-one')).toBe('chat:thread-one') + expect(worktreeSelectionKey('chat-tab', 'chat', 'thread-two')).toBe('chat:thread-two') + }) + + it('isolates chat sessions mounted in separate Workbench panes', () => { + expect(worktreeSelectionKey('canvas-pane-one', 'chat', 'canvas-pane-one')).toBe('chat:canvas-pane-one') + expect(worktreeSelectionKey('canvas-pane-two', 'chat', 'canvas-pane-two')).toBe('chat:canvas-pane-two') + }) + + it('isolates Git Workspace tabs by tab instance', () => { + expect(worktreeSelectionKey('workspace-git', 'git')).toBe('tab:workspace-git') + expect(worktreeSelectionKey('another-git', 'git')).toBe('tab:another-git') + }) + + it('defaults new or stale selections to the primary checkout', () => { + expect(resolveSelectedWorktree(undefined, worktrees)).toBeNull() + expect(resolveSelectedWorktree('removed', worktrees)).toBeNull() + expect(resolveSelectedWorktree('feature', worktrees)).toEqual(worktrees[0]) + }) +}) diff --git a/src/renderer/src/surface-worktree-selection.ts b/src/renderer/src/surface-worktree-selection.ts new file mode 100644 index 0000000..fc15d63 --- /dev/null +++ b/src/renderer/src/surface-worktree-selection.ts @@ -0,0 +1,25 @@ +import type { TabKind, Worktree } from './types' + +/** + * Chat threads own branch/worktree selection independently. Other surfaces, + * including Git Workspace tabs, own selection by tab instance. + */ +export function worktreeSelectionKey( + tabId: string, + tabKind: TabKind | undefined, + chatSessionId?: string | null, +): string { + if (!tabId) return '' + return tabKind === 'chat' && chatSessionId + ? `chat:${chatSessionId}` + : `tab:${tabId}` +} + +/** Missing/stale selections intentionally fall back to the primary checkout. */ +export function resolveSelectedWorktree( + selectedId: string | null | undefined, + worktrees: readonly Worktree[], +): Worktree | null { + if (!selectedId) return null + return worktrees.find(worktree => worktree.id === selectedId) ?? null +} diff --git a/src/shared/hub-relay-types.ts b/src/shared/hub-relay-types.ts new file mode 100644 index 0000000..3640d59 --- /dev/null +++ b/src/shared/hub-relay-types.ts @@ -0,0 +1,32 @@ +import type { CrewCodeRemoteRequest, CrewCodeRemoteResponse } from './remote-access-types' + +export const CREWCODE_HUB_RELAY_PROTOCOL = 'crewcode.hub-relay.v1' as const +export const HUB_CONNECTION_TICKET_TTL_MS = 60_000 +export const HUB_RELAY_MAX_FRAME_BYTES = 1024 * 1024 +export const HUB_RELAY_IDLE_TIMEOUT_MS = 30 * 60_000 +export const HUB_RELAY_ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60_000 + +export type BrainAccessScope = 'workspace:read' | 'workspace:write' | 'terminal' | 'agent' + +export interface HubConnectionTicketResponse { + ticket: string + expiresAt: number + machineId: string + machinePublicKey: string + requestedScopes: BrainAccessScope[] +} + +export type HubRelayControlFrame = + | { type: 'brainReady'; machineId: string } + | { type: 'connect'; connectionId: string; userId: string; browserSessionId: string; requestedScopes: BrainAccessScope[] } + | { type: 'ready'; connectionId: string; machineId: string; machinePublicKey: string; requestedScopes: BrainAccessScope[] } + | { type: 'clientHello'; connectionId: string; key: string } + | { type: 'serverHello'; connectionId: string; key: string; signature: string; grantedScopes: BrainAccessScope[] } + | { type: 'encrypted'; connectionId: string; sequence: number; ciphertext: string } + | { type: 'close'; connectionId: string; reason: string } + +export type HubTunnelPlaintext = + | { type: 'rpc'; request: CrewCodeRemoteRequest } + | { type: 'rpcResult'; response: CrewCodeRemoteResponse } + | { type: 'event'; channel: 'pty' | 'bridge'; event: unknown } + | { type: 'error'; code: string; message: string } diff --git a/src/shared/remote-access-types.ts b/src/shared/remote-access-types.ts index 52423ac..0de66b3 100644 --- a/src/shared/remote-access-types.ts +++ b/src/shared/remote-access-types.ts @@ -12,6 +12,11 @@ export interface CrewCodeServerCapabilities { git: boolean terminals: boolean agents: boolean + attachments?: boolean + mcp?: boolean + github?: boolean + voice?: boolean + editorFormat?: boolean } } From fb757193f4514e113054a389807a1dbdc7dd6ba5 Mon Sep 17 00:00:00 2001 From: OnPoint-Dev-Tools Date: Sun, 23 Aug 2026 23:16:23 -0400 Subject: [PATCH 06/10] feat: implement attachment upload functionality with chunked transfer and recovery - Added support for attachment uploads through the Brain workspace, allowing files to be uploaded in chunks. - Introduced new RPC methods: `attachments.begin`, `attachments.chunk`, and `attachments.finish` for managing uploads. - Implemented validation for attachment size and chunk sequence to ensure integrity during uploads. - Enhanced error handling for attachment uploads, including digest verification and cleanup on failure. - Updated tests to cover new attachment upload features and ensure proper functionality. - Modified the UI to support attachment uploads and display relevant messages. --- README.md | 3 + docs/security-model.md | 5 +- docs/web-remote-access.md | 15 ++- src/main/hub-brain-relay.ts | 92 ++++++++++--- src/main/hub-relay.test.ts | 64 +++++++-- src/main/remote-access-server.test.ts | 27 +++- src/main/remote-access-server.ts | 127 +++++++++++++++++- .../src/runtime/WebConnectionScreen.tsx | 22 +-- .../src/runtime/hub-relay-client.test.ts | 23 ++++ src/renderer/src/runtime/hub-relay-client.ts | 43 ++++++ .../runtime/recovered-agent-history.test.ts | 30 +++++ .../src/runtime/recovered-agent-history.ts | 37 +++++ src/renderer/src/runtime/web-rpc-client.ts | 34 ++--- src/renderer/src/styles/prompt-builder.css | 3 - 14 files changed, 457 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/runtime/recovered-agent-history.test.ts create mode 100644 src/renderer/src/runtime/recovered-agent-history.ts diff --git a/README.md b/README.md index 0ebba6c..cdddae6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ > Run, supervise, and review multiple AI coding agents across git worktrees without losing control of your repo. [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) +[![Website](https://img.shields.io/badge/Website-crewcode.logixhub.icu-0b7285.svg)](https://crewcode.logixhub.icu) +[![X](https://img.shields.io/badge/X-@OnPointTools-000000.svg)](https://x.com/OnPointTools) +[![YouTube](https://img.shields.io/badge/YouTube-@CjWisdom-FF0000.svg)](https://www.youtube.com/@CjWisdom)
diff --git a/docs/security-model.md b/docs/security-model.md index ee15abf..edc03f4 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -110,7 +110,10 @@ per user and 1,000 events / 1 MiB of detached evidence buffered per resource; interrupted RPCs are never replayed. Execution custody is still process-resident rather than crash durable: Brain process, VPS, revocation, or persistent Brain-to-Hub relay loss can stop execution without a -complete remote halt journal. Attachment tunneling is also not implemented. +complete remote halt journal. Attachments are tunneled as ordered chunks inside the +E2EE relay: Hub receives ciphertext only, while Brain enforces `workspace:write`, a +25 MiB file limit, registered-root and symlink containment, strict sequence and +size bounds, SHA-256 integrity, active-upload limits, and temporary-file cleanup. ## Hop 1 — untrusted content -> agent diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index 021fe29..e5bd0f4 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -401,11 +401,16 @@ discovery, including the existing SSH routing. Network filesystem RPC also rejects roots absent from the server workspace store, preventing a browser from substituting `/` or another arbitrary host path. `workspaceStore.ts` and `fs.ts` are now Electron transport adapters for those operations. Native folder pickers, -attachment handling, formatting, and destructive filesystem mutations remain in -the Electron adapter until their browser API and validation contracts are added. - -Hub-relayed attachment tunneling is not implemented. The browser can list the -Brain-owned MCP registry and select entries by opaque id; `bridge.start` resolves +formatting, and destructive filesystem mutations remain in the Electron adapter +until their browser API and validation contracts are added. + +Hub-relayed attachment tunneling uses ordered 256 KiB chunks inside the existing +browser-to-Brain encrypted RPC tunnel. The Hub sees only bounded ciphertext frames. +Brain requires `workspace:write`, restricts destinations to registered workspace +roots, rejects symlink escapes and files over 25 MiB, verifies a final SHA-256 +digest, and removes canceled, failed, idle, or shutdown-temporary uploads. The +browser can list the Brain-owned MCP registry and select entries by opaque id; +`bridge.start` resolves those ids server-side and never accepts executable MCP command or environment definitions from the browser. diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts index 6d25310..1cad91c 100644 --- a/src/main/hub-brain-relay.ts +++ b/src/main/hub-brain-relay.ts @@ -49,6 +49,7 @@ export interface RunningBrainRelay { export function brainScopeForMethod(method: string): BrainAccessScope | null { if (READ_METHODS.has(method)) return 'workspace:read' if (method.startsWith('pty.')) return 'terminal' + if (method.startsWith('attachments.')) return 'workspace:write' if (AGENT_METHOD_PREFIXES.some(prefix => method.startsWith(prefix))) return 'agent' if (WRITE_METHOD_PREFIXES.some(prefix => method.startsWith(prefix))) return 'workspace:write' return null @@ -71,6 +72,13 @@ function latestAssistantMessageIndex(messages: Array<{ role: string; content: st return -1 } +function precedingUserText(messages: Array<{ role: string; content: string }>, before: number): string | undefined { + for (let index = before - 1; index >= 0; index -= 1) { + if (messages[index]!.role === 'user') return messages[index]!.content + } + return undefined +} + function websocketOrigin(origin: string): string { const url = new URL(origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' @@ -101,7 +109,11 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise((resolve, reject) => { eventSocket.once('open', resolve); eventSocket.once('error', reject) }) - const relay = new WebSocket(websocketOrigin(options.credential.hubOrigin), ['crewcode.brain.v1', options.credential.token]) + // The Brain-local execution backend outlives every Hub/browser transport. + // `relay` is replaced on transient Hub disconnects without recreating the + // backend or stopping provider processes. + let relay!: WebSocket + let reconnectTimer: ReturnType | null = null const sessions = new Map() type ResourceOwner = { userId: string @@ -224,7 +236,7 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise { + const handleRelayMessage = async (raw: WebSocket.RawData): Promise => { let frame: HubRelayControlFrame try { frame = JSON.parse(raw.toString()) as HubRelayControlFrame } catch { relay.close(4002, 'invalid Hub relay frame'); return } if (frame.type === 'brainReady') { relayReadyResolve(); return } @@ -286,13 +298,17 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise owner.userId === session.userId).length const exceedsResourceLimit = createsResource && !existingOwner && ownedResourceCount >= MAX_OWNED_RESOURCES_PER_USER - const canAttach = existingOwner?.userId === session.userId - && (existingOwner.connectionId === null || existingOwner.connectionId === session.connectionId) - const wrongOwner = !!existingOwner && !canAttach - const missingOwner = !!ownerMap && !!resourceId && !createsResource && existingOwner?.connectionId !== session.connectionId + // Execution authority belongs to the authenticated Hub user on this Brain, + // not to one ephemeral browser websocket. connectionId is event-routing + // custody only: a same-owner command from a replacement page atomically + // attaches the resource to that page. Otherwise closing a tab turns a + // transport lifecycle into an execution lifecycle and strands live agents. + const ownsResource = existingOwner?.userId === session.userId + const wrongOwner = !!existingOwner && !ownsResource + const missingOwner = !!ownerMap && !!resourceId && !createsResource && !existingOwner const requestResourceId = responseRequestId ? requestOwners.get(responseRequestId) : undefined const requestOwner = requestResourceId ? bridgeOwners.get(requestResourceId) : undefined - const wrongRequestOwner = !!responseRequestId && requestOwner?.connectionId !== session.connectionId + const wrongRequestOwner = !!responseRequestId && requestOwner?.userId !== session.userId let response: CrewCodeRemoteResponse let replayResourceId = '' if (!scope || !session.grantedScopes.has(scope)) { @@ -327,7 +343,12 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise { - sessions.clear() - eventSocket.close() - void closeBackend().finally(closeResolve) - }) - await new Promise((resolve, reject) => { relay.once('open', resolve); relay.once('error', reject) }) + const connectRelay = (): Promise => { + const socket = new WebSocket(websocketOrigin(options.credential.hubOrigin), ['crewcode.brain.v1', options.credential.token]) + relay = socket + socket.on('message', raw => { void handleRelayMessage(raw) }) + // The close handler owns retry. Keep an error listener installed so a failed + // reconnect cannot become an uncaught EventEmitter error. + socket.on('error', () => undefined) + socket.on('close', () => { + if (relay !== socket) return + // Detach only browser routing. Provider execution and the loopback event + // socket stay alive while the persistent Brain reconnects to the Hub. + for (const connectionId of [...sessions.keys()]) releaseSession(connectionId) + if (closing) { + eventSocket.close() + void closeBackend().finally(closeResolve) + return + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null + void connectRelay().catch(() => undefined) + }, 5_000) + }) + return new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + } + await connectRelay() await relayReady return { @@ -481,7 +534,8 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise { expect(brainScopeForMethod('workspaces.list')).toBe('workspace:read') expect(brainScopeForMethod('fs.writeFile')).toBe('workspace:write') expect(brainScopeForMethod('pty.create')).toBe('terminal') + expect(brainScopeForMethod('attachments.chunk')).toBe('workspace:write') expect(brainScopeForMethod('bridge.prompt')).toBe('agent') expect(brainScopeForMethod('mcp.list')).toBe('agent') expect(brainScopeForMethod('voice.transcribe')).toBe('agent') @@ -334,7 +335,7 @@ describe('authenticated encrypted Hub relay', () => { }) it('detaches a terminal on browser loss and explicitly reclaims the live process', async () => { - const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'terminal']) + const { hub, machineId, machineToken, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'terminal']) const issue = async (): Promise => { const response = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, @@ -349,7 +350,19 @@ describe('authenticated encrypted Hub relay', () => { .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, pid: expect.any(Number) } } }) await first.close() + // A transient shared Hub relay replacement must not tear down Brain-local + // execution either. The Brain reconnects its transport while retaining the + // same backend and PTY/provider registry. + const replacement = new WebSocket(hub.url.replace(/^http/, 'ws') + '/api/v1/hub/relay', ['crewcode.brain.v1', machineToken]) + await new Promise((resolve, reject) => { replacement.once('open', resolve); replacement.once('error', reject) }) + await new Promise(resolve => { replacement.once('close', () => resolve()); replacement.close(1000, 'test transport replacement') }) + await new Promise(resolve => setTimeout(resolve, 5_250)) + const second = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) + // The durable Hub owner can operate its Brain-owned process immediately; + // explicit claim only controls eager event replay and is not authorization. + await expect(second.rpc({ protocolVersion: 1, id: 'write-before-custody-claim', method: 'pty.write', params: { paneId: 'durable-pane', data: 'echo still-alive\n' } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) await expect(second.rpc({ protocolVersion: 1, id: 'claim-custody-pty', method: 'pty.claim', params: { paneIds: ['durable-pane'] } })) .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { claimed: ['durable-pane'] } } }) await expect(second.rpc({ protocolVersion: 1, id: 'reattach-custody-pty', method: 'pty.create', params: { paneId: 'durable-pane', cwd: workspaceRoot, shell: '/bin/sh' } })) @@ -437,21 +450,21 @@ describe('authenticated encrypted Hub relay', () => { event: { type: 'text_delta', bridgeId, delta: 'finished while detached' }, }) expect(requestCount).toBe(1) + await expect(second.rpc({ protocolVersion: 1, id: 'durable-history-data', method: 'bridge.replayHistory', params: { bridgeId } })) + .resolves.toMatchObject({ + type: 'rpcResult', + response: { ok: true, result: { replayed: true, latestAssistant: { text: 'finished while detached', userText: 'complete later' } } }, + }) // Closing the superseded connection after handoff must not detach the new // owner or make its next operation fail ownership checks. await first.close() await expect(second.rpc({ protocolVersion: 1, id: 'compact-after-handoff', method: 'bridge.compact', params: { bridgeId } })) .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) - // Recovery must not depend exclusively on the page's eager claim pass. A - // restored renderer always reasserts its deterministic bridge.start before - // prompting; that start atomically transfers same-owner custody even while - // the superseded browser still appears attached. + // Recovery must not depend on eager claim or reissuing bridge.start. The + // agent belongs to the authenticated Hub owner, while browser connection + // ids only decide where subsequent live events are routed. const third = await openEncryptedSession({ hub, ticket: await issue(), machineId, publicKey }) - await expect(third.rpc({ - protocolVersion: 1, id: 'reattach-without-claim', method: 'bridge.start', - params: { bridgeId, provider: 'ollama', model: 'fake-model', cwd: workspaceRoot, conversationScopeKey: 'detached-chat' }, - })).resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true, attached: true } } }) await second.close() await expect(third.rpc({ protocolVersion: 1, id: 'prompt-after-page-return', method: 'bridge.prompt', params: { bridgeId, text: 'continue working' } })) .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { ok: true } } }) @@ -537,6 +550,37 @@ describe('authenticated encrypted Hub relay', () => { await second.close() }) + it('tunnels attachment chunks end-to-end into the Brain workspace', async () => { + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write']) + const ticketResponse = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:write'] }), + }) + const { ticket } = await ticketResponse.json() as { ticket: string } + const session = await openEncryptedSession({ hub, ticket, machineId, publicKey }) + await session.rpc({ protocolVersion: 1, id: 'add-upload-root', method: 'workspaces.add', params: { path: workspaceRoot } }) + const bytes = Buffer.from('private attachment bytes never visible to Hub') + const begun = await session.rpc({ + protocolVersion: 1, id: 'begin-upload', method: 'attachments.begin', + params: { root: workspaceRoot, name: '../private.txt', size: bytes.byteLength }, + }) + if (begun.type !== 'rpcResult' || !begun.response.ok) throw new Error(`attachment begin failed: ${JSON.stringify(begun)}`) + const uploadId = String((begun.response.result as { uploadId: string }).uploadId) + await expect(session.rpc({ + protocolVersion: 1, id: 'chunk-upload', method: 'attachments.chunk', + params: { uploadId, sequence: 0, data: bytes.toString('base64') }, + })).resolves.toMatchObject({ type: 'rpcResult', response: { ok: true } }) + const finished = await session.rpc({ + protocolVersion: 1, id: 'finish-upload', method: 'attachments.finish', + params: { uploadId, sha256: createHash('sha256').update(bytes).digest('hex') }, + }) + if (finished.type !== 'rpcResult' || !finished.response.ok) throw new Error(`attachment finish failed: ${JSON.stringify(finished)}`) + const rel = String((finished.response.result as { rel: string }).rel) + expect(rel).toMatch(/^\.crewcode\/attachments\//) + expect(readFileSync(join(workspaceRoot, rel), 'utf8')).toBe(bytes.toString()) + await session.close() + }) + it('executes a scoped read RPC and rejects ticket replay', async () => { const { hub, machineId, cookie, csrf, publicKey } = await fixture(['workspace:read']) const issue = () => fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { diff --git a/src/main/remote-access-server.test.ts b/src/main/remote-access-server.test.ts index b90de64..50ab295 100644 --- a/src/main/remote-access-server.test.ts +++ b/src/main/remote-access-server.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, mkdirSync, readFileSync, realpathSync } from 'fs' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'fs' +import { createHash } from 'crypto' import { join } from 'path' import { tmpdir as osTmpdir } from 'os' import { afterEach, describe, expect, it } from 'vitest' @@ -130,6 +131,30 @@ describe('remote access server', () => { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` }, body: 'no', }) expect(forbidden.status).toBe(403) + + const tunneled = Buffer.from('encrypted relay attachment') + const beginBody = await (await rpc('begin-tunnel', 'attachments.begin', { root, name: '../../tunneled.txt', size: tunneled.byteLength })).json() as { result: { uploadId: string } } + const uploadId = beginBody.result.uploadId + expect(await (await rpc('chunk-tunnel', 'attachments.chunk', { uploadId, sequence: 0, data: tunneled.toString('base64') })).json()) + .toMatchObject({ ok: true, result: { received: tunneled.byteLength } }) + const digest = createHash('sha256').update(tunneled).digest('hex') + const finished = await (await rpc('finish-tunnel', 'attachments.finish', { uploadId, sha256: digest })).json() as { result: { rel: string } } + expect(finished.result.rel).toMatch(/^\.crewcode\/attachments\//) + expect(finished.result.rel).toMatch(/tunneled\.txt$/) + expect(readFileSync(join(root, finished.result.rel), 'utf8')).toBe('encrypted relay attachment') + + if (process.platform !== 'win32') { + const symlinkRoot = mkdtempSync(join(tmpdir(), 'crewcode-rpc-attachment-link-')) + const outside = mkdtempSync(join(tmpdir(), 'crewcode-rpc-attachment-outside-')) + symlinkSync(outside, join(symlinkRoot, '.crewcode')) + await rpc('add-symlink-root', 'workspaces.add', { path: symlinkRoot }) + const escaped = await rpc('begin-symlink-escape', 'attachments.begin', { root: symlinkRoot, name: 'escape.txt', size: 1 }) + expect(escaped.status).toBe(403) + } + + const badBegin = await (await rpc('begin-bad-digest', 'attachments.begin', { root, name: 'bad.txt', size: 1 })).json() as { result: { uploadId: string } } + await rpc('chunk-bad-digest', 'attachments.chunk', { uploadId: badBegin.result.uploadId, sequence: 0, data: Buffer.from('x').toString('base64') }) + expect((await rpc('finish-bad-digest', 'attachments.finish', { uploadId: badBegin.result.uploadId, sha256: '0'.repeat(64) })).status).toBe(500) }) it('forbids agent startup outside registered workspaces', async () => { diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index 48fc480..d0a2f67 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -1,7 +1,8 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http' import { dirname, extname, join, normalize, posix, sep } from 'path' -import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'fs' +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'fs' import { spawnSync } from 'child_process' +import { createHash, randomBytes } from 'crypto' import { homedir } from 'os' import { CREWCODE_REMOTE_PROTOCOL_VERSION, @@ -35,6 +36,9 @@ import { WebSocketServer, WebSocket } from 'ws' const MAX_REQUEST_BYTES = 2 * 1024 * 1024 const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 +const MAX_ATTACHMENT_CHUNK_BYTES = 256 * 1024 +const MAX_ACTIVE_ATTACHMENT_UPLOADS = 8 +const ATTACHMENT_UPLOAD_IDLE_MS = 2 * 60_000 export interface RemoteAccessServerOptions { host?: string @@ -168,6 +172,21 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions } return resolved } + const attachmentDirectory = (root: string): string => { + const crewDirectory = join(root, '.crewcode') + if (!existsSync(crewDirectory)) mkdirSync(crewDirectory, { mode: 0o700 }) + const resolvedCrew = realpathSync(crewDirectory) + if (resolvedCrew !== root && !resolvedCrew.startsWith(root + sep)) { + throw Object.assign(new Error('attachment directory escapes workspace'), { remoteCode: 'FORBIDDEN' }) + } + const directory = join(resolvedCrew, 'attachments') + if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 }) + const resolved = realpathSync(directory) + if (resolved !== root && !resolved.startsWith(root + sep)) { + throw Object.assign(new Error('attachment directory escapes workspace'), { remoteCode: 'FORBIDDEN' }) + } + return resolved + } const validChildName = (value: unknown): string => { const name = String(value ?? '').trim() if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) throw new Error('invalid folder name') @@ -191,6 +210,27 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions if (result.status !== 0) return { error: 'not a git repo' } return { worktrees: parsePorcelainWorktrees(result.stdout ?? '', cwd) } } + type AttachmentUpload = { + fd: number + root: string + tempPath: string + finalPath: string + rel: string + size: number + received: number + nextSequence: number + touchedAt: number + hash: ReturnType + } + const attachmentUploads = new Map() + const discardAttachmentUpload = (uploadId: string): boolean => { + const upload = attachmentUploads.get(uploadId) + if (!upload) return false + attachmentUploads.delete(uploadId) + try { closeSync(upload.fd) } catch { /* already closed */ } + try { unlinkSync(upload.tempPath) } catch { /* already removed */ } + return true + } const handlers = new Map([ ['auth.sessions', () => auth.list()], ['auth.revoke', params => ({ revoked: auth.revoke(String(params.sessionId ?? '')) })], @@ -272,6 +312,80 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['fs.delete', params => filesystemService.delete(registeredRoot(params), String(params.sub ?? ''))], ['fs.rename', params => filesystemService.rename(registeredRoot(params), String(params.sub ?? ''), String(params.newName ?? ''))], ['fs.listFiles', params => filesystemService.listFiles(registeredRoot(params))], + ['attachments.begin', params => { + if (attachmentUploads.size >= MAX_ACTIVE_ATTACHMENT_UPLOADS) throw new Error('too many active attachment uploads') + const root = registeredRoot({ root: params.root }) + const size = Number(params.size) + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_ATTACHMENT_BYTES) throw new Error('invalid attachment size') + const originalName = String(params.name ?? '') + const safeName = originalName.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').replace(/^\.+/, '_').slice(0, 100) || 'file' + const directory = attachmentDirectory(root) + const uploadId = randomBytes(16).toString('hex') + const filename = `${Date.now().toString(36)}-${uploadId.slice(0, 8)}-${safeName}` + const finalPath = normalize(join(directory, filename)) + if (!finalPath.startsWith(normalize(directory) + sep)) throw new Error('attachment path escapes workspace') + const tempPath = `${finalPath}.${uploadId}.upload` + const fd = openSync(tempPath, 'wx', 0o600) + const rel = posix.join('.crewcode', 'attachments', filename) + attachmentUploads.set(uploadId, { + fd, root, tempPath, finalPath, rel, size, received: 0, nextSequence: 0, + touchedAt: Date.now(), hash: createHash('sha256'), + }) + return { uploadId, chunkBytes: MAX_ATTACHMENT_CHUNK_BYTES } + }], + ['attachments.chunk', params => { + const uploadId = String(params.uploadId ?? '') + const upload = attachmentUploads.get(uploadId) + if (!upload) throw new Error('attachment upload not found') + const sequence = Number(params.sequence) + if (!Number.isSafeInteger(sequence) || sequence !== upload.nextSequence) { + discardAttachmentUpload(uploadId) + throw new Error('attachment chunk sequence rejected') + } + const encoded = typeof params.data === 'string' ? params.data : '' + if (!encoded || encoded.length > Math.ceil(MAX_ATTACHMENT_CHUNK_BYTES * 4 / 3) + 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + discardAttachmentUpload(uploadId) + throw new Error('attachment chunk rejected') + } + const bytes = Buffer.from(encoded, 'base64') + if (bytes.byteLength === 0 || bytes.byteLength > MAX_ATTACHMENT_CHUNK_BYTES || upload.received + bytes.byteLength > upload.size) { + discardAttachmentUpload(uploadId) + throw new Error('attachment chunk size rejected') + } + let written = 0 + while (written < bytes.byteLength) written += writeSync(upload.fd, bytes, written, bytes.byteLength - written) + upload.hash.update(bytes) + upload.received += bytes.byteLength + upload.nextSequence += 1 + upload.touchedAt = Date.now() + return { received: upload.received } + }], + ['attachments.finish', params => { + const uploadId = String(params.uploadId ?? '') + const upload = attachmentUploads.get(uploadId) + if (!upload) throw new Error('attachment upload not found') + if (upload.received !== upload.size) { + discardAttachmentUpload(uploadId) + throw new Error('attachment upload is incomplete') + } + const digest = upload.hash.digest('hex') + const expectedDigest = typeof params.sha256 === 'string' ? params.sha256.toLowerCase() : '' + if (!/^[a-f0-9]{64}$/.test(expectedDigest) || digest !== expectedDigest) { + discardAttachmentUpload(uploadId) + throw new Error('attachment digest rejected') + } + attachmentUploads.delete(uploadId) + try { + closeSync(upload.fd) + renameSync(upload.tempPath, upload.finalPath) + } catch (error) { + try { closeSync(upload.fd) } catch { /* already closed */ } + try { unlinkSync(upload.tempPath) } catch { /* already removed */ } + throw error + } + return { rel: upload.rel, size: upload.size, sha256: digest } + }], + ['attachments.cancel', params => ({ canceled: discardAttachmentUpload(String(params.uploadId ?? '')) })], ['git.status', params => gitService.status(registeredRoot({ root: params.cwd }))], ['git.stage', params => gitService.stage(registeredRoot({ root: params.cwd }), Array.isArray(params.paths) ? params.paths.map(String) : [])], ['git.stageAll', params => gitService.stageAll(registeredRoot({ root: params.cwd }))], @@ -376,8 +490,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const originalName = url.searchParams.get('name') ?? '' const safeName = originalName.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').replace(/^\.+/, '_').slice(0, 100) || 'file' const data = await readBody(request, MAX_ATTACHMENT_BYTES) - const directory = join(root, '.crewcode', 'attachments') - mkdirSync(directory, { recursive: true }) + const directory = attachmentDirectory(root) const filename = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}` const target = normalize(join(directory, filename)) if (!target.startsWith(normalize(directory) + sep)) throw new Error('attachment path escapes workspace') @@ -428,6 +541,12 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions } }) + const attachmentSweep = setInterval(() => { + const cutoff = Date.now() - ATTACHMENT_UPLOAD_IDLE_MS + for (const [uploadId, upload] of attachmentUploads) if (upload.touchedAt < cutoff) discardAttachmentUpload(uploadId) + }, 30_000) + attachmentSweep.unref() + const sockets = new Set() const websocketServer = new WebSocketServer({ noServer: true, handleProtocols: protocols => protocols.has('crewcode.v1') ? 'crewcode.v1' : false }) server.on('upgrade', (request, socket, head) => { @@ -469,6 +588,8 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions pairingToken: pairing.token, pairingUrl: `${url}/pair#token=${encodeURIComponent(pairing.token)}`, close: () => new Promise((resolve, reject) => { + clearInterval(attachmentSweep) + for (const uploadId of [...attachmentUploads.keys()]) discardAttachmentUpload(uploadId) unsubscribePty() unsubscribeAgent() ptyService.killAll() diff --git a/src/renderer/src/runtime/WebConnectionScreen.tsx b/src/renderer/src/runtime/WebConnectionScreen.tsx index 73fc175..0b13e96 100644 --- a/src/renderer/src/runtime/WebConnectionScreen.tsx +++ b/src/renderer/src/runtime/WebConnectionScreen.tsx @@ -5,6 +5,7 @@ import { SettingsProvider } from '../hooks/useSettings' import { NotificationsProvider } from '../hooks/useNotifications' import { hydrateMessagesFromBackend } from '../stores/chat-messages-store' import { installCrewCodeRuntime } from './crewcode-client' +import { restoreRecoveredAssistant, type RecoveredAssistant } from './recovered-agent-history' import { clearClaimedWebBridgeRoutes, markClaimedWebBridgeRoutes, rememberWebBridgeRoutes, webBridgeRoutes } from './web-bridge-routes' import { connectHubRelayTransport, @@ -85,13 +86,15 @@ export function WebConnectionScreen() { // left the Brain-local conversation shard intact. for (const execution of executions) { if (execution.status !== 'completed' || !execution.conversationScopeKey) continue - await connectedRelay.transport.rpc('bridge.replayHistory', { bridgeId: execution.bridgeId }) + const recovered = await connectedRelay.transport.rpc<{ latestAssistant: RecoveredAssistant | null }>('bridge.replayHistory', { bridgeId: execution.bridgeId }) + restoreRecoveredAssistant(execution.conversationScopeKey, execution.bridgeId, recovered.latestAssistant) } for (const route of webBridgeRoutes()) { - await connectedRelay.transport.rpc('bridge.recoverHistory', { + const recovered = await connectedRelay.transport.rpc<{ latestAssistant: RecoveredAssistant | null }>('bridge.recoverHistory', { bridgeId: route.bridgeId, conversationScopeKey: route.tabId, }) + restoreRecoveredAssistant(route.tabId, route.bridgeId, recovered.latestAssistant) } } if (!cancelled) setBrainExecutions(executions) @@ -103,18 +106,15 @@ export function WebConnectionScreen() { }) executionPoll = setInterval(() => { void refreshExecutions() }, 10_000) setRelay(connectedRelay) - // Discover routes and reclaim detached executions before App mounts. - // The managed transport buffers their replay until the bridge event - // subscriber is installed, so a completed reply survives page reload. - await refreshExecutions(true) - initialRelayRefreshComplete = true const client = createWebCrewCodeClient(connectedRelay.transport) - await client.workspacesList() installCrewCodeRuntime({ kind: 'web', client }) - // App and its message store are statically imported before the web - // runtime exists. Retry the desktop-style authoritative transcript - // hydration now that encrypted Brain RPC is available. + // Hydrate the authoritative browser transcript first, then merge any + // reply that completed in Brain custody while the page was absent. + // Doing this in the opposite order lets hydration overwrite recovery. await hydrateMessagesFromBackend() + await refreshExecutions(true) + initialRelayRefreshComplete = true + await client.workspacesList() setStatus(`Connected with Brain-local scopes: ${connectedRelay.grantedScopes.join(', ') || 'none'}`) setConnected(true) return diff --git a/src/renderer/src/runtime/hub-relay-client.test.ts b/src/renderer/src/runtime/hub-relay-client.test.ts index 97a5707..a80216f 100644 --- a/src/renderer/src/runtime/hub-relay-client.test.ts +++ b/src/renderer/src/runtime/hub-relay-client.test.ts @@ -40,6 +40,29 @@ async function settle(): Promise { } describe('managed Hub relay transport', () => { + it('uploads attachments as ordered bounded chunks through relay RPC', async () => { + const first = connection('first', ['workspace:write']) + first.rpc.mockImplementation(async (method, params) => { + if (method === 'attachments.begin') return { uploadId: 'upload-1', chunkBytes: 3 } + if (method === 'attachments.finish') return { rel: '.crewcode/attachments/file.txt' } + if (method === 'attachments.chunk') return { received: (Number((params as { sequence: number }).sequence) + 1) * 3 } + return `first:${method}` + }) + const managed = await connectHubRelayTransport('machine', ['workspace:write'], { open: vi.fn().mockResolvedValue(first.value) }) + + await expect(managed.transport.uploadAttachment?.('/workspace', '../file.txt', new TextEncoder().encode('abcdefg').buffer)) + .resolves.toBe('.crewcode/attachments/file.txt') + expect(first.rpc.mock.calls.filter(([method]) => method === 'attachments.chunk').map(([, params]) => params)) + .toEqual([ + { uploadId: 'upload-1', sequence: 0, data: 'YWJj' }, + { uploadId: 'upload-1', sequence: 1, data: 'ZGVm' }, + { uploadId: 'upload-1', sequence: 2, data: 'Zw==' }, + ]) + expect(first.rpc).toHaveBeenCalledWith('attachments.finish', { + uploadId: 'upload-1', sha256: '7d1a54127b222502f5b79b5fb0803061152a44f92b37e23c6527baf665d4da9a', + }) + }) + it('requires explicit fresh-ticket reconnection and never queues disconnected RPC', async () => { const first = connection('first') const second = connection('second', ['workspace:read', 'terminal']) diff --git a/src/renderer/src/runtime/hub-relay-client.ts b/src/renderer/src/runtime/hub-relay-client.ts index 196a075..f60734c 100644 --- a/src/renderer/src/runtime/hub-relay-client.ts +++ b/src/renderer/src/runtime/hub-relay-client.ts @@ -31,6 +31,15 @@ function decodeBase64Url(value: string): Uint8Array { return result } +function encodeBase64(value: Uint8Array): string { + let raw = '' + const size = 32 * 1024 + for (let offset = 0; offset < value.byteLength; offset += size) { + raw += String.fromCharCode(...value.subarray(offset, Math.min(value.byteLength, offset + size))) + } + return btoa(raw) +} + function encodeBase64Url(value: ArrayBuffer | Uint8Array): string { const bytes = value instanceof Uint8Array ? value : new Uint8Array(value) let raw = '' @@ -336,6 +345,40 @@ export async function connectHubRelayTransport( } return result }, + async uploadAttachment(root, name, body) { + if (!active) throw new WebRpcError('Hub relay is disconnected; reconnect before uploading', 'UNAUTHENTICATED') + const connection = active + const bytes = new Uint8Array(body) + const begun = await connection.transport.rpc<{ uploadId: string; chunkBytes: number }>('attachments.begin', { + root, name, size: bytes.byteLength, + }) + const chunkBytes = Math.min(256 * 1024, Number(begun.chunkBytes)) + if (!begun.uploadId || !Number.isSafeInteger(chunkBytes) || chunkBytes < 1) throw new Error('Brain returned invalid attachment upload parameters') + try { + let sequence = 0 + for (let offset = 0; offset < bytes.byteLength; offset += chunkBytes) { + // Await each acknowledgement for natural end-to-end backpressure and + // strict ordering; no plaintext attachment bytes are visible to Hub. + await connection.transport.rpc('attachments.chunk', { + uploadId: begun.uploadId, + sequence: sequence++, + data: encodeBase64(bytes.subarray(offset, Math.min(bytes.byteLength, offset + chunkBytes))), + }) + } + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', body)) + const finished = await connection.transport.rpc<{ rel: string }>('attachments.finish', { + uploadId: begun.uploadId, + sha256: [...digest].map(byte => byte.toString(16).padStart(2, '0')).join(''), + }) + if (!finished.rel) throw new Error('Brain did not return an attachment path') + return finished.rel + } catch (error) { + // Best effort: disconnect cleanup and Brain's idle sweep cover cases in + // which the encrypted cancellation itself cannot arrive. + await connection.transport.rpc('attachments.cancel', { uploadId: begun.uploadId }).catch(() => undefined) + throw error + } + }, subscribe(listener) { eventListeners.add(listener) if (pendingEvents.length) { diff --git a/src/renderer/src/runtime/recovered-agent-history.test.ts b/src/renderer/src/runtime/recovered-agent-history.test.ts new file mode 100644 index 0000000..322f36f --- /dev/null +++ b/src/renderer/src/runtime/recovered-agent-history.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { Message } from '../types' +import { useMessagesStore } from '../stores/chat-messages-store' +import { restoreRecoveredAssistant } from './recovered-agent-history' + +const scope = 'workspace-chat-session' +const prompt: Message = { kind: 'user', text: 'create the folder', time: '8:45 PM' } + +beforeEach(() => { + useMessagesStore.setState({ messagesByTab: { [scope]: [prompt] } }) +}) + +describe('detached agent history recovery', () => { + it('adds the Brain reply missing after the matching persisted browser prompt', () => { + restoreRecoveredAssistant(scope, 'bridge', { index: 3, userText: 'create the folder', text: 'Created the requested folder.' }) + + expect(useMessagesStore.getState().messagesByTab[scope]).toEqual([ + prompt, + expect.objectContaining({ kind: 'agent', text: 'Created the requested folder.', streaming: false }), + ]) + }) + + it('does not duplicate a reply already restored by hydration or relay replay', () => { + const recovered = { index: 3, userText: 'create the folder', text: 'Created the requested folder.' } + restoreRecoveredAssistant(scope, 'bridge', recovered) + restoreRecoveredAssistant(scope, 'bridge', recovered) + + expect(useMessagesStore.getState().messagesByTab[scope].filter(message => message.kind === 'agent')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/runtime/recovered-agent-history.ts b/src/renderer/src/runtime/recovered-agent-history.ts new file mode 100644 index 0000000..e0a380a --- /dev/null +++ b/src/renderer/src/runtime/recovered-agent-history.ts @@ -0,0 +1,37 @@ +import { useMessagesStore } from '../stores/chat-messages-store' + +export interface RecoveredAssistant { + index: number + text: string + userText?: string +} + +/** Merge a Brain-local completed reply after the browser transcript has hydrated. */ +export function restoreRecoveredAssistant(scopeId: string, bridgeId: string, recovered: RecoveredAssistant | null): void { + if (!recovered?.text.trim()) return + useMessagesStore.getState().setMessagesForTab(scopeId, messages => { + let userIndex = -1 + if (recovered.userText !== undefined) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]! + if (message.kind === 'user' && message.text === recovered.userText) { userIndex = index; break } + } + } + // A hydrated or previously recovered copy after the matching prompt wins. + // Do not duplicate it when replayHistory and recoverHistory both report the + // same Brain-local conversation. + const alreadyPresent = messages.some((message, index) => index > userIndex + && message.kind === 'agent' && message.text === recovered.text) + if (alreadyPresent) return messages + return [...messages, { + kind: 'agent', + time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), + blocks: [], + text: recovered.text, + chunks: [recovered.text], + turnId: `recovered-${bridgeId}-${recovered.index}`, + processId: `recovered-${bridgeId}-${recovered.index}-agent-history`, + streaming: false, + }] + }) +} diff --git a/src/renderer/src/runtime/web-rpc-client.ts b/src/renderer/src/runtime/web-rpc-client.ts index a65adaa..7989913 100644 --- a/src/renderer/src/runtime/web-rpc-client.ts +++ b/src/renderer/src/runtime/web-rpc-client.ts @@ -223,23 +223,27 @@ export function createWebCrewCodeClient(sessionOrTransport: string | WebClientTr attachmentsPick: async () => ({ canceled: true, filePaths: [] }), attachmentsImport: async (root, items) => { const rels: string[] = [] - for (const item of items) { - const source = item.data instanceof ArrayBuffer ? new Uint8Array(item.data) : item.data - const bytes = new Uint8Array(source.byteLength) - bytes.set(source) - if (transport.uploadAttachment) { - rels.push(await transport.uploadAttachment(root, item.name, bytes.buffer)) - continue + try { + for (const item of items) { + const source = item.data instanceof ArrayBuffer ? new Uint8Array(item.data) : item.data + const bytes = new Uint8Array(source.byteLength) + bytes.set(source) + if (transport.uploadAttachment) { + rels.push(await transport.uploadAttachment(root, item.name, bytes.buffer)) + continue + } + if (!directSession) return { error: 'attachment upload is unavailable through this remote transport' } + const response = await fetch(`/api/v1/attachments?root=${encodeURIComponent(root)}&name=${encodeURIComponent(item.name)}`, { + method: 'POST', headers: { authorization: `Bearer ${directSession}` }, body: bytes.buffer, + }) + const result = await response.json() as { rel?: string; error?: { message?: string } } + if (!response.ok || !result.rel) return { error: result.error?.message ?? `attachment upload failed with ${response.status}` } + rels.push(result.rel) } - if (!directSession) return { error: 'attachment upload is unavailable through this remote transport' } - const response = await fetch(`/api/v1/attachments?root=${encodeURIComponent(root)}&name=${encodeURIComponent(item.name)}`, { - method: 'POST', headers: { authorization: `Bearer ${directSession}` }, body: bytes.buffer, - }) - const result = await response.json() as { rel?: string; error?: { message?: string } } - if (!response.ok || !result.rel) return { error: result.error?.message ?? `attachment upload failed with ${response.status}` } - rels.push(result.rel) + return { rels } + } catch (error) { + return { error: (error as Error).message || 'attachment upload failed' } } - return { rels } }, // There is no synchronous network transport. Start a keepalive request so // page teardown can still hand the final settled transcript to the server. diff --git a/src/renderer/src/styles/prompt-builder.css b/src/renderer/src/styles/prompt-builder.css index 3d93e0f..6287d95 100644 --- a/src/renderer/src/styles/prompt-builder.css +++ b/src/renderer/src/styles/prompt-builder.css @@ -872,9 +872,6 @@ display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 6px 10px; - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - background: #0c0f0c; font-family: var(--font-family-mono); font-size: 10.5px; color: var(--muted-foreground); } From 2a8f6cdeb59a6c83b8a2968d5f9254ecda0d22b5 Mon Sep 17 00:00:00 2001 From: OnPoint-Dev-Tools Date: Sun, 23 Aug 2026 23:07:58 -0400 Subject: [PATCH 07/10] feat: Mobile version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MobileShell (src/renderer/src/components/ui/MobileShell.tsx): numeric z-index (var() invalid for zIndex), stale closure in useMobileShell, proper drag reset, a11y, single-active-sheet logic. Responsive CSS (styles.css bottom): app → 100dvh, no border on mobile, drawer becomes bottom sheet, terminal hidden on mobile (sheet-only), grid/composer/thread adapt, sheet keyframes. PWA (src/renderer/index.html + src/renderer/public/ + public/): viewport-fit=cover, theme-color, manifest, 192/512 icons (from build/icons/512), sw.js cache-first + SW registration in main.tsx (browser only) — verified out/renderer/manifest.json & icons present. --- public/icons/icon-192.png | Bin 0 -> 24505 bytes public/icons/icon-512.png | Bin 0 -> 89183 bytes public/manifest.json | 14 + public/sw.js | 18 + src/renderer/index.html | 7 +- src/renderer/public/icons/icon-192.png | Bin 0 -> 24505 bytes src/renderer/public/icons/icon-512.png | Bin 0 -> 89183 bytes src/renderer/public/manifest.json | 14 + src/renderer/public/sw.js | 18 + src/renderer/src/App.tsx | 422 ++++++++++++++---- .../src/components/ui/MobileShell.tsx | 292 ++++++++++++ src/renderer/src/main.tsx | 6 + src/renderer/src/styles/colors_and_type.css | 125 ++++++ src/renderer/src/styles/styles.css | 36 ++ 14 files changed, 873 insertions(+), 79 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 src/renderer/public/icons/icon-192.png create mode 100644 src/renderer/public/icons/icon-512.png create mode 100644 src/renderer/public/manifest.json create mode 100644 src/renderer/public/sw.js create mode 100644 src/renderer/src/components/ui/MobileShell.tsx diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..93b8f095dab677b1874acb9b81834d680b2dff00 GIT binary patch literal 24505 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

Ct_6jf$NMy=l)%APIYyhy8g31n16Z! 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 { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)).then(() => self.skipWaiting())) +}) +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())) +}) +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return + const url = new URL(e.request.url) + if (url.origin !== location.origin) return + if (e.request.headers.get('accept')?.includes('text/html')) { + e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(CACHE).then(cache => cache.put(e.request, c)); return r }).catch(() => caches.match(e.request).then(r => r || caches.match('/')))) + return + } + e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(r => { if (r.ok) { const c = r.clone(); caches.open(CACHE).then(cache => cache.put(e.request, c)) } return r }))) +}) diff --git a/src/renderer/index.html b/src/renderer/index.html index c74f291..35776e0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,12 @@ - + + + + + + CrewCode diff --git a/src/renderer/public/icons/icon-192.png b/src/renderer/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..93b8f095dab677b1874acb9b81834d680b2dff00 GIT binary patch literal 24505 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

Ct_6jf$NMy=l)%APIYyhy8g31n16Z! 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 { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)).then(() => self.skipWaiting())) +}) +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())) +}) +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return + const url = new URL(e.request.url) + if (url.origin !== location.origin) return + if (e.request.headers.get('accept')?.includes('text/html')) { + e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(CACHE).then(cache => cache.put(e.request, c)); return r }).catch(() => caches.match(e.request).then(r => r || caches.match('/')))) + return + } + e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(r => { if (r.ok) { const c = r.clone(); caches.open(CACHE).then(cache => cache.put(e.request, c)) } return r }))) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8ad532d..a48ce0c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -38,6 +38,7 @@ import { chatSessionOwnerWorkspaceId } from './hooks/chat-session-tab-owner' import type { Prompt as PromptDef, Skill as SkillDef } from './types/prompts' import { Icon } from './components/ui/Icon' import { LoadingScreen } from './components/ui/LoadingScreen' +import { MobileShell, useMobileShell, type MobileTab } from './components/ui/MobileShell' import type { AgentActivityState } from './components/ui/AgentActivityIndicator' import { Onboarding } from './components/onboarding/Onboarding' import { NotificationBar } from './components/ui/NotificationBar' @@ -325,6 +326,9 @@ export default function App() { return { ...prev, [tabId]: value } }) }, [setGitWidthByTab]) + // ── Mobile shell ────────────────────────────────────────────────────────── + const mobile = useMobileShell() + // ── Tabs per workspace ─────────────────────────────────────────────────── const { tabs, activeTab, activeTabId, setActiveTabId, setActiveTabInWorkspace, getActiveTabIdForWorkspace, selectWorkspace, openTab: handleNewTab, openTabInWorkspace, openPluginTab, restoreChatTabInWorkspace, closeTab, closeTabInWorkspace, @@ -2987,6 +2991,263 @@ export default function App() { ) : null + // Mobile sheet content components + const WorkspacesContent = () => ( +

+
+ +
+
+ {ws.workspaces.map(workspace => ( + + ))} +
+
+
Quick Actions
+ +
+
+ ) + + const GitSheetContent = () => ( +
+ {hasWs && activeWorkspace ? ( +
+
+
{activeWorkspace.name}
+
{effectivePath}
+
Branch: {effectiveBranch}
+
+
+ + + + + +
+
+ ) : ( +
+ No workspace selected +
+ )} +
+ ) + + const TerminalSheetContent = () => ( +
+ {hasWs ? ( + <> + pty.addShell(activeWs, activeTabId, effectivePath, effectiveShell(settings))} + onAddAgent={(agentId) => { + const a = agents.find(x => x.id === agentId) + return a ? pty.addAgent(activeWs, activeTabId, a.id, a.name, effectivePath, a.path) : undefined + }} + onAddSsh={(target) => pty.addSsh(activeWs, activeTabId, target, effectivePath)} + sshTargets={sshTargets} + layout={pty.getTabLayout(activeTabId)} + onLayoutChange={(layout) => pty.setTabLayout(activeTabId, layout)} + onOpenUrl={openBrowserUrl} + pluginTerminalWatchers={pluginTerminalWatchers} + onPluginTerminalWatcher={(target, paneId) => runPluginActionTarget(target, { source: 'terminal-watcher', terminalPaneId: paneId })} + /> + + ) : ( +
+ No workspace selected +
+ )} +
+ ) + + const MoreSheetContent = () => ( +
+
+

Settings

+ + + + + +
+
+

Actions

+ + + +
+
+ ) + return ( -
- {loadingScreenMounted && } - - {!settings.onboardingCompleted && !showLoadingScreen && ( - setSetting('defaultAgent', id)} - hasProjects={ws.workspaces.length > 0} - onAddProject={() => setAddOpen(true)} - onFinish={() => setSetting('onboardingCompleted', true)} + + }, + git: { + open: mobile.sheets.git?.open ?? false, + title: 'Git', + content: + }, + terminal: { + open: mobile.sheets.terminal?.open ?? false, + title: 'Terminal', + content: + }, + more: { + open: mobile.sheets.more?.open ?? false, + title: 'More', + content: + }, + }} + onSheetToggle={mobile.onSheetToggle} + > +
+ {loadingScreenMounted && } + + {!settings.onboardingCompleted && !showLoadingScreen && ( + setSetting('defaultAgent', id)} + hasProjects={ws.workspaces.length > 0} + onAddProject={() => setAddOpen(true)} + onFinish={() => setSetting('onboardingCompleted', true)} + /> + )} + + + resolveGitAuth(null)} + /> + resolveSigningPassphrase(null)} + /> + - )} - - runPluginActionTarget(item.target, { source: 'plugin-menu' })} - /> - - - resolveGitAuth(null)} - /> - resolveSigningPassphrase(null)} - /> - - setMenuletOpen(o => !o)} - onClose={() => setMenuletOpen(false)} - onOpenHub={openMissionControl} - onOpenAgent={handleMcOpen} - onPauseAgent={handleMcPause} - onResumeAgent={handleMcResume} - onSpawnAgent={handleMcSpawn} - onRespondRequest={bridges.respondUserRequest} - /> + setMenuletOpen(o => !o)} + onClose={() => setMenuletOpen(false)} + onOpenHub={openMissionControl} + onOpenAgent={handleMcOpen} + onPauseAgent={handleMcPause} + onResumeAgent={handleMcResume} + onSpawnAgent={handleMcSpawn} + onRespondRequest={bridges.respondUserRequest} + /> - + -
+
{tweaks.drawerPosition === 'left' && workspacesPanel}
@@ -3298,8 +3563,9 @@ export default function App() {
)} - -
+ +
+ ) } diff --git a/src/renderer/src/components/ui/MobileShell.tsx b/src/renderer/src/components/ui/MobileShell.tsx new file mode 100644 index 0000000..2155cb9 --- /dev/null +++ b/src/renderer/src/components/ui/MobileShell.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect, useRef, useCallback, Fragment } from 'react' +import { Icon, type IconName } from './Icon' + +export type MobileTab = 'chat' | 'terminal' | 'editor' | 'git' | 'more' + +interface SheetProps { + id: string + title: string + children: React.ReactNode + open: boolean + onClose: () => void + maxHeight?: string +} + +function Sheet({ title, children, open, onClose, maxHeight = 'calc(100vh - 120px)' }: SheetProps) { + const handleRef = useRef(null) + const contentRef = useRef(null) + const [dragging, setDragging] = useState(false) + const startYRef = useRef(0) + const currentYRef = useRef(0) + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + } + }, [open, onClose]) + + useEffect(() => { + if (!open && contentRef.current) contentRef.current.style.transform = '' + }, [open]) + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (e.target !== handleRef.current && !handleRef.current?.contains(e.target as Node)) return + setDragging(true) + startYRef.current = e.touches[0].clientY + currentYRef.current = 0 + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!dragging) return + currentYRef.current = e.touches[0].clientY - startYRef.current + if (currentYRef.current > 0 && contentRef.current) { + contentRef.current.style.transform = `translateY(${currentYRef.current}px)` + } + }, [dragging]) + + const handleTouchEnd = useCallback(() => { + if (!dragging) return + setDragging(false) + if (currentYRef.current > 100) onClose() + else if (contentRef.current) contentRef.current.style.transform = '' + }, [dragging, onClose]) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.target !== handleRef.current && !handleRef.current?.contains(e.target as Node)) return + setDragging(true) + startYRef.current = e.clientY + currentYRef.current = 0 + const move = (me: MouseEvent) => { + currentYRef.current = me.clientY - startYRef.current + if (currentYRef.current > 0 && contentRef.current) { + contentRef.current.style.transform = `translateY(${currentYRef.current}px)` + } + } + const up = () => { + document.removeEventListener('mousemove', move) + document.removeEventListener('mouseup', up) + setDragging(false) + if (currentYRef.current > 100) onClose() + else if (contentRef.current) contentRef.current.style.transform = '' + } + document.addEventListener('mousemove', move) + document.addEventListener('mouseup', up) + }, [onClose]) + + if (!open) return null + + return ( + +
+
+
+
+
+
+

{title}

+ +
+
+ {children} +
+
+ + ) +} + +interface BottomNavProps { + activeTab: MobileTab + onTabChange: (tab: MobileTab) => void + unreadCounts?: Record + sheets: Record + onSheetToggle: (id: string) => void +} + +function BottomNav({ activeTab, onTabChange, unreadCounts, sheets, onSheetToggle }: BottomNavProps) { + const tabs: { id: MobileTab; icon: IconName; label: string; sheetId?: string }[] = [ + { id: 'chat', icon: 'chat', label: 'Chat' }, + { id: 'terminal', icon: 'terminal', label: 'Terminal', sheetId: 'terminal' }, + { id: 'editor', icon: 'code', label: 'Editor' }, + { id: 'git', icon: 'branch', label: 'Git', sheetId: 'git' }, + { id: 'more', icon: 'more', label: 'More', sheetId: 'more' }, + ] + + return ( + + ) +} + +interface MobileShellProps { + children: React.ReactNode + activeTab: MobileTab + onTabChange: (tab: MobileTab) => void + sheets: Record + onSheetToggle: (id: string) => void + unreadCounts?: Record +} + +export function MobileShell({ children, activeTab, onTabChange, sheets, onSheetToggle, unreadCounts }: MobileShellProps) { + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth <= 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + if (!isMobile) return <>{children} + return ( +
+
{children}
+ [k, v.open]))} onSheetToggle={onSheetToggle} /> + {Object.entries(sheets).map(([id, sheet]) => sheet.open && ( + onSheetToggle(id)}>{sheet.content} + ))} +
+ ) +} + +export function useMobileShell() { + const [activeTab, setActiveTab] = useState('chat') + const [sheets, setSheets] = useState>({}) + + const onTabChange = useCallback((tab: MobileTab) => { + setActiveTab(tab) + setSheets(prev => { + const next = { ...prev } + let dirty = false + for (const k of Object.keys(next)) if (next[k].open) { next[k] = { ...next[k], open: false }; dirty = true } + return dirty ? next : prev + }) + }, []) + + const onSheetToggle = useCallback((id: string) => { + setSheets(prev => ({ ...prev, [id]: { ...prev[id], open: !prev[id]?.open, title: prev[id]?.title ?? id, content: prev[id]?.content ?? null } })) + }, []) + + const openSheet = useCallback((id: string, title: string, content: React.ReactNode) => { + setSheets(prev => { + const next: typeof prev = {} + for (const [k, v] of Object.entries(prev)) next[k] = k === id ? { open: true, title, content } : { ...v, open: false } + if (!next[id]) next[id] = { open: true, title, content } + else next[id] = { open: true, title, content } + return next + }) + }, []) + + const closeSheet = useCallback((id: string) => { + setSheets(prev => ({ ...prev, [id]: { ...prev[id], open: false } })) + }, []) + + return { activeTab, onTabChange, sheets, onSheetToggle, openSheet, closeSheet, setActiveTab } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 22437ef..06ef9ff 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -56,6 +56,12 @@ function warmFonts(): void { if ('requestIdleCallback' in window) (window as any).requestIdleCallback(warmFonts) else setTimeout(warmFonts, 1500) +if ('serviceWorker' in navigator && !isElectronRuntime) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}) + }) +} + ReactDOM.createRoot(document.getElementById('root')!).render( {isElectronRuntime ? ( diff --git a/src/renderer/src/styles/colors_and_type.css b/src/renderer/src/styles/colors_and_type.css index 61313fd..8f43311 100644 --- a/src/renderer/src/styles/colors_and_type.css +++ b/src/renderer/src/styles/colors_and_type.css @@ -269,3 +269,128 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +/* ============================================================ + Mobile / Responsive tokens + ============================================================ */ + +:root { + /* Breakpoints */ + --bp-mobile: 480px; + --bp-tablet: 768px; + --bp-desktop: 1024px; + --bp-wide: 1440px; + + /* Touch targets */ + --touch-target: 44px; + --touch-target-sm: 36px; + --touch-target-lg: 52px; + + /* Mobile spacing (tighter) */ + --space-mobile-1: 4px; + --space-mobile-2: 8px; + --space-mobile-3: 12px; + --space-mobile-4: 16px; + + /* Sheet/drawer heights */ + --sheet-handle: 24px; + --sheet-handle-width: 40px; + --sheet-min-height: 200px; + --sheet-max-height: calc(100vh - 120px); + --sheet-full-height: calc(100vh - 48px); + + /* Bottom nav */ + --bottom-nav-height: 56px; + --bottom-nav-height-safe: calc(56px + env(safe-area-inset-bottom)); + + /* Composer */ + --composer-min-height: 44px; + --composer-max-height: 180px; + + /* Terminal rows on mobile */ + --term-rows-mobile: 15; + + /* Z-index layers for mobile sheets */ + --z-bottom-nav: 1000; + --z-sheet-backdrop: 1100; + --z-sheet: 1200; + --z-toast: 1300; + --z-modal: 1400; +} + +/* Media queries as CSS custom properties for JS access */ +@media (max-width: 768px) { + :root { + --is-mobile: 1; + } +} + +@media (min-width: 769px) { + :root { + --is-mobile: 0; + } +} + +/* Safe area insets for notched devices */ +@supports (padding: max(0px)) { + :root { + --safe-top: env(safe-area-inset-top); + --safe-right: env(safe-area-inset-right); + --safe-bottom: env(safe-area-inset-bottom); + --safe-left: env(safe-area-inset-left); + } +} + +/* Mobile-first utility classes */ +.mobile-only { display: none; } +.desktop-only { display: inherit; } + +@media (max-width: 768px) { + .mobile-only { display: inherit; } + .desktop-only { display: none; } + + /* Tighter spacing on mobile */ + :root { + --space-1: 4px; + --space-2: 8px; + --space-3: 10px; + --space-4: 12px; + --space-5: 16px; + --space-6: 20px; + } + + /* Larger touch targets */ + .cc-body { font-size: 15px; } + .cc-body-sm { font-size: 13.5px; } + .cc-caption { font-size: 11.5px; } + .cc-label { font-size: 12.5px; } + .cc-mono { font-size: 14px; } + .cc-mono-sm { font-size: 12.5px; } + + /* Prevent zoom on input focus (iOS) */ + input, select, textarea { font-size: 16px !important; } +} + +/* Touch-friendly focus styles */ +@media (hover: none) and (pointer: coarse) { + *:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + button, [role="button"] { + min-height: var(--touch-target); + min-width: var(--touch-target); + } +} + +/* Scrollbar styling for mobile sheets */ +.sheet-content { + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} +.sheet-content::-webkit-scrollbar { width: 6px; height: 6px; } +.sheet-content::-webkit-scrollbar-track { background: transparent; } +.sheet-content::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 999px; +} diff --git a/src/renderer/src/styles/styles.css b/src/renderer/src/styles/styles.css index 8685430..f0ba5a6 100644 --- a/src/renderer/src/styles/styles.css +++ b/src/renderer/src/styles/styles.css @@ -6909,3 +6909,39 @@ body.light .writer-prompt-textarea { .canvas-mode-hub, .canvas-mode-pane-grid { grid-template-columns: 1fr; } } + +/* ── Mobile web shell ───────────────────────────────────────────── */ +@media (max-width: 768px) { + html, body, #root { overflow: auto; height: auto; min-height: 100dvh; } + body { background: var(--background); overscroll-behavior-y: contain; } + .app { height: 100dvh; border: none; border-radius: 0; } + .titlebar { -webkit-app-region: no-drag; height: 44px; padding: 0 8px; } + .wintabs { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-right: 8px; } + .wintabs .tabs-scroll { overflow-x: auto; } + .wintab { min-width: 120px; max-width: 160px; } + .app-region.drawer-left, .app-region.drawer-right { flex-direction: column; } + .ws-drawer.side { position: fixed; inset: auto 0 0 0; width: auto !important; height: 70dvh; border: 1px solid var(--border); border-bottom: none; border-radius: 16px 16px 0 0; z-index: 1200; transform: translateY(100%); transition: transform 260ms cubic-bezier(.2,.8,.2,1); } + .ws-drawer.side.open { transform: translateY(0); width: auto !important; } + .ws-drawer.side .ws-inner { width: auto !important; } + .ws-dock { height: 44px; padding: 0 12px; } + .main { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; margin: 0; border: none; border-radius: 0; } + .main.term-down, .main.no-term { grid-template-columns: 1fr; } + .termcol-outer { display: none !important; } + .mobile-shell .termcol-outer { display: flex !important; border-left: none; } + .composer { width: 100%; min-width: 0; border-radius: 16px; } + .composer-wrap { padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); } + .thread-content { padding: 12px 16px 0; } + .thread-content.density-compact { padding: 12px 16px 4px; } + .thr-h { padding: 12px 16px 10px; } + .cp-backdrop { padding-top: 24px; align-items: flex-start; } + .cp { width: 96%; } + .fresh-chat-composer .composer { width: 100%; } + .md-body.split { grid-template-columns: 1fr; } + .md-preview { border-left: none; border-top: 1px solid var(--border); } + .ft { width: 100%; max-width: none; border-left: none; border-top: 1px solid var(--border); max-height: 40dvh; } + .ed-main { flex-direction: column; } + .canvas-mode-pane-grid { grid-template-columns: 1fr; } +} + +@keyframes sheetIn { from { transform: translateY(100%); } to { transform: translateY(0); } } +@keyframes sheetBackdropIn { from { opacity: 0; } to { opacity: 1; } } From 9a1c5d1277be138bf3dccc751adea46b902e70c4 Mon Sep 17 00:00:00 2001 From: CrewCode Test Date: Mon, 24 Aug 2026 23:19:49 -0400 Subject: [PATCH 08/10] feat: add bridge handoff functionality and enhance mobile styles - Implemented method in web-rpc-client for handling conversation handoffs. - Updated mission-control and system-monitor styles for improved mobile responsiveness. - Introduced new mobile settings styles to enhance user experience on smaller screens. - Added Tailwind CSS utility classes for better design consistency and responsiveness. - Extended session type definitions to include for branch provisioning. --- AGENTS.md | 4 + docs/crewcoder-provider.md | 4 +- docs/current-state.md | 10 +- docs/provider-context-handoff.md | 9 + docs/security-model.md | 26 +- docs/tailwind-renderer.md | 18 + docs/web-remote-access.md | 52 +- electron.vite.config.ts | 3 +- package-lock.json | 819 +++++++++++++++++- package.json | 4 + src/main/agents/bridge-service.ts | 16 + src/main/agents/custody-invariants.ts | 2 +- src/main/agents/index.ts | 72 ++ src/main/brain-authorization-policy.test.ts | 24 + src/main/brain-authorization-policy.ts | 68 ++ src/main/hub-brain-relay.ts | 29 +- src/main/hub-machine-enrollment.test.ts | 52 +- src/main/hub-machine-enrollment.ts | 196 ++++- src/main/hub-mobile-access.test.ts | 38 + src/main/hub-mobile-access.ts | 61 ++ src/main/hub-relay.test.ts | 19 + src/main/hub-server.test.ts | 54 +- src/main/hub-server.ts | 81 +- src/main/hub.test.ts | 18 +- src/main/hub.ts | 47 +- src/main/index.ts | 28 +- src/main/packaged-cli-dispatch.test.ts | 20 + src/main/packaged-cli-dispatch.ts | 7 + src/main/pty-service.ts | 12 +- src/main/remote-access-server.ts | 18 +- src/preload/index.ts | 8 + src/renderer/src/App.tsx | 126 ++- src/renderer/src/components/chat/ChatPane.tsx | 100 ++- .../src/components/chat/HandoffCard.tsx | 150 ++++ .../src/components/chat/SoloChatView.tsx | 4 +- .../src/components/chat/handoff-card.test.ts | 29 + .../chat/mobile-chat-layout.test.ts | 40 + .../src/components/composer/Composer.tsx | 93 +- .../composer/MobileComposerMenus.tsx | 280 ++++++ .../src/components/composer/PickerSheet.tsx | 18 +- .../composer/mobile-composer-menus.test.ts | 40 + .../composer/mobile-picker-sheet.test.ts | 25 + .../settings/BrainAuthorizationSection.tsx | 70 ++ .../components/settings/SettingsScreen.tsx | 84 +- .../brain-authorization-settings.test.ts | 17 + .../settings/default-branch-settings.test.ts | 31 + .../settings/mobile-settings-layout.test.ts | 50 ++ .../src/components/system/SystemMonitor.tsx | 11 +- .../src/components/thread/ChatHeader.tsx | 40 +- .../thread/Messages.render-isolation.test.ts | 37 + .../src/components/thread/Messages.test.ts | 9 +- .../src/components/thread/Messages.tsx | 75 +- .../src/components/thread/ThinkingBlock.tsx | 86 +- .../src/components/thread/TurnWorkLog.tsx | 172 ++-- src/renderer/src/components/ui/AppMenu.tsx | 2 + .../src/components/ui/MobileShell.tsx | 114 +-- .../src/components/ui/WindowTabs.test.ts | 28 + src/renderer/src/components/ui/WindowTabs.tsx | 9 +- .../components/workspaces/WorkspaceDock.tsx | 22 + .../workspaces/WorkspacesDrawer.tsx | 140 ++- .../mobile-utility-controls.test.ts | 38 + .../mobile-workspace-drawer.test.ts | 40 + .../workspace-drawer-layout.test.ts | 14 + src/renderer/src/hooks/useAgentBridge.ts | 11 +- src/renderer/src/hooks/useBridgeRegistry.ts | 1 + .../src/hooks/useChatSessions.test.ts | 27 + src/renderer/src/hooks/useChatSessions.ts | 7 +- .../hooks/useMobileWindowTabsAutoHide.test.ts | 83 ++ .../src/hooks/useMobileWindowTabsAutoHide.ts | 222 +++++ src/renderer/src/hooks/useSettings.tsx | 5 + src/renderer/src/main.tsx | 1 + .../src/runtime/WebConnectionScreen.tsx | 2 + .../runtime/brain-authorization-runtime.ts | 5 + src/renderer/src/runtime/hub-relay-client.ts | 10 +- src/renderer/src/runtime/web-rpc-client.ts | 1 + src/renderer/src/styles/mission-control.css | 16 + src/renderer/src/styles/settings.css | 167 ++++ src/renderer/src/styles/styles.css | 409 ++++++++- src/renderer/src/styles/system-monitor.css | 15 + src/renderer/src/styles/tailwind.css | 31 + src/renderer/src/types/index.ts | 4 + 81 files changed, 4426 insertions(+), 404 deletions(-) create mode 100644 docs/provider-context-handoff.md create mode 100644 docs/tailwind-renderer.md create mode 100644 src/main/brain-authorization-policy.test.ts create mode 100644 src/main/brain-authorization-policy.ts create mode 100644 src/main/hub-mobile-access.test.ts create mode 100644 src/main/hub-mobile-access.ts create mode 100644 src/main/packaged-cli-dispatch.test.ts create mode 100644 src/main/packaged-cli-dispatch.ts create mode 100644 src/renderer/src/components/chat/HandoffCard.tsx create mode 100644 src/renderer/src/components/chat/handoff-card.test.ts create mode 100644 src/renderer/src/components/chat/mobile-chat-layout.test.ts create mode 100644 src/renderer/src/components/composer/MobileComposerMenus.tsx create mode 100644 src/renderer/src/components/composer/mobile-composer-menus.test.ts create mode 100644 src/renderer/src/components/composer/mobile-picker-sheet.test.ts create mode 100644 src/renderer/src/components/settings/BrainAuthorizationSection.tsx create mode 100644 src/renderer/src/components/settings/brain-authorization-settings.test.ts create mode 100644 src/renderer/src/components/settings/default-branch-settings.test.ts create mode 100644 src/renderer/src/components/settings/mobile-settings-layout.test.ts create mode 100644 src/renderer/src/components/workspaces/mobile-utility-controls.test.ts create mode 100644 src/renderer/src/components/workspaces/mobile-workspace-drawer.test.ts create mode 100644 src/renderer/src/hooks/useMobileWindowTabsAutoHide.test.ts create mode 100644 src/renderer/src/hooks/useMobileWindowTabsAutoHide.ts create mode 100644 src/renderer/src/runtime/brain-authorization-runtime.ts create mode 100644 src/renderer/src/styles/tailwind.css diff --git a/AGENTS.md b/AGENTS.md index 7640b14..a181849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,8 @@ The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer The design system lives in `.design/crewcode-design-system/`. The canonical CSS tokens are in `src/renderer/src/styles/colors_and_type.css`. +Renderer components may use Tailwind v4 utilities through the utilities-only integration in `src/renderer/src/styles/tailwind.css`. Preflight must stay disabled so incremental conversions do not reset unrelated app surfaces. Use the `cc-*` semantic Tailwind colors, which map to the canonical live CSS tokens; see `docs/tailwind-renderer.md`. + **Hard rules:** - Background: `#0f120f` (dark), never pure black @@ -177,6 +179,8 @@ Three tsconfigs compose via project references: Read this file only when working on any of the features below and need the Current state of them `CrewCoder provider`, `ACP Grok Build`, `Sidebar Folder Creation`, `Crew Supervisor`, `Delegated Threads`,`Chat Archiving`, `Hide work Logs`, `Realtime Voice Orb`, `Notifcation Sound`, `Agent Messages`, `Agent Task Activity`, `Cusromization Panel`, `Queued Messages`, `Composer Execution Modes & reasoning`, `Claude SDK Global skills isolation`, `Provider Switch Handoff & Compact`, `Chat`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, [Current State](docs/current-state.md) +Provider context handoff is initiated from the Solo Chat header or `/handoff`. Preserve the destination-card behavior, existing-chat provider/model/effort locking, visible destination meter, and disposable destination-provider summary flow documented in `docs/provider-context-handoff.md`. + ## Plugin platform notes CrewCode has a local-first plugin platform moving from v0 prototype to stable contract. diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index 8061fed..746d403 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -77,7 +77,9 @@ failure. CrewCoder ACP respects CrewCoder's persisted `autoCompact` setting. CrewCode does not force compaction or retry context-window failures. Automatic and provider-neutral safety compaction are reported live through `_crewcoder/compaction_update`, allowing CrewCode to show the compaction meter -while CrewCoder summarizes in the background. When automatic compaction is off, the user explicitly +while CrewCoder summarizes in the background. If the ACP child exits, CrewCode removes that dead +bridge registration; the next composer submission uses normal missing-bridge recovery rather than +attempting to write to closed stdin and surfacing `crewcoder acp: process not writable`. When automatic compaction is off, the user explicitly runs `/compact` before continuing; this policy does not affect Pi or other providers. A prompt has a ten-minute **inactivity** watchdog rather than a wall-clock turn diff --git a/docs/current-state.md b/docs/current-state.md index 5a4b8d4..561dfb3 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,9 +4,11 @@ Real agent integration is wired through normalized bridges (pi, OpenCode, Claude Workspaces, worktrees, git operations, terminals, settings, and crew sessions are all real and persisted (workspaces + tabs to disk, messages to localStorage). +Settings → General stores a workspace-scoped default branch for newly created solo-chat sessions. The selector detects local branches from the active repository. A new session captures that setting once, reuses or creates the branch worktree, selects it for that chat surface, then clears the one-shot request so existing chats and later manual branch switches are never moved retroactively. Delegated threads retain their separate base/worktree contract. + ## CrewCoder -CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp --approval review`. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. See `docs/crewcoder-provider.md`. +CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp --approval review`. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. See `docs/crewcoder-provider.md`. ## ACP Grok Build @@ -38,7 +40,7 @@ Chat archiving (`Session.archived`) is non-destructive: archiving releases the s ## Hide work Logs -Settings include `hideVerboseAgentLogs`, which filters thinking/toolcall/worklog rows at the shared `Messages` renderer. Keep final agent replies, user messages, and important system/status meters visible; do not delete verbose messages from storage just because they are hidden in the UI. +Settings include `hideVerboseAgentLogs`, which filters thinking/toolcall/worklog rows at the shared `Messages` renderer. Keep final agent replies, user messages, and important system/status meters visible; do not delete verbose messages from storage just because they are hidden in the UI. Work logs and thinking traces use the utilities-only Tailwind renderer integration while retaining their real message/tool mapping, expansion behavior, diagnostics, diffs, and file actions. During execution, tool runs remain visible in stream order; once a later agent response exists, all earlier tool calls in that turn consolidate into one initially expanded work log directly before the latest response. Keep layouts bounded on mobile and do not replace real data with template/demo content. See `docs/tailwind-renderer.md`. ## Realtime Voice Orb @@ -54,7 +56,7 @@ Completed-turn desktop notifications use the persisted `notificationSound` setti ## Agent Messages -Completed agent-message Markdown fenced code uses the shared safe Shiki `CodeBlock`; streaming text and inline code remain lightweight. Shiki and semantic Markdown accents (headings, list markers, emphasis, links, inline code) must use `--syntax-*` CSS-variable references derived from canonical theme tokens in `colors_and_type.css`, never a fixed bundled palette, so live theme changes recolor existing messages without re-tokenization. Keep body/list text readable, the 80,000-character fallback, and React-node rendering (no `innerHTML`). See `docs/agent-message-markdown.md`. +Streaming answer presentation may use Tailwind utilities, but must remain one lightweight text node while live; do not reintroduce per-word animation or template citations/follow-ups that are not backed by real message data. Completed agent-message Markdown fenced code uses the shared safe Shiki `CodeBlock`; streaming text and inline code remain lightweight. Shiki and semantic Markdown accents (headings, list markers, emphasis, links, inline code) must use `--syntax-*` CSS-variable references derived from canonical theme tokens in `colors_and_type.css`, never a fixed bundled palette, so live theme changes recolor existing messages without re-tokenization. Keep body/list text readable, the 80,000-character fallback, and React-node rendering (no `innerHTML`). See `docs/agent-message-markdown.md`. ## Agent Task Activity @@ -88,6 +90,8 @@ Provider switching mid-chat is context handoff, not true provider state migratio Manual `/compact` uses the same disposable-summary pattern: generate an AI summary from a bounded transcript in a temporary session, show the summary in chat, replace CrewCode's local replay history with that summary, clear the provider-native resume id, and start fresh on the next prompt. +Manual context handoff is available from the Solo Chat header and `/handoff`. Its card targets either a new chat (with provider/model/effort selection) or a used chat (retaining that chat's existing provider/model/effort). The destination immediately shows the handoff meter; a disposable destination-provider session summarizes the source, the packet is appended to destination local replay history, and the destination native resume id is cleared before continuation. Remote browser handoff refuses explicitly until Brain owns an equivalent cross-thread conversation store. See `docs/provider-context-handoff.md`. + ## Chat Visible chat transcripts persist in two layers (`src/renderer/src/stores/chat-messages-store.ts` + `src/main/transcript-store.ts`). **L2 — disk** is authoritative and unbounded: one file per scope under `userData/transcripts/transcript..json` holding the full rich `Message[]`, written back on the same settle/debounce cadence and via a synchronous IPC batch (`transcripts:saveSyncBatch`) on window teardown. **L1 — `crewcode:messagesByTab` localStorage** is a bounded, synchronous cache for instant paint on launch; it caps each scope's tail and, on `QuotaExceededError`, evicts the least-recently-touched scopes so the newest conversation always wins. On launch the store hydrates from L2, backfilling anything L1 evicted. Do NOT treat localStorage as the source of truth or let `persist()` swallow quota errors silently — that was the original "recent messages vanish on restart" data-loss bug. Growing turns stay memory-only: do not serialize L1 or structured-clone L2 for live thinking/agent/tool rows, because those synchronous renderer costs caused the workspace-wide hitch on structural stream events. A settled scope schedules both layers at bounded idle; pagehide/beforeunload/hidden visibility synchronously flushes even live scopes, which is the durability guarantee. User messages are settled and therefore persist before normal agent work, while a crash may lose only the partial in-progress response. Main's `transcripts:save` is a last-wins async queue (`fsp.writeFile`), never a sync write on the IPC handler; the teardown sync batch drops queued payloads for scopes it writes so a stale async write can't clobber it. Transcript mtimes are cached in main after the launch scan and updated on writes/removes—never re-read and JSON-parse every shard per `transcripts:mtimes` request (that blocked Browser main for 1.2–1.3s on Mission Control tool-state refreshes). Explicit session deletion must also call `transcripts:remove`; the reconciliation prune must NOT delete disk files. diff --git a/docs/provider-context-handoff.md b/docs/provider-context-handoff.md new file mode 100644 index 0000000..e9df19d --- /dev/null +++ b/docs/provider-context-handoff.md @@ -0,0 +1,9 @@ +# Provider context handoff + +CrewCode exposes context handoff from the Solo Chat header and through `/handoff`. + +The handoff card separates destinations into **New chat** and **Used chats** tabs. Opening **Used chats** loads the other chat sessions in the current workspace. New destinations allow provider, model, and reasoning effort selection. Existing destinations keep their already-selected provider, model, effort, and visible transcript. + +A handoff is not provider-native session migration. CrewCode starts a disposable session using the destination provider, generates a bounded summary of the source conversation, and appends that packet to the destination's local replay history. The destination provider's native resume id is cleared so its next prompt starts fresh and receives both its prior local history and the imported handoff packet. The destination transcript displays a progress meter and the generated handoff summary. + +If summarization fails, the destination meter is marked failed and CrewCode does not report success. Remote browser/Brain handoff currently refuses explicitly because that runtime does not own the desktop conversation store; `/compact` and normal prompting remain unchanged there. diff --git a/docs/security-model.md b/docs/security-model.md index edc03f4..417505c 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -74,7 +74,18 @@ per-peer limiter. Enrollment creates an Ed25519 identity on the brain and a sepa the Hub stores the public key and only SHA-256 of the bearer secret. Heartbeats are outbound HTTPS requests. Presence goes offline after 90 seconds without a successful heartbeat, and owner revocation immediately makes the bearer credential fail closed. -Browser mutations retain exact-origin and CSRF enforcement. +Browser mutations retain exact-origin and CSRF enforcement. The explicitly labeled +first-owner QR carries the same short-lived, single-use bootstrap fragment already +printed to the trusted terminal; it expires in ten minutes and is removed after +registration. Normal mobile QR codes encode only the stable validated HTTPS Hub +origin and are rendered in the authenticated dashboard; they contain no browser +session, enrollment secret, or Brain ticket. Phone-approved enrollment keeps its +Ed25519 private key on the PC, uses the short code only for human comparison, and +protects polling/credential delivery with a separate 256-bit request secret. Pending +requests are memory-only, bounded, rate-limited, ten-minute, owner-approved with +CSRF, one-time on delivery, and audited. +Tailscale setup refuses to overwrite an existing Serve configuration without an +explicit replacement flag. QR transfer does not weaken normal passkey sign-in. **Connection and execution gates:** only an authenticated Hub browser session plus CSRF can issue a 60-second memory-only ticket for one owned, active, relay-connected @@ -87,11 +98,14 @@ handshake transcript with its enrolled Ed25519 key, and ordered application fram use direction-separated HKDF/AES-256-GCM keys. The Hub sees routing metadata and handshake public values, but not RPC, source, terminal, prompt, or response plaintext. -Hub identity still does not grant execution. `crewcode brain` grants no RPC scope by -default and requires explicit local `--workspace-root` plus repeatable -`--allow-scope` settings. Each decrypted method is classified at the Brain and must -be included in both the ticket request and local grant. The reused backend then -revalidates registered workspace roots for filesystem, Git, PTY, and agent calls. +Hub identity still does not grant execution. The first `crewcode brain` start grants +no RPC scope by default and seeds an owner-only persisted policy from explicit local +`--workspace-root` and repeatable `--allow-scope` settings. Thereafter Settings → +Brain Access manages it only through E2EE owner RPC. Reductions apply immediately, +stop affected agents/terminals, and remove scopes from existing sessions; additions +require a fresh ticket and handshake. Each decrypted method must be included in both +the ticket request and current local grant. The backend revalidates live workspace +roots for filesystem, Git, PTY, attachments, and agent calls. **Tests:** `hub-server.test.ts` covers CSRF, issue/enroll, replay rejection, stale presence, heartbeat, and revocation. `hub-machine-enrollment.test.ts` covers URL diff --git a/docs/tailwind-renderer.md b/docs/tailwind-renderer.md new file mode 100644 index 0000000..cbd3b82 --- /dev/null +++ b/docs/tailwind-renderer.md @@ -0,0 +1,18 @@ +# Tailwind renderer compatibility + +CrewCode's renderer supports Tailwind CSS v4 utilities through `@tailwindcss/vite`. + +## Integration + +- `electron.vite.config.ts` registers Tailwind only for the renderer build. +- `src/renderer/src/styles/tailwind.css` imports Tailwind's theme and utility layers. +- Tailwind Preflight is intentionally not imported. The existing application reset and component CSS remain authoritative, preventing an incremental Tailwind conversion from changing unrelated Electron and browser surfaces. +- Semantic Tailwind colors (`cc-canvas`, `cc-surface`, `cc-field`, `cc-hover`, `cc-ink`, `cc-muted`, `cc-line`, `cc-accent`, `cc-success`, and `cc-danger`) resolve to CrewCode's live CSS design tokens. They therefore continue to follow theme customization. + +## Usage rules + +Use Tailwind utilities for new or converted renderer component layout and responsive behavior. Do not hardcode a second palette or bypass the tokens in `colors_and_type.css`. Technical values and tool output remain in the configured mono font. + +Legacy root class names may remain when tests or integrations use them as stable row identities. In that case, keep compatibility selectors narrow and implement the component's internal layout with utilities. + +The work-log, thinking trace, and streaming-answer surfaces are the first converted components. Their existing data mapping, file-open actions, diagnostics, syntax highlighting, and diff rendering remain unchanged. Tool runs stay in stream order while live; when the turn has a later agent response, its earlier tool calls consolidate into one initially expanded work log directly before the latest response. Responsive rules constrain long paths/output at narrow widths, and reduced-motion preferences collapse decorative transitions. diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index e5bd0f4..8889f39 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -318,13 +318,36 @@ npx crewcode serve --host 127.0.0.1 npx crewcode serve --host 0.0.0.0 --public-origin https://your-hub.example ``` -Implemented self-hosted Hub command: +Implemented self-hosted Hub and mobile QR commands: ```bash crewcode hub +crewcode hub mobile --tailscale +crewcode hub mobile --public-origin https://your-hub.example crewcode hub --host 0.0.0.0 --public-origin https://your-hub.example ``` +`hub mobile --tailscale` requires a connected Tailscale client, MagicDNS, and HTTPS +certificates enabled for the tailnet. It derives the exact `https://.` +origin, refuses to overwrite an existing Serve configuration unless +`--tailscale-replace` is explicitly supplied, proxies HTTPS to the loopback Hub, +and prints a terminal QR. On first startup, a distinctly labeled setup QR contains +the same short-lived, single-use 10-minute bootstrap fragment as the printed owner +setup link; this is necessary to create the first passkey and must not be shared. +After owner creation, terminal and authenticated-dashboard QR payloads contain only +the stable Hub URL—no session, enrollment credential, or Brain ticket. The phone +must belong to the tailnet and still signs in normally. + +Users without Tailscale provide their own trusted HTTPS reverse proxy/domain with +`hub mobile --public-origin`. The proxy must forward HTTP and WebSocket upgrades to +the loopback Hub. A QR code is address transfer, not a tunnel; plain LAN HTTP and +self-signed certificates are intentionally not treated as safe iPhone deployment. + +Passkeys are bound to the exact hostname. Changing an already-configured Hub from +`localhost` or another domain to a Tailscale/domain origin requires registering the +owner credential for that final origin (for an early test install, use a separate Hub +data directory and re-enroll Brain). Once selected, keep the HTTPS origin stable. + The Hub defaults to `127.0.0.1:3774`, stores state in `~/.crewcode/hub/hub.sqlite`, and prints a ten-minute single-use owner setup URL on first launch. Interactive terminals receive an OSC 8 clickable setup link plus the raw URL as a copy fallback. @@ -336,12 +359,23 @@ require an explicit final public origin; non-loopback origins require HTTPS beca the origin is cryptographically bound to passkeys. Put a TLS reverse proxy or Tailscale HTTPS in front of the HTTP listener for network deployment. -After signing in, select **Enroll a machine**. The Hub issues a memory-only, -ten-minute, single-use token. On that machine run the displayed command and paste -the token at the hidden prompt: +After signing in on the phone, run this on the machine: ```bash crewcode enroll --hub https://your-hub.example +``` + +The PC generates its Ed25519 identity locally, prints a short `XXXX-XXXX` comparison +code and public-key fingerprint, and polls with a separate 256-bit private request +secret. The authenticated phone dashboard automatically shows the pending machine. +Verify the code/fingerprint, then tap **Approve** or **Reject**. The short code is +identification only and cannot retrieve a credential; approval returns the one-time +machine bearer credential exclusively to the polling PC. Requests expire after ten +minutes, are memory-only, rate/bound limited, and disappear on Hub restart. The +legacy `--token` path remains for controlled automation but is no longer the default. +Then start the relay: + +```bash crewcode brain ``` @@ -367,9 +401,13 @@ crewcode brain \ --allow-scope agent ``` -Hub sign-in and ticket scope requests cannot widen these grants. Every RPC method is -classified again at the Brain and filesystem/PTY/agent operations retain registered- -workspace enforcement. The enrolled Ed25519 identity signs each ephemeral P-256 +The first Brain start seeds an owner-only persisted policy from these flags. After +that, the web Settings → Brain Access section manages Brain-local roots and scopes +through the E2EE tunnel without restarting Brain. Reductions apply immediately and +stop affected agents/terminals; additions renew the encrypted tunnel with a fresh +ticket. Hub sign-in and ticket scope requests cannot widen these grants. Every RPC +method is classified again at the Brain and filesystem/PTY/agent operations retain +live workspace enforcement. The enrolled Ed25519 identity signs each ephemeral P-256 handshake; HKDF-derived AES-256-GCM keys encrypt ordered application frames so the Hub routes ciphertext rather than source, terminal, prompt, or response content. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1a8213b..9533de0 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' import { resolve } from 'path' import { execSync } from 'child_process' @@ -44,6 +45,6 @@ export default defineConfig({ '@renderer': resolve('src/renderer/src') } }, - plugins: [react()] + plugins: [react(), tailwindcss()] } }) diff --git a/package-lock.json b/package-lock.json index 2ab7f28..335a1a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "pdfkit": "^0.19.1", "pidtree": "^0.6.0", "pidusage": "^4.0.1", + "qrcode": "^1.5.4", "react": "^18.3.0", "react-dom": "^18.3.0", "react-grab": "^0.1.37", @@ -72,8 +73,10 @@ "crewcode-server": "bin/crewcode-server.mjs" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.0.0", "@types/pdfkit": "^0.17.6", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-test-renderer": "^18.3.1", @@ -86,6 +89,7 @@ "electron-devtools-installer": "^4.0.0", "electron-vite": "^5.0.0", "react-test-renderer": "^18.3.1", + "tailwindcss": "^4.3.3", "vitest": "^4.1.11" }, "engines": { @@ -4268,6 +4272,278 @@ "node": ">=10" } }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4464,6 +4740,16 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -4889,7 +5175,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5946,7 +6231,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5959,7 +6243,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6319,6 +6602,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -6361,6 +6654,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -6865,7 +7164,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -6887,6 +7185,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -7597,7 +7909,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -8270,7 +8581,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8621,6 +8931,256 @@ "immediate": "~3.0.5" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -10824,6 +11384,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -10874,7 +11443,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11077,6 +11645,15 @@ "browserify-zlib": "^0.2.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/polished": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/polished/-/polished-1.9.3.tgz", @@ -11307,6 +11884,194 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -11812,7 +12577,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11827,6 +12591,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -12247,6 +13017,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -12798,6 +13574,27 @@ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -14216,6 +15013,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 9516f8f..c704dba 100644 --- a/package.json +++ b/package.json @@ -161,8 +161,10 @@ "test:watch": "vitest" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.0.0", "@types/pdfkit": "^0.17.6", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-test-renderer": "^18.3.1", @@ -175,6 +177,7 @@ "electron-devtools-installer": "^4.0.0", "electron-vite": "^5.0.0", "react-test-renderer": "^18.3.1", + "tailwindcss": "^4.3.3", "vitest": "^4.1.11" }, "dependencies": { @@ -219,6 +222,7 @@ "pdfkit": "^0.19.1", "pidtree": "^0.6.0", "pidusage": "^4.0.1", + "qrcode": "^1.5.4", "react": "^18.3.0", "react-dom": "^18.3.0", "react-grab": "^0.1.37", diff --git a/src/main/agents/bridge-service.ts b/src/main/agents/bridge-service.ts index 40a7d12..71102d1 100644 --- a/src/main/agents/bridge-service.ts +++ b/src/main/agents/bridge-service.ts @@ -170,6 +170,13 @@ export class AgentBridgeService { return entry.bridge.compact?.() ?? Promise.resolve({ ok: false, unsupported: true, error: 'provider does not support compaction' }) } + handoff(_bridgeId: string, _sourceConversationKey: string): Promise<{ ok: boolean; error?: string }> { + // Browser/Brain bridge custody does not expose the desktop conversation + // store needed to summarize another thread. Refuse rather than implying a + // provider-native state migration happened. + return Promise.resolve({ ok: false, error: 'context handoff is unavailable in remote browser sessions' }) + } + removeFollowUp(bridgeId: string, followUpId: string): Promise<{ ok: boolean; error?: string }> { const entry = this.bridges.get(bridgeId) if (!entry) return Promise.resolve({ ok: false, error: 'bridge not found' }) @@ -247,6 +254,15 @@ export class AgentBridgeService { return { ok: true } } + async stopWhere(predicate: (entry: { bridgeId: string; cwd: string; running: boolean }) => boolean): Promise { + const stopped: string[] = [] + for (const [bridgeId, entry] of [...this.bridges]) { + if (!predicate({ bridgeId, cwd: entry.opts.cwd, running: entry.running })) continue + await this.stop(bridgeId); stopped.push(bridgeId) + } + return stopped + } + async stopAll(): Promise { for (const bridgeId of [...this.bridges.keys()]) this.cancelPendingRequests(bridgeId) await Promise.all([...this.bridges.values()].map(entry => entry.bridge.stop().catch(() => {}))) diff --git a/src/main/agents/custody-invariants.ts b/src/main/agents/custody-invariants.ts index cd13535..91f6ba6 100644 --- a/src/main/agents/custody-invariants.ts +++ b/src/main/agents/custody-invariants.ts @@ -118,7 +118,7 @@ export function decideModeChange(current: ModeLevel | undefined, next: ModeLevel * inspection (status, journal reads) is deliberately not in this set — a halt * must never hide the evidence it was raised to preserve. */ -export type PrivilegedAction = 'prompt' | 'authorize' | 'respond' | 'compact' | 'setMode' | 'removeFollowUp' +export type PrivilegedAction = 'prompt' | 'authorize' | 'respond' | 'compact' | 'handoff' | 'setMode' | 'removeFollowUp' export function refusalMessage(action: PrivilegedAction, halt: CustodyViolation): string { return `${action} refused: ${CUSTODY_INVARIANTS[halt.invariant].title.toLowerCase()} — ${halt.detail}. Reauthorize this thread to continue.` diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index d4c2535..89e1b9f 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -1127,6 +1127,16 @@ export function registerAgentBridgeIpc(resolveAgentPath: AgentPathResolver): voi }) } win?.webContents.send('bridge:event', eventToSend) + if (entry && event.type === 'closed') { + // A dead ACP child is not reusable. Leaving it registered makes the next + // composer submission hit its closed stdin and report the misleading + // "process not writable" error instead of taking the existing missing- + // bridge self-heal path. Guard identity so a late close from an older + // process cannot delete a replacement bridge with the same id. + queueMicrotask(() => { + if (bridges.get(event.bridgeId) === entry) bridges.delete(event.bridgeId) + }) + } } try { @@ -1417,6 +1427,68 @@ export function registerAgentBridgeIpc(resolveAgentPath: AgentPathResolver): voi return result }) + ipcMain.handle('bridge:handoff', async (_e, { bridgeId, sourceConversationKey, options }: { bridgeId: string; sourceConversationKey: string; options: HandoffPromptOptions }) => { + const entry = bridges.get(bridgeId) + if (!entry) return { ok: false, error: 'bridge not found' } + const refused = custodyRefusal(entry, bridgeId, 'handoff') + if (refused) return { ok: false, ...refused } + if (!entry.conversationKey || !sourceConversationKey) return { ok: false, error: 'handoff conversation scope is unavailable' } + if (entry.conversationKey === sourceConversationKey) return { ok: false, error: 'choose a different chat for context handoff' } + + const sourceHistory = loadConversation(sourceConversationKey) + if (sourceHistory.length === 0) return { ok: false, error: 'no source conversation history available to hand off' } + + const handoffTurnId = `${bridgeId}:handoff:${Date.now().toString(36)}` + entry.userInitiatedStop = false + entry.running = true + entry.lastActivityAt = Date.now() + custodyJournal().patch(bridgeId, { + status: 'running', turnId: handoffTurnId, turnStartedAt: Date.now(), + activePrompt: 'Prepare context handoff', authority: authorityOf(entry.opts), + }) + + try { + const summary = await summarizeHandoffWithDisposable(entry, sourceHistory, options) + if (!summary?.trim()) return { ok: false, error: 'handoff summary failed' } + + // A used destination keeps its own local history. Append a clearly framed + // packet, then clear native resume state so the next prompt replays both + // that history and the incoming context into a fresh provider session. + const targetHistory = loadConversation(entry.conversationKey) + saveConversation(entry.conversationKey, [ + ...targetHistory, + { role: 'user', content: `CrewCode context handoff from ${options.fromProvider ?? 'another provider'}. Continue with the imported context alongside this chat's existing history.` }, + { role: 'assistant', content: summary.trim() }, + ]) + if (entry.sessionKey) clearSessionId(entry.sessionKey) + replayInjectedForThread.delete(replayMarker(entry.conversationKey, entry.provider)) + entry.lastUsage = undefined + webContents.fromId(entry.webContentsId)?.send('bridge:event', { + type: 'handoff_summary', + bridgeId, + summary: summary.trim(), + fromProvider: options.fromProvider, + toProvider: options.toProvider ?? entry.provider, + reason: 'handoff', + } satisfies BridgeEvent) + + const teardownEntry = entry + queueMicrotask(() => { + teardownEntry.bridge.stop().catch(() => {}) + bridges.delete(bridgeId) + webContents.fromId(teardownEntry.webContentsId)?.send('bridge:event', { type: 'idle_stopped', bridgeId } satisfies BridgeEvent) + }) + return { ok: true } + } finally { + entry.running = false + entry.lastActivityAt = Date.now() + const record = custodyJournal().get(bridgeId) + if (record?.status === 'running' && record.turnId === handoffTurnId) { + custodyJournal().patch(bridgeId, { status: 'idle', turnId: undefined, turnStartedAt: undefined, activePrompt: undefined }) + } + } + }) + ipcMain.handle('bridge:compact', async (_e, { bridgeId }: { bridgeId: string }) => { const entry = bridges.get(bridgeId) if (!entry) return { ok: false, error: 'bridge not found' } diff --git a/src/main/brain-authorization-policy.test.ts b/src/main/brain-authorization-policy.test.ts new file mode 100644 index 0000000..a668ef8 --- /dev/null +++ b/src/main/brain-authorization-policy.test.ts @@ -0,0 +1,24 @@ +import { mkdirSync, mkdtempSync, statSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { describe, expect, it } from 'vitest' +import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain-authorization-policy' + +describe('Brain authorization policy', () => { + it('persists canonical roots, scopes, and local audit across restart', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'brain-policy-')); const root = join(dataDir, 'workspace'); mkdirSync(root) + let now = 100; const path = brainAuthorizationPolicyPath(dataDir) + const policy = new BrainAuthorizationPolicy(path, [root], ['workspace:read'], () => now) + now = 200 + const updated = policy.update({ roots: [root], scopes: ['agent', 'workspace:read'], userId: 'owner' }) + expect(updated).toMatchObject({ scopes: ['agent', 'workspace:read'], roots: [root], audit: [{ userId: 'owner', at: 200 }] }) + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(new BrainAuthorizationPolicy(path, [], [], () => 300).current()).toEqual(updated) + }) + it('rejects invalid policy', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'brain-policy-invalid-')) + const policy = new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(dataDir), [], []) + expect(() => policy.update({ roots: [], scopes: ['agent'], userId: 'owner' })).toThrow('at least one workspace root') + expect(() => policy.update({ roots: ['/definitely/missing'], scopes: [], userId: 'owner' })).toThrow('does not exist') + }) +}) diff --git a/src/main/brain-authorization-policy.ts b/src/main/brain-authorization-policy.ts new file mode 100644 index 0000000..1dab825 --- /dev/null +++ b/src/main/brain-authorization-policy.ts @@ -0,0 +1,68 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'fs' +import { dirname, join, sep } from 'path' +import type { BrainAccessScope } from '../shared/hub-relay-types' + +const VALID_SCOPES = new Set(['workspace:read', 'workspace:write', 'terminal', 'agent']) +const MAX_ROOTS = 100 +const MAX_AUDIT_EVENTS = 200 +export interface BrainAuthorizationAuditEvent { at: number; userId: string; previousScopes: BrainAccessScope[]; scopes: BrainAccessScope[]; previousRoots: string[]; roots: string[] } +export interface BrainAuthorizationSnapshot { version: 1; scopes: BrainAccessScope[]; roots: string[]; updatedAt: number; audit: BrainAuthorizationAuditEvent[] } + +function normalizeRoots(values: unknown[]): string[] { + if (values.length > MAX_ROOTS) throw new Error(`Brain authorization supports at most ${MAX_ROOTS} workspace roots`) + const roots: string[] = [] + for (const value of values) { + if (typeof value !== 'string' || !value.trim() || value.length > 4096) throw new Error('workspace roots must be non-empty paths') + if (!existsSync(value) || !statSync(value).isDirectory()) throw new Error(`workspace root does not exist or is not a directory: ${value}`) + const root = realpathSync(value) + if (!roots.includes(root)) roots.push(root) + } + return roots.sort() +} +function normalizeScopes(values: unknown[]): BrainAccessScope[] { + if (values.length > VALID_SCOPES.size) throw new Error('Brain scopes must be a bounded array') + const scopes = values.map(String) as BrainAccessScope[] + if (scopes.some(scope => !VALID_SCOPES.has(scope)) || new Set(scopes).size !== scopes.length) throw new Error('Brain scopes contain an invalid or duplicate value') + return scopes.sort() +} +export function pathWithinRoots(candidate: string, roots: string[]): boolean { + let resolved: string + try { resolved = realpathSync(candidate) } catch { return false } + return roots.some(root => resolved === root || resolved.startsWith(root + sep)) +} +export class BrainAuthorizationPolicy { + private snapshot: BrainAuthorizationSnapshot + constructor(readonly path: string, initialRoots: string[], initialScopes: BrainAccessScope[], private readonly now: () => number = Date.now) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + try { chmodSync(dirname(path), 0o700) } catch { /* Windows */ } + this.snapshot = this.load() ?? { version: 1, roots: normalizeRoots(initialRoots), scopes: normalizeScopes(initialScopes), updatedAt: this.now(), audit: [] } + this.persist() + } + current(): BrainAuthorizationSnapshot { return JSON.parse(JSON.stringify(this.snapshot)) as BrainAuthorizationSnapshot } + allowsScope(scope: BrainAccessScope): boolean { return this.snapshot.scopes.includes(scope) } + update(input: { roots: unknown; scopes: unknown; userId: string }): BrainAuthorizationSnapshot { + if (!Array.isArray(input.roots) || !Array.isArray(input.scopes)) throw new Error('roots and scopes must be arrays') + const roots = normalizeRoots(input.roots); const scopes = normalizeScopes(input.scopes) + if (scopes.length > 0 && roots.length === 0) throw new Error('remote scopes require at least one workspace root') + const previous = this.snapshot + const event: BrainAuthorizationAuditEvent = { at: this.now(), userId: input.userId, previousScopes: previous.scopes, scopes, previousRoots: previous.roots, roots } + this.snapshot = { version: 1, roots, scopes, updatedAt: event.at, audit: [...previous.audit, event].slice(-MAX_AUDIT_EVENTS) } + this.persist(); return this.current() + } + private load(): BrainAuthorizationSnapshot | null { + if (!existsSync(this.path)) return null + try { + const value = JSON.parse(readFileSync(this.path, 'utf8')) as Partial + if (value.version !== 1 || !Array.isArray(value.roots) || !Array.isArray(value.scopes)) throw new Error('invalid authorization policy') + return { version: 1, roots: normalizeRoots(value.roots), scopes: normalizeScopes(value.scopes), updatedAt: Number.isFinite(value.updatedAt) ? Number(value.updatedAt) : this.now(), audit: Array.isArray(value.audit) ? value.audit.slice(-MAX_AUDIT_EVENTS) as BrainAuthorizationAuditEvent[] : [] } + } catch (error) { throw new Error(`could not load Brain authorization policy ${this.path}: ${(error as Error).message}`) } + } + private persist(): void { + const temporary = `${this.path}.${process.pid}.tmp` + writeFileSync(temporary, `${JSON.stringify(this.snapshot, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }) + try { chmodSync(temporary, 0o600) } catch { /* Windows */ } + renameSync(temporary, this.path) + try { chmodSync(this.path, 0o600) } catch { /* Windows */ } + } +} +export function brainAuthorizationPolicyPath(dataDir: string): string { return join(dataDir, 'brain-authorization.json') } diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts index 1cad91c..1b8e3d5 100644 --- a/src/main/hub-brain-relay.ts +++ b/src/main/hub-brain-relay.ts @@ -12,6 +12,7 @@ import { type HubTunnelPlaintext, } from '../shared/hub-relay-types' import { resolveHeadlessAgentPath } from './headless-agent-resolver' +import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain-authorization-policy' import { loadConversation } from './agents/conversation-store' import type { MachineCredentialFile } from './hub-machine-enrollment' import { createBrainRelayCipher, type BrainRelayCipher } from './hub-relay-crypto' @@ -87,7 +88,8 @@ function websocketOrigin(origin: string): string { } export async function startBrainRelay(options: BrainRelayOptions): Promise { - const roots = options.allowedWorkspaceRoots.map(root => realpathSync(root)) + const policy = new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(options.dataDir), options.allowedWorkspaceRoots.map(root => realpathSync(root)), options.allowedScopes) + const roots = policy.current().roots const runtimeDataDir = join(options.dataDir, 'runtime') // Agent persistence helpers also run in Electron, where they fall back to // app.getPath(). A Brain is ordinary Node, so pin the same explicit runtime @@ -153,7 +155,6 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise | null = null const closeBackend = (): Promise => { @@ -241,7 +242,7 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise allowed.has(scope)) + const grantedScopes = frame.requestedScopes.filter(scope => policy.allowsScope(scope)) sessions.set(frame.connectionId, { connectionId: frame.connectionId, userId: frame.userId, grantedScopes: new Set(grantedScopes), expectedBrowserSequence: 0, brainSequence: 0 }) return } @@ -311,7 +312,27 @@ export async function startBrainRelay(options: BrainRelayOptions): Promise { + vi.unstubAllGlobals() while (directories.length) rmSync(directories.pop() as string, { recursive: true, force: true }) }) @@ -23,6 +26,28 @@ function directory(): string { return value } +describe('phone-approved device enrollment issuer', () => { + it('uses a short display code but keeps authority in a one-time 256-bit polling secret', () => { + let now = 1_000 + const issuer = new HubDeviceEnrollmentIssuer(() => now) + const keys = generateKeyPairSync('ed25519') + const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url') + const request = issuer.request({ publicKey, name: 'Cortex', platform: 'linux', version: 'test' }) + expect(request.userCode).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/) + expect(request.requestToken).not.toContain(request.userCode) + expect(Buffer.from(request.requestToken.split('.')[1], 'base64url')).toHaveLength(32) + expect(issuer.list()[0]).toMatchObject({ id: request.requestId, userCode: request.userCode, name: 'Cortex' }) + expect(issuer.poll(request.requestToken)).toEqual({ status: 'pending' }) + expect(issuer.approve(request.requestId, { machineId: 'a'.repeat(32), token: 'machine.secret' })).toBe(true) + expect(issuer.poll(request.requestToken)).toEqual({ status: 'approved', machineId: 'a'.repeat(32), token: 'machine.secret' }) + expect(issuer.poll(request.requestToken)).toBeNull() + + const expired = issuer.request({ publicKey, name: 'Old', platform: null, version: null }) + now = expired.expiresAt + expect(issuer.poll(expired.requestToken)).toBeNull() + }) +}) + describe('Hub machine client security', () => { it('accepts HTTPS and loopback HTTP Hub origins only', () => { expect(normalizeHubUrl('https://hub.example/')).toBe('https://hub.example') @@ -44,6 +69,31 @@ describe('Hub machine client security', () => { expect(() => parseBrainOptions(['--allow-scope', 'everything'], 'brain')).toThrow('invalid Brain scope') }) + it('polls privately until phone approval and persists the PC-generated identity', async () => { + const dataDir = directory() + const machineId = 'b'.repeat(32) + const machineToken = `${machineId}.${Buffer.alloc(32, 9).toString('base64url')}` + let polls = 0 + let requestedPublicKey = '' + vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { + if (url.endsWith('/request')) { + requestedPublicKey = (JSON.parse(String(init.body)) as { publicKey: string }).publicKey + return new Response(JSON.stringify({ requestToken: `request.${Buffer.alloc(32, 3).toString('base64url')}`, userCode: 'ABCD-2345', expiresAt: 10_000 }), { status: 201 }) + } + polls += 1 + return new Response(JSON.stringify(polls === 1 ? { status: 'pending' } : { status: 'approved', machineId, token: machineToken }), { status: polls === 1 ? 202 : 200 }) + })) + let now = 1_000 + const pending = vi.fn() + const credential = await enrollMachineByApproval({ + dataDir, hubOrigin: 'https://hub.example', name: 'Cortex', allowedWorkspaceRoots: [], allowedScopes: [], + }, { now: () => now, sleep: async ms => { now += ms }, onPending: pending }) + + expect(pending).toHaveBeenCalledWith(expect.objectContaining({ userCode: 'ABCD-2345', verificationUrl: 'https://hub.example' })) + expect(credential).toMatchObject({ machineId, token: machineToken, publicKey: requestedPublicKey }) + expect(readMachineCredential(machineCredentialPath(dataDir))).toEqual(credential) + }) + it('writes and validates an owner-only machine credential file', () => { const dataDir = directory() const path = machineCredentialPath(dataDir) diff --git a/src/main/hub-machine-enrollment.ts b/src/main/hub-machine-enrollment.ts index 3cb6ab1..093f733 100644 --- a/src/main/hub-machine-enrollment.ts +++ b/src/main/hub-machine-enrollment.ts @@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'path' import { homedir } from 'os' import type { BrainAccessScope } from '../shared/hub-relay-types' import { startBrainRelay } from './hub-brain-relay' +import { BrainAuthorizationPolicy, brainAuthorizationPolicyPath } from './brain-authorization-policy' export const HUB_ENROLLMENT_TTL_MS = 10 * 60_000 export const HUB_HEARTBEAT_INTERVAL_MS = 30_000 @@ -17,6 +18,31 @@ interface PendingEnrollment { expiresAt: number } +interface PendingDeviceEnrollment { + id: string + secretDigest: Buffer + userCode: string + publicKey: string + name: string + platform: string | null + version: string | null + createdAt: number + expiresAt: number + state: 'pending' | 'approved' | 'rejected' + credential?: { machineId: string; token: string } +} + +export interface DeviceEnrollmentSummary { + id: string + userCode: string + name: string + platform: string | null + version: string | null + publicKeyFingerprint: string + createdAt: number + expiresAt: number +} + export interface MachineCredentialFile { version: 1 hubOrigin: string @@ -31,6 +57,86 @@ function digest(value: string): Buffer { return createHash('sha256').update(value).digest() } +export class HubDeviceEnrollmentIssuer { + private readonly pending = new Map() + private readonly alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + constructor(private readonly now: () => number = Date.now, private readonly maxPending = 20) {} + + request(input: { publicKey: string; name: string; platform: string | null; version: string | null }): { + requestToken: string; requestId: string; userCode: string; expiresAt: number + } { + this.prune() + if (this.pending.size >= this.maxPending) throw new Error('too many pending device enrollments') + const id = randomBytes(16).toString('hex') + const secret = randomBytes(32).toString('base64url') + let code = '' + do { + const bytes = randomBytes(8) + code = `${[...bytes.subarray(0, 4)].map(value => this.alphabet[value % this.alphabet.length]).join('')}-${[...bytes.subarray(4)].map(value => this.alphabet[value % this.alphabet.length]).join('')}` + } while ([...this.pending.values()].some(item => item.userCode === code)) + const createdAt = this.now() + const expiresAt = createdAt + HUB_ENROLLMENT_TTL_MS + this.pending.set(id, { + id, secretDigest: digest(secret), userCode: code, + publicKey: input.publicKey, name: input.name, platform: input.platform, version: input.version, + createdAt, expiresAt, state: 'pending', + }) + return { requestToken: `${id}.${secret}`, requestId: id, userCode: code, expiresAt } + } + + list(): DeviceEnrollmentSummary[] { + this.prune() + return [...this.pending.values()].filter(item => item.state === 'pending').map(item => ({ + id: item.id, userCode: item.userCode, name: item.name, platform: item.platform, version: item.version, + publicKeyFingerprint: createHash('sha256').update(Buffer.from(item.publicKey, 'base64url')).digest('hex').match(/.{1,4}/g)?.slice(0, 4).join(':') ?? '', + createdAt: item.createdAt, expiresAt: item.expiresAt, + })) + } + + pendingRequest(id: string): PendingDeviceEnrollment | null { + this.prune() + const item = this.pending.get(id) + return item?.state === 'pending' ? item : null + } + + approve(id: string, credential: { machineId: string; token: string }): boolean { + const item = this.pendingRequest(id) + if (!item) return false + item.state = 'approved'; item.credential = credential + return true + } + + reject(id: string): boolean { + const item = this.pendingRequest(id) + if (!item) return false + item.state = 'rejected' + return true + } + + poll(requestToken: string): { status: 'pending' | 'rejected' } | { status: 'approved'; machineId: string; token: string } | null { + this.prune() + const separator = requestToken.indexOf('.') + if (separator < 1) return null + const id = requestToken.slice(0, separator) + const item = this.pending.get(id) + if (!item) return null + const supplied = digest(requestToken.slice(separator + 1)) + if (supplied.length !== item.secretDigest.length || !timingSafeEqual(supplied, item.secretDigest)) { + this.pending.delete(id) + return null + } + if (item.state === 'pending') return { status: 'pending' } + this.pending.delete(id) + if (item.state === 'rejected') return { status: 'rejected' } + return item.credential ? { status: 'approved', ...item.credential } : null + } + + private prune(): void { + const at = this.now() + for (const [id, item] of this.pending) if (item.expiresAt <= at) this.pending.delete(id) + } +} + export class HubEnrollmentIssuer { private readonly pending = new Map() @@ -180,21 +286,69 @@ async function hubRequest(origin: string, path: string, init: RequestInit): Prom return body } -export async function enrollMachine(options: BrainCliOptions, now = Date.now): Promise { - if (!options.hubOrigin || !options.token) throw new Error('Hub origin and enrollment token are required') +function newMachineIdentity(): { publicKey: string; privateKey: string } { + const keyPair = generateKeyPairSync('ed25519') + return { + publicKey: keyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + privateKey: keyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url'), + } +} + +function persistEnrolledMachine(options: BrainCliOptions, identity: { publicKey: string; privateKey: string }, result: Record, now: () => number): MachineCredentialFile { + if (!options.hubOrigin || typeof result.machineId !== 'string' || typeof result.token !== 'string') throw new Error('Hub returned an invalid machine credential') + const credential: MachineCredentialFile = { version: 1, hubOrigin: options.hubOrigin, machineId: result.machineId, token: result.token, ...identity, enrolledAt: now() } + writeMachineCredential(machineCredentialPath(options.dataDir), credential) + return credential +} + +function ensureEnrollmentDestination(options: BrainCliOptions): void { const credentialPath = machineCredentialPath(options.dataDir) if (existsSync(credentialPath)) throw new Error(`machine credential already exists at ${credentialPath}; revoke the old machine, then remove or move this file before enrolling again`) - const keyPair = generateKeyPairSync('ed25519') - const publicKey = keyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url') - const privateKey = keyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64url') +} + +export async function enrollMachine(options: BrainCliOptions, now = Date.now): Promise { + if (!options.hubOrigin || !options.token) throw new Error('Hub origin and enrollment token are required') + ensureEnrollmentDestination(options) + const identity = newMachineIdentity() const result = await hubRequest(options.hubOrigin, '/api/v1/hub/machines/enroll', { method: 'POST', - body: JSON.stringify({ enrollmentToken: options.token, publicKey, name: options.name, platform: platform(), version: process.env.npm_package_version ?? null }), + body: JSON.stringify({ enrollmentToken: options.token, publicKey: identity.publicKey, name: options.name, platform: platform(), version: process.env.npm_package_version ?? null }), }) - if (typeof result.machineId !== 'string' || typeof result.token !== 'string') throw new Error('Hub returned an invalid machine credential') - const credential: MachineCredentialFile = { version: 1, hubOrigin: options.hubOrigin, machineId: result.machineId, token: result.token, publicKey, privateKey, enrolledAt: now() } - writeMachineCredential(credentialPath, credential) - return credential + return persistEnrolledMachine(options, identity, result, now) +} + +export async function enrollMachineByApproval(options: BrainCliOptions, controls: { + now?: () => number + sleep?: (ms: number) => Promise + onPending?: (details: { userCode: string; publicKeyFingerprint: string; expiresAt: number; verificationUrl: string }) => void +} = {}): Promise { + if (!options.hubOrigin) throw new Error('Hub origin is required') + ensureEnrollmentDestination(options) + const identity = newMachineIdentity() + const now = controls.now ?? Date.now + const sleep = controls.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) + const requested = await hubRequest(options.hubOrigin, '/api/v1/hub/device-enrollments/request', { + method: 'POST', + body: JSON.stringify({ publicKey: identity.publicKey, name: options.name, platform: platform(), version: process.env.npm_package_version ?? null }), + }) + if (typeof requested.requestToken !== 'string' || typeof requested.userCode !== 'string' || typeof requested.expiresAt !== 'number') { + throw new Error('Hub returned an invalid device enrollment request') + } + controls.onPending?.({ + userCode: requested.userCode, + publicKeyFingerprint: createHash('sha256').update(Buffer.from(identity.publicKey, 'base64url')).digest('hex').match(/.{1,4}/g)?.slice(0, 4).join(':') ?? '', + expiresAt: requested.expiresAt, + verificationUrl: options.hubOrigin, + }) + while (now() < requested.expiresAt) { + const result = await hubRequest(options.hubOrigin, '/api/v1/hub/device-enrollments/poll', { + method: 'POST', body: JSON.stringify({ requestToken: requested.requestToken }), + }) + if (result.status === 'approved') return persistEnrolledMachine(options, identity, result, now) + if (result.status !== 'pending') throw new Error('Hub returned an invalid device enrollment status') + await sleep(Math.min(2_000, Math.max(0, requested.expiresAt - now()))) + } + throw new Error('device enrollment expired before owner approval') } export async function sendHeartbeat(credential: MachineCredentialFile): Promise { @@ -235,7 +389,7 @@ async function hiddenEnrollmentToken(): Promise { } function brainUsage(command: 'enroll' | 'brain'): string { - if (command === 'enroll') return `CrewCode machine enrollment\n\nUsage:\n crewcode enroll --hub [--name ] [--data-dir ]\n\nThe enrollment token is requested without echo in an interactive terminal. It is\nsingle-use and expires after ten minutes. --token is available only for controlled\nautomation because command-line arguments may be exposed in process lists/history.` + if (command === 'enroll') return `CrewCode machine enrollment\n\nUsage:\n crewcode enroll --hub [--name ] [--data-dir ]\n\nBy default, the PC prints a short code and waits for approval in the authenticated\nphone/Hub dashboard. --token keeps the legacy one-time token flow for controlled\nautomation only because command-line arguments may be exposed in process history.` return `CrewCode outbound Brain relay\n\nUsage:\n crewcode brain [--data-dir ] [--workspace-root ] [--allow-scope ]\n\nScopes (repeatable): workspace:read, workspace:write, terminal, agent.\nThe Brain grants no remote RPC scope by default. Workspace roots and scopes are\nBrain-local authorization; signing in to the Hub cannot widen them.` } @@ -243,9 +397,14 @@ export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[ const parsed = parseBrainOptions(argv, command) if ('help' in parsed) { console.log(brainUsage(command)); return } if (command === 'enroll') { - parsed.token ||= await hiddenEnrollmentToken() - if (!parsed.token) throw new Error('enrollment token is required') - const credential = await enrollMachine(parsed) + const credential = parsed.token + ? await enrollMachine(parsed) + : await enrollMachineByApproval(parsed, { onPending: details => { + console.log(`Approve this machine in the Hub dashboard:\n${details.verificationUrl}`) + console.log(`Code: ${details.userCode}`) + console.log(`Public-key fingerprint: ${details.publicKeyFingerprint}`) + console.log('Waiting for owner approval…') + } }) console.log(`Enrolled machine ${credential.machineId} with ${credential.hubOrigin}.`) console.log(`Credential stored at ${machineCredentialPath(parsed.dataDir)}.`) console.log('Run `crewcode brain` with explicit workspace roots and scopes to enable the outbound relay.') @@ -256,6 +415,7 @@ export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[ throw new Error('remote scopes require at least one explicit --workspace-root') } const credential = readMachineCredential(machineCredentialPath(parsed.dataDir)) + const authorization = new BrainAuthorizationPolicy(brainAuthorizationPolicyPath(parsed.dataDir), parsed.allowedWorkspaceRoots, parsed.allowedScopes).current() let stopped = false let activeRelay: Awaited> | null = null let wake: (() => void) | undefined @@ -267,8 +427,8 @@ export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[ process.once('SIGINT', shutdown) process.once('SIGTERM', shutdown) console.log(`CrewCode Brain connecting outbound to ${credential.hubOrigin}.`) - console.log(parsed.allowedScopes.length - ? `Brain-local grants: ${parsed.allowedScopes.join(', ')} under ${parsed.allowedWorkspaceRoots.join(', ')}.` + console.log(authorization.scopes.length + ? `Brain-local grants: ${authorization.scopes.join(', ')} under ${authorization.roots.join(', ')}.` : 'Brain-local grants: none. Hub users can connect, but all privileged RPC is denied.') while (!stopped) { @@ -277,8 +437,8 @@ export async function runBrainCommand(command: 'enroll' | 'brain', argv: string[ activeRelay = await startBrainRelay({ credential, dataDir: parsed.dataDir, - allowedWorkspaceRoots: parsed.allowedWorkspaceRoots, - allowedScopes: parsed.allowedScopes, + allowedWorkspaceRoots: authorization.roots, + allowedScopes: authorization.scopes, }) console.log('Authenticated outbound relay connected.') const heartbeat = setInterval(() => { diff --git a/src/main/hub-mobile-access.test.ts b/src/main/hub-mobile-access.test.ts new file mode 100644 index 0000000..e3340a3 --- /dev/null +++ b/src/main/hub-mobile-access.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { configureTailscaleServe, tailscaleHttpsOrigin, type RunCommand } from './hub-mobile-access' + +describe('Hub mobile Tailscale access', () => { + it('derives only a connected MagicDNS HTTPS origin', () => { + expect(tailscaleHttpsOrigin({ BackendState: 'Running', Self: { Online: true, DNSName: 'Cortex.tailnet.ts.net.' } })) + .toBe('https://cortex.tailnet.ts.net') + expect(() => tailscaleHttpsOrigin({ BackendState: 'NeedsLogin', Self: { Online: false }, Health: ['logged out'] })) + .toThrow('logged out') + expect(() => tailscaleHttpsOrigin({ BackendState: 'Running', Self: { Online: true, DNSName: '' } })) + .toThrow('MagicDNS') + }) + + it('configures a fixed local Hub port without replacing existing Serve routes', () => { + const run = vi.fn((_command, args) => { + if (args[0] === 'status') return { status: 0, stdout: JSON.stringify({ BackendState: 'Running', Self: { Online: true, DNSName: 'crew.tail.ts.net.' } }), stderr: '' } + if (args[0] === 'serve' && args[1] === 'status') return { status: 0, stdout: '{}', stderr: '' } + return { status: 0, stdout: '', stderr: '' } + }) + expect(configureTailscaleServe(3774, { run })).toEqual({ publicOrigin: 'https://crew.tail.ts.net', changed: true }) + expect(run).toHaveBeenCalledWith('tailscale', ['serve', '--bg', '--yes', '3774']) + + const matching = vi.fn((_command, args) => args[0] === 'status' + ? { status: 0, stdout: JSON.stringify({ BackendState: 'Running', Self: { Online: true, DNSName: 'crew.tail.ts.net.' } }), stderr: '' } + : { status: 0, stdout: JSON.stringify({ Web: { 'crew.tail.ts.net:443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:3774' } } } } }), stderr: '' }) + expect(configureTailscaleServe(3774, { run: matching })).toEqual({ publicOrigin: 'https://crew.tail.ts.net', changed: false }) + expect(matching).not.toHaveBeenCalledWith('tailscale', ['serve', '--bg', '--yes', '3774']) + + const occupied = vi.fn((_command, args) => args[0] === 'status' + ? { status: 0, stdout: JSON.stringify({ BackendState: 'Running', Self: { Online: true, DNSName: 'crew.tail.ts.net.' } }), stderr: '' } + : { status: 0, stdout: JSON.stringify({ Web: { crew: {} } }), stderr: '' }) + expect(() => configureTailscaleServe(3774, { run: occupied })).toThrow('different configuration') + }) + + it('rejects ephemeral ports because the proxy target must remain stable', () => { + expect(() => configureTailscaleServe(0, { run: vi.fn() })).toThrow('fixed Hub port') + }) +}) diff --git a/src/main/hub-mobile-access.ts b/src/main/hub-mobile-access.ts new file mode 100644 index 0000000..127fafb --- /dev/null +++ b/src/main/hub-mobile-access.ts @@ -0,0 +1,61 @@ +import { spawnSync } from 'child_process' + +export interface TailscaleStatus { + BackendState?: string + MagicDNSSuffix?: string + Self?: { DNSName?: string; Online?: boolean } + Health?: string[] +} + +export function tailscaleHttpsOrigin(status: TailscaleStatus): string { + if (status.BackendState !== 'Running' || status.Self?.Online !== true) { + const health = status.Health?.find(Boolean) + throw new Error(`Tailscale is not connected${health ? `: ${health}` : '; run `tailscale up` and sign in first'}`) + } + const hostname = String(status.Self.DNSName ?? '').replace(/\.$/, '').toLowerCase() + if (!hostname || !hostname.includes('.') || !/^[a-z0-9.-]+$/.test(hostname)) { + throw new Error('Tailscale MagicDNS hostname is unavailable; enable MagicDNS and HTTPS certificates in the tailnet') + } + return `https://${hostname}` +} + +export interface CommandResult { status: number | null; stdout: string; stderr: string; error?: Error } +export type RunCommand = (command: string, args: string[]) => CommandResult + +const runCommand: RunCommand = (command, args) => { + const result = spawnSync(command, args, { encoding: 'utf8' }) + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error } +} + +function checked(result: CommandResult, action: string): string { + if (result.error) throw new Error(`${action}: ${result.error.message}`) + if (result.status !== 0) throw new Error(`${action}: ${(result.stderr || result.stdout || `exit ${result.status}`).trim()}`) + return result.stdout +} + +export function configureTailscaleServe(port: number, options: { replace?: boolean; run?: RunCommand } = {}): { publicOrigin: string; changed: boolean } { + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('Tailscale mobile access requires a fixed Hub port') + const run = options.run ?? runCommand + const rawStatus = checked(run('tailscale', ['status', '--json']), 'could not inspect Tailscale status') + let status: TailscaleStatus + try { status = JSON.parse(rawStatus) as TailscaleStatus } catch { throw new Error('Tailscale returned invalid status JSON') } + const publicOrigin = tailscaleHttpsOrigin(status) + + const serveStatus = checked(run('tailscale', ['serve', 'status', '--json']), 'could not inspect Tailscale Serve') + let existing: unknown = null + try { existing = JSON.parse(serveStatus || '{}') } catch { existing = serveStatus.trim() } + const configured = typeof existing === 'object' && existing !== null && Object.keys(existing as object).length > 0 + if (configured) { + const hostname = new URL(publicOrigin).hostname + const web = (existing as { Web?: Record }> }).Web + const proxy = web?.[`${hostname}:443`]?.Handlers?.['/']?.Proxy + const expected = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]) + if (proxy && expected.has(proxy)) return { publicOrigin, changed: false } + if (!options.replace) { + throw new Error('Tailscale Serve already has a different configuration. Refusing to overwrite it; inspect `tailscale serve status` or rerun with --tailscale-replace after confirming replacement is safe') + } + checked(run('tailscale', ['serve', 'reset']), 'could not reset Tailscale Serve') + } + checked(run('tailscale', ['serve', '--bg', '--yes', String(port)]), 'could not configure Tailscale Serve') + return { publicOrigin, changed: true } +} diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts index 4701b0a..9e4b0c1 100644 --- a/src/main/hub-relay.test.ts +++ b/src/main/hub-relay.test.ts @@ -550,6 +550,25 @@ describe('authenticated encrypted Hub relay', () => { await second.close() }) + it('applies Brain authorization reductions immediately and stops affected resources', async () => { + const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write', 'terminal']) + const ticketResponse = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json', origin: hub.publicOrigin }, + body: JSON.stringify({ requestedScopes: ['workspace:write', 'terminal'] }), + }) + const { ticket } = await ticketResponse.json() as { ticket: string } + const session = await openEncryptedSession({ hub, ticket, machineId, publicKey }) + await session.rpc({ protocolVersion: 1, id: 'add-policy-root', method: 'workspaces.add', params: { path: workspaceRoot } }) + await session.rpc({ protocolVersion: 1, id: 'create-policy-pty', method: 'pty.create', params: { paneId: 'policy-pane', cwd: workspaceRoot } }) + await expect(session.rpc({ protocolVersion: 1, id: 'get-policy', method: 'brain.authorization.get', params: {} })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { roots: [workspaceRoot], scopes: ['terminal', 'workspace:write'] } } }) + await expect(session.rpc({ protocolVersion: 1, id: 'reduce-policy', method: 'brain.authorization.update', params: { roots: [workspaceRoot], scopes: ['workspace:write'] } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: true, result: { stopped: { paneIds: ['policy-pane'] } } } }) + await expect(session.rpc({ protocolVersion: 1, id: 'terminal-after-revoke', method: 'pty.write', params: { paneId: 'policy-pane', data: 'no' } })) + .resolves.toMatchObject({ type: 'rpcResult', response: { ok: false, error: { code: 'FORBIDDEN' } } }) + await session.close() + }) + it('tunnels attachment chunks end-to-end into the Brain workspace', async () => { const { hub, machineId, cookie, csrf, publicKey, workspaceRoot } = await fixture(['workspace:write']) const ticketResponse = await fetch(`${hub.url}/api/v1/hub/machines/${machineId}/tickets`, { diff --git a/src/main/hub-server.test.ts b/src/main/hub-server.test.ts index 0106844..779a5a8 100644 --- a/src/main/hub-server.test.ts +++ b/src/main/hub-server.test.ts @@ -25,7 +25,7 @@ async function server(): Promise { return running } -async function authenticatedServer(now: () => number): Promise<{ running: RunningHubServer; cookie: string; csrf: string }> { +async function authenticatedServer(now: () => number, publicOrigin?: string): Promise<{ running: RunningHubServer; cookie: string; csrf: string }> { const directory = temporaryDirectory() const store = new HubStore(join(directory, 'hub.sqlite')) const owner = store.createOwnerWithCredential({ @@ -37,9 +37,10 @@ async function authenticatedServer(now: () => number): Promise<{ running: Runnin }) const session = store.createSession(owner.id, now(), 10 * 60_000) store.close() - const running = await startHubServer({ dataDir: directory, port: 0, now }) + const running = await startHubServer({ dataDir: directory, port: 0, now, publicOrigin }) cleanups.push(() => running.close()) - return { running, cookie: `crewcode_hub_session=${encodeURIComponent(session.token)}`, csrf: session.csrf } + const cookieName = publicOrigin?.startsWith('https:') ? '__Host-crewcode_hub_session' : 'crewcode_hub_session' + return { running, cookie: `${cookieName}=${encodeURIComponent(session.token)}`, csrf: session.csrf } } describe('Hub enrollment credentials', () => { @@ -149,6 +150,53 @@ describe('Hub HTTP security boundary', () => { expect(configured.status).toBe(200) }) + it('serves the stable HTTPS mobile URL as an authenticated QR without credentials', async () => { + const { running, cookie } = await authenticatedServer(() => 10_000, 'https://crewcode.example') + const unauthorized = await fetch(`${running.url}/api/v1/hub/mobile-qr.svg`, { headers: { origin: 'https://crewcode.example' } }) + expect(unauthorized.status).toBe(401) + const response = await fetch(`${running.url}/api/v1/hub/mobile-qr.svg`, { headers: { origin: 'https://crewcode.example', cookie } }) + const svg = await response.text() + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('content-type')).toContain('image/svg+xml') + expect(svg).toContain(' { + const { running, cookie, csrf } = await authenticatedServer(() => 10_000) + const publicKey = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }).toString('base64url') + const requestedResponse = await fetch(`${running.url}/api/v1/hub/device-enrollments/request`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ publicKey, name: 'phone-approved-pc', platform: 'linux', version: 'test' }), + }) + expect(requestedResponse.status).toBe(201) + const requested = await requestedResponse.json() as { requestToken: string; requestId: string; userCode: string } + const pendingPoll = await fetch(`${running.url}/api/v1/hub/device-enrollments/poll`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ requestToken: requested.requestToken }), + }) + expect(pendingPoll.status).toBe(202) + + const listedResponse = await fetch(`${running.url}/api/v1/hub/device-enrollments`, { headers: { cookie } }) + const listedText = await listedResponse.text() + expect(JSON.parse(listedText)).toMatchObject({ requests: [{ id: requested.requestId, userCode: requested.userCode, name: 'phone-approved-pc', publicKeyFingerprint: expect.any(String) }] }) + expect(listedText).not.toContain(requested.requestToken) + const approvedResponse = await fetch(`${running.url}/api/v1/hub/device-enrollments/${requested.requestId}/approve`, { + method: 'POST', headers: { cookie, 'x-crewcode-csrf': csrf, 'content-type': 'application/json' }, body: '{}', + }) + const approvedText = await approvedResponse.text() + expect(approvedResponse.status).toBe(201) + expect(approvedText).not.toContain('token') + + const completed = await (await fetch(`${running.url}/api/v1/hub/device-enrollments/poll`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ requestToken: requested.requestToken }), + })).json() as { status: string; machineId: string; token: string } + expect(completed).toMatchObject({ status: 'approved', machineId: expect.any(String), token: expect.any(String) }) + expect((await fetch(`${running.url}/api/v1/hub/device-enrollments/poll`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ requestToken: requested.requestToken }), + })).status).toBe(401) + }) + it('enrolls, tracks, expires, and revokes an authenticated outbound machine', async () => { let time = 10_000 const { running, cookie, csrf } = await authenticatedServer(() => time) diff --git a/src/main/hub-server.ts b/src/main/hub-server.ts index fa63883..4a80262 100644 --- a/src/main/hub-server.ts +++ b/src/main/hub-server.ts @@ -16,8 +16,9 @@ import { type HubRelayControlFrame, } from '../shared/hub-relay-types' import { HubAuth } from './hub-auth' -import { HubEnrollmentIssuer, HUB_MACHINE_ONLINE_WINDOW_MS } from './hub-machine-enrollment' +import { HubDeviceEnrollmentIssuer, HubEnrollmentIssuer, HUB_MACHINE_ONLINE_WINDOW_MS } from './hub-machine-enrollment' import { HubStore, type HubSession } from './hub-store' +import QRCode from 'qrcode' const MAX_BODY_BYTES = 1024 * 1024 const HUB_AUTH_ATTEMPTS_PER_MINUTE = 30 @@ -163,14 +164,14 @@ function hubHtml(): string {

CREWCODE

Self-hosted Hub

Checking Hub…

- +

` } -const HUB_CSS = `:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0f120f;color:#d7e0dc}*{box-sizing:border-box}body{margin:0}main{min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(640px,100%);border:1px solid #1c2f2f;padding:28px;background:#0f120f}.eyebrow{font:600 11px/1.4 monospace;letter-spacing:.18em;color:#79958a}h1{margin:.25rem 0 1.25rem;font-size:26px}h2{font-size:15px;margin-top:24px}label{display:grid;gap:8px;margin:20px 0;font-size:13px}input,button{border:1px solid #285a48;background:#131a17;color:inherit;padding:10px 12px;font:inherit}button{cursor:pointer;background:#285a48}.quiet{background:transparent}.row,.machine{display:flex;align-items:center;justify-content:space-between;gap:16px}.machines{border-top:1px solid #1c2f2f;margin-bottom:14px;color:#8da49a;font:13px/1.5 monospace}.machine{padding:10px 0;border-bottom:1px solid #1c2f2f}.machine button{padding:5px 8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid #1c2f2f;padding:12px;color:#8da49a}.error{color:#d89595;min-height:1.4em}` +const HUB_CSS = `:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0f120f;color:#d7e0dc}*{box-sizing:border-box}body{margin:0}main{min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(640px,100%);border:1px solid #1c2f2f;padding:28px;background:#0f120f}.eyebrow{font:600 11px/1.4 monospace;letter-spacing:.18em;color:#79958a}h1{margin:.25rem 0 1.25rem;font-size:26px}h2{font-size:15px;margin-top:24px}label{display:grid;gap:8px;margin:20px 0;font-size:13px}input,button{border:1px solid #285a48;background:#131a17;color:inherit;padding:10px 12px;font:inherit}button{cursor:pointer;background:#285a48}.quiet{background:transparent}.row,.machine{display:flex;align-items:center;justify-content:space-between;gap:16px}.machines{border-top:1px solid #1c2f2f;margin-bottom:14px;color:#8da49a;font:13px/1.5 monospace}.machine{padding:10px 0;border-bottom:1px solid #1c2f2f}.machine button{padding:5px 8px}.muted{color:#8da49a;font-size:13px}#mobile{text-align:center}#mobile-qr{background:#fff;padding:8px;max-width:100%;height:auto}code{overflow-wrap:anywhere}pre{white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid #1c2f2f;padding:12px;color:#8da49a}.error{color:#d89595;min-height:1.4em}` const HUB_JS = `(()=>{'use strict'; -const $=id=>document.getElementById(id),status=$('status'),error=$('error');let csrf=''; +const $=id=>document.getElementById(id),status=$('status'),error=$('error');let csrf='',pendingProbe=false; const b64=b=>{const bytes=new Uint8Array(b);let s='';for(const x of bytes)s+=String.fromCharCode(x);return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'')}; const bytes=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');while(s.length%4)s+='=';const raw=atob(s);return Uint8Array.from(raw,c=>c.charCodeAt(0))}; const json=async(url,opts={})=>{const r=await fetch(url,{...opts,headers:{'content-type':'application/json',...(opts.headers||{})}});const body=await r.json();if(!r.ok)throw new Error(body.error||('Request failed: '+r.status));return body}; @@ -179,11 +180,12 @@ const creation=o=>({...o,challenge:bytes(o.challenge),user:{...o.user,id:bytes(o const request=o=>({...o,challenge:bytes(o.challenge),allowCredentials:(o.allowCredentials||[]).map(c=>({...c,id:bytes(c.id)}))}); const authError=e=>{const message=e&&e.message?e.message:String(e);if(!window.isSecureContext)return'Passkeys require a secure browser context. Open the exact localhost URL printed by CrewCode, or use the configured HTTPS Hub origin.';if(message.includes('InsecureLocalhostNotAllowed'))return'This browser or passkey provider refuses passkeys over HTTP localhost. For local testing, try current Chrome or Chromium. Otherwise run the Hub at its final HTTPS origin and create the passkey there.';return message}; function view(name){for(const id of ['setup','signin','dashboard'])$(id).hidden=id!==name} -async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');if(x.status==='online'){const open=document.createElement('button');open.textContent='Open';open.onclick=()=>{location.href='/app?machine='+encodeURIComponent(x.id)};actions.append(open)}if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(revoke)}row.append(actions);list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} +async function refresh(){error.textContent='';const s=await json('/api/v1/hub/status');if(!s.ownerConfigured){view('setup');status.textContent=location.hash.includes('bootstrap=')?'Register the first owner passkey.':'Open the one-time setup URL printed by crewcode hub.';return}try{const me=await json('/api/v1/hub/session');csrf=me.csrf;view('dashboard');status.textContent='Hub ready';$('username').textContent=me.user.username;const mobile=$('mobile');mobile.hidden=location.protocol!=='https:';if(!mobile.hidden){$('mobile-url').textContent=location.origin;$('mobile-qr').src='/api/v1/hub/mobile-qr.svg'}const pending=await json('/api/v1/hub/device-enrollments'),pendingWrap=$('pending-wrap'),pendingList=$('pending-machines');pendingList.textContent='';pendingWrap.hidden=!pending.requests.length;for(const x of pending.requests){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · code '+x.userCode+' · fingerprint '+x.publicKeyFingerprint+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');const approve=document.createElement('button');approve.textContent='Approve';approve.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/approve',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};const reject=document.createElement('button');reject.className='quiet';reject.textContent='Reject';reject.onclick=async()=>{try{await json('/api/v1/hub/device-enrollments/'+encodeURIComponent(x.id)+'/reject',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(approve,reject);row.append(actions);pendingList.append(row)}const m=await json('/api/v1/hub/machines'),list=$('machines');list.textContent='';if(!m.machines.length)list.textContent='No machines enrolled yet.';for(const x of m.machines){const row=document.createElement('div');row.className='machine';const label=document.createElement('span');label.textContent=x.name+' · '+x.status+(x.platform?' · '+x.platform:'');row.append(label);const actions=document.createElement('span');if(x.status==='online'){const open=document.createElement('button');open.textContent='Open';open.onclick=()=>{location.href='/app?machine='+encodeURIComponent(x.id)};actions.append(open)}if(x.status!=='revoked'){const revoke=document.createElement('button');revoke.className='quiet';revoke.textContent='Revoke';revoke.onclick=async()=>{try{await json('/api/v1/hub/machines/'+encodeURIComponent(x.id)+'/revoke',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});await refresh()}catch(e){error.textContent=e.message}};actions.append(revoke)}row.append(actions);list.append(row)}}catch{view('signin');status.textContent='Sign in to view your machines.'}} $('setup-button').onclick=async()=>{try{error.textContent='';const token=new URLSearchParams(location.hash.slice(1)).get('bootstrap')||'';const username=$('owner').value;const start=await json('/api/v1/hub/bootstrap/options',{method:'POST',body:JSON.stringify({token,username})});const credential=await navigator.credentials.create({publicKey:creation(start.options)});const done=await json('/api/v1/hub/bootstrap/verify',{method:'POST',body:JSON.stringify({token,username,flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;history.replaceState(null,'',location.pathname);await refresh()}catch(e){error.textContent=authError(e)}}; $('signin-button').onclick=async()=>{try{error.textContent='';const start=await json('/api/v1/hub/auth/options',{method:'POST',body:'{}'});const credential=await navigator.credentials.get({publicKey:request(start.options)});const done=await json('/api/v1/hub/auth/verify',{method:'POST',body:JSON.stringify({flowId:start.flowId,response:credentialJSON(credential)})});csrf=done.csrf;await refresh()}catch(e){error.textContent=authError(e)}}; $('enrollment-button').onclick=async()=>{try{error.textContent='';const issued=await json('/api/v1/hub/enrollments',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'}),out=$('enrollment');out.hidden=false;out.textContent='Enrollment token (single use; do not share):\\n'+issued.token+'\\n\\nRun on the machine within 10 minutes, then paste the token when prompted:\\ncrewcode enroll --hub '+location.origin}catch(e){error.textContent=e.message}}; $('logout-button').onclick=async()=>{try{await json('/api/v1/hub/logout',{method:'POST',headers:{'x-crewcode-csrf':csrf},body:'{}'});csrf='';$('enrollment').hidden=true;$('enrollment').textContent='';await refresh()}catch(e){error.textContent=e.message}}; +setInterval(()=>{if($('dashboard').hidden||!$('pending-wrap').hidden||pendingProbe)return;pendingProbe=true;json('/api/v1/hub/device-enrollments').then(p=>{if(p.requests.length)return refresh()}).catch(()=>{}).finally(()=>{pendingProbe=false})},3000); refresh().catch(e=>{status.textContent='Could not connect';error.textContent=e.message});})();` function serveHubApp(webRoot: string | undefined, pathname: string, response: ServerResponse): boolean { @@ -215,7 +217,9 @@ function serveAsset(pathname: string, response: ServerResponse): boolean { response.writeHead(200, { 'content-type': type, 'content-length': Buffer.byteLength(body), - 'cache-control': pathname === '/' ? 'no-store' : 'public, max-age=300', + // Hub setup/dashboard assets are tiny and contain deployment control flow. + // Never let a phone retain stale enrollment or bootstrap behavior. + 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', @@ -231,6 +235,7 @@ export async function startHubServer(options: HubServerOptions): Promise { it('prints a clickable terminal link with a plain-text fallback', () => { expect(terminalLink('Open setup', 'http://localhost:3774/#bootstrap=secret', true)).toBe('\u001B]8;;http://localhost:3774/#bootstrap=secret\u0007Open setup\u001B]8;;\u0007') expect(terminalLink('Open setup', 'http://localhost:3774/#bootstrap=secret', false)).toBe('http://localhost:3774/#bootstrap=secret') }) + it('uses the one-time bootstrap URL only for initial-owner QR setup', () => { + const origin = 'https://cortex.tail.ts.net' + const bootstrap = `${origin}/#bootstrap=one-time-secret` + expect(mobileQrTarget(origin, bootstrap)).toEqual({ url: bootstrap, containsCredential: true }) + expect(mobileQrTarget(origin)).toEqual({ url: origin, containsCredential: false }) + }) + it('uses safe loopback defaults', () => { expect(parseHubOptions([], '/tmp')).toMatchObject({ host: '127.0.0.1', port: 3774 }) }) @@ -17,9 +24,18 @@ describe('Hub CLI options', () => { port: 4444, dataDir: resolve('/tmp', 'state'), publicOrigin: 'https://crewcode.example', + mobile: false, + tailscale: false, + tailscaleReplace: false, }) }) + it('supports Tailscale and generic HTTPS mobile modes', () => { + expect(parseHubOptions(['mobile'], '/tmp')).toMatchObject({ mobile: true, tailscale: true, port: 3774 }) + expect(parseHubOptions(['mobile', '--public-origin', 'https://crewcode.example'], '/tmp')).toMatchObject({ mobile: true, tailscale: false, publicOrigin: 'https://crewcode.example' }) + expect(() => parseHubOptions(['mobile', '--tailscale', '--public-origin', 'https://crewcode.example'], '/tmp')).toThrow('either --tailscale or --public-origin') + }) + it('requires a final public origin for wildcard binds', () => { expect(() => parseHubOptions(['--host', '0.0.0.0'])).toThrow('--public-origin is required') }) diff --git a/src/main/hub.ts b/src/main/hub.ts index f751b95..b6ffcee 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -2,12 +2,17 @@ import { homedir } from 'os' import { existsSync } from 'fs' import { join, resolve } from 'path' import { startHubServer } from './hub-server' +import { configureTailscaleServe } from './hub-mobile-access' +import QRCode from 'qrcode' export interface HubCliOptions { host: string port: number dataDir: string publicOrigin?: string + mobile: boolean + tailscale: boolean + tailscaleReplace: boolean } function usage(): string { @@ -15,17 +20,21 @@ function usage(): string { Usage: crewcode hub [options] + crewcode hub mobile [--tailscale] [options] Options: --host
Bind address (default: 127.0.0.1) --port TCP port, 0 chooses an available port (default: 3774) --data-dir Hub state directory (default: ~/.crewcode/hub) - --public-origin Final HTTPS browser origin (required for network binds) + --public-origin Final HTTPS browser origin (generic reverse proxy/domain) + --tailscale Configure Tailscale Serve HTTPS for the fixed Hub port + --tailscale-replace Explicitly replace an existing Tailscale Serve config --help Show this help Examples: crewcode hub - crewcode hub --host 0.0.0.0 --public-origin https://crewcode.example + crewcode hub mobile --tailscale + crewcode hub mobile --public-origin https://crewcode.example The public origin is cryptographically bound to passkeys. Choose the final LAN, Tailscale, or user-controlled HTTPS name before creating the owner passkey.` @@ -48,12 +57,16 @@ export function normalizeHubOrigin(value: string): string { } export function parseHubOptions(argv: string[], cwd = process.cwd()): HubCliOptions | { help: true } { - const args = argv[0] === 'hub' ? argv.slice(1) : argv + const rawArgs = argv[0] === 'hub' ? argv.slice(1) : argv + const mobile = rawArgs[0] === 'mobile' + const args = mobile ? rawArgs.slice(1) : rawArgs if (args.includes('--help') || args.includes('-h')) return { help: true } let host = '127.0.0.1' let port = 3774 let dataDir = join(homedir(), '.crewcode', 'hub') let publicOrigin: string | undefined + let tailscale = false + let tailscaleReplace = false for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--host') host = valueAfter(args, index++, arg) @@ -63,16 +76,26 @@ export function parseHubOptions(argv: string[], cwd = process.cwd()): HubCliOpti if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error(`invalid port: ${raw}`) } else if (arg === '--data-dir') dataDir = resolve(cwd, valueAfter(args, index++, arg)) else if (arg === '--public-origin') publicOrigin = normalizeHubOrigin(valueAfter(args, index++, arg)) + else if (arg === '--tailscale') tailscale = true + else if (arg === '--tailscale-replace') { tailscale = true; tailscaleReplace = true } else throw new Error(`unknown option: ${arg}`) } + if (tailscale && publicOrigin) throw new Error('choose either --tailscale or --public-origin, not both') + if (mobile && !tailscale && !publicOrigin) tailscale = true if ((host === '0.0.0.0' || host === '::') && !publicOrigin) throw new Error('--public-origin is required for network Hub binds') - return { host, port, dataDir, publicOrigin } + return { host, port, dataDir, publicOrigin, mobile, tailscale, tailscaleReplace } } export function terminalLink(label: string, url: string, isTerminal = Boolean(process.stdout.isTTY)): string { return isTerminal ? `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007` : url } +export function mobileQrTarget(publicOrigin: string, bootstrapUrl?: string): { url: string; containsCredential: boolean } { + return bootstrapUrl + ? { url: bootstrapUrl, containsCredential: true } + : { url: publicOrigin, containsCredential: false } +} + function defaultWebRoot(): string | undefined { const candidates = [resolve(__dirname, '../renderer'), resolve(__dirname, '../../out/renderer')] return candidates.find(candidate => existsSync(join(candidate, 'index.html'))) @@ -81,14 +104,26 @@ function defaultWebRoot(): string | undefined { export async function runHub(argv = process.argv.slice(2)): Promise { const parsed = parseHubOptions(argv) if ('help' in parsed) { console.log(usage()); return } + if (parsed.tailscale) parsed.publicOrigin = configureTailscaleServe(parsed.port, { replace: parsed.tailscaleReplace }).publicOrigin const hub = await startHubServer({ ...parsed, webRoot: defaultWebRoot() }) console.log(`CrewCode Hub listening on ${hub.url}`) console.log(`Hub browser origin: ${hub.publicOrigin}`) if (hub.bootstrapUrl) { console.log(`Create the first owner passkey (single use, expires in 10 minutes):\n${terminalLink('Open owner passkey setup', hub.bootstrapUrl)}`) - if (process.stdout.isTTY) console.log(`If the link is not clickable, copy this URL:\n${hub.bootstrapUrl}`) + if (parsed.mobile || parsed.publicOrigin?.startsWith('https://')) { + // Initial owner registration necessarily carries the short-lived bootstrap + // secret. Label it distinctly; after setup, every normal mobile QR returns + // to containing only the stable, credential-free Hub origin. + const qr = mobileQrTarget(hub.publicOrigin, hub.bootstrapUrl) + console.log(`Scan once to create the Hub owner (contains a one-time 10-minute setup credential):\n${await QRCode.toString(qr.url, { type: 'terminal', small: true })}${qr.url}`) + } else if (process.stdout.isTTY) console.log(`If the link is not clickable, copy this URL:\n${hub.bootstrapUrl}`) + } else { + console.log('Hub owner is configured. Sign in with a registered passkey.') + if (parsed.mobile || parsed.publicOrigin?.startsWith('https://')) { + const qr = mobileQrTarget(hub.publicOrigin) + console.log(`Scan to open CrewCode on your phone (URL only; no credential is embedded):\n${await QRCode.toString(qr.url, { type: 'terminal', small: true })}${qr.url}`) + } } - else console.log('Hub owner is configured. Sign in with a registered passkey.') if (parsed.host === '0.0.0.0' || parsed.host === '::') console.warn('Network access is enabled. Terminate TLS at the configured public origin.') const shutdown = (): void => { void hub.close().finally(() => process.exit(0)) } process.once('SIGINT', shutdown) diff --git a/src/main/index.ts b/src/main/index.ts index 9bc4cce..bf7e4cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -59,10 +59,34 @@ import { type VoiceTranscriptionRequest, } from '../shared/voice-types' import { localVoiceService } from './local-voice-service' +import { packagedHeadlessArgs } from './packaged-cli-dispatch' const { app, BrowserWindow, clipboard, ipcMain, nativeImage, protocol, session, shell } = electron import { spawn } from 'child_process' +// The packaged AppImage executable is also named `crewcode`. Dispatch recognized +// server commands in the main process with all window initialization disabled: +// `crewcode` remains desktop, while `crewcode hub|serve|brain|enroll` is headless. +// Running here (rather than ELECTRON_RUN_AS_NODE) preserves compatibility for +// shared backend modules that intentionally import Electron's app-path adapter. +const packagedCliArgs = app.isPackaged ? packagedHeadlessArgs(process.argv) : null +if (packagedCliArgs) { + const [command, ...args] = packagedCliArgs + const run = command === 'hub' + ? import('./hub').then(module => module.runHub(args)) + : command === 'serve' + ? import('./headless').then(module => module.runHeadless(args)) + : import('./hub-machine-enrollment').then(module => module.runBrainCommand(command as 'brain' | 'enroll', args)) + void run.then(() => { + // Long-running Hub/direct servers return after binding and remain alive on + // their sockets. Help and enrollment are finite commands and should exit. + if (command === 'enroll' || args.includes('--help') || args.includes('-h')) app.exit(0) + }).catch(error => { + console.error((error as Error).message) + app.exit(1) + }) +} + const isDev = process.env['NODE_ENV'] === 'development' function isWaylandSession(): boolean { @@ -159,13 +183,13 @@ async function loadReactDevtools(): Promise { console.warn('[devtools] React DevTools auto-install is disabled on Electron 42; use CREWCODE_REACT_DEVTOOLS=1 only after migrating to session.extensions.* APIs.') } -if (isWaylandSession()) { +if (!packagedCliArgs && isWaylandSession()) { // Chromium's Vulkan surface path can be unstable on Wayland/NVIDIA; keep // Wayland enabled while avoiding that compatibility path. app.commandLine.appendSwitch('disable-vulkan-surface') } -app.whenReady().then(async () => { +if (!packagedCliArgs) app.whenReady().then(async () => { registerPtyIpc() registerWorkspaceIpc() registerFsIpc() diff --git a/src/main/packaged-cli-dispatch.test.ts b/src/main/packaged-cli-dispatch.test.ts new file mode 100644 index 0000000..3b86472 --- /dev/null +++ b/src/main/packaged-cli-dispatch.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { packagedHeadlessArgs } from './packaged-cli-dispatch' + +describe('packaged CrewCode CLI dispatch', () => { + it('keeps bare and unrelated invocations in the desktop app', () => { + expect(packagedHeadlessArgs(['/opt/CrewCode/crewcode'])).toBeNull() + expect(packagedHeadlessArgs(['/opt/CrewCode/crewcode', '--help'])).toBeNull() + expect(packagedHeadlessArgs(['/opt/CrewCode/crewcode', 'unknown'])).toBeNull() + }) + + it('routes supported headless commands with all arguments intact', () => { + expect(packagedHeadlessArgs(['/tmp/.mount/crewcode', 'hub', 'mobile', '--tailscale', '--data-dir', '/state'])) + .toEqual(['hub', 'mobile', '--tailscale', '--data-dir', '/state']) + expect(packagedHeadlessArgs(['/tmp/.mount/crewcode', 'brain', '--data-dir', '/brain'])) + .toEqual(['brain', '--data-dir', '/brain']) + expect(packagedHeadlessArgs(['/tmp/.mount/crewcode', 'enroll', '--hub', 'https://hub.example'])) + .toEqual(['enroll', '--hub', 'https://hub.example']) + expect(packagedHeadlessArgs(['/tmp/.mount/crewcode', 'serve'])).toEqual(['serve']) + }) +}) diff --git a/src/main/packaged-cli-dispatch.ts b/src/main/packaged-cli-dispatch.ts new file mode 100644 index 0000000..10ba246 --- /dev/null +++ b/src/main/packaged-cli-dispatch.ts @@ -0,0 +1,7 @@ +export const PACKAGED_HEADLESS_COMMANDS = new Set(['hub', 'serve', 'brain', 'enroll']) + +/** User arguments accepted by the packaged Electron executable as headless CLI. */ +export function packagedHeadlessArgs(argv: string[]): string[] | null { + const args = argv.slice(1) + return args[0] && PACKAGED_HEADLESS_COMMANDS.has(args[0]) ? args : null +} diff --git a/src/main/pty-service.ts b/src/main/pty-service.ts index a8329bf..c8090f1 100644 --- a/src/main/pty-service.ts +++ b/src/main/pty-service.ts @@ -22,6 +22,7 @@ function remoteShellArgv(root: string): string[] | null { interface Pane { proc: IPty buffer: string + cwd: string } // Replayed when a React terminal surface remounts (workspace/tab switches). @@ -144,7 +145,7 @@ export class PtyService { let proc: IPty try { proc = pty.spawn(exe, remoteArgs ?? argv, { name: 'xterm-256color', cols, rows, cwd: actualCwd, env: ptyEnv }) } catch (error) { return { error: `pty spawn failed: ${error instanceof Error ? error.message : String(error)}` } } - const pane: Pane = { proc, buffer: '' } + const pane: Pane = { proc, buffer: '', cwd: actualCwd } proc.onData(data => { appendReplayBuffer(pane, data) this.emit({ type: 'data', paneId, data }) @@ -177,6 +178,15 @@ export class PtyService { this.pendingWrites.delete(paneId) } + killWhere(predicate: (cwd: string) => boolean): string[] { + const killed: string[] = [] + for (const [paneId, pane] of this.panes) { + if (!predicate(pane.cwd)) continue + this.kill(paneId); killed.push(paneId) + } + return killed + } + killAll(): void { for (const pane of this.panes.values()) try { pane.proc.kill() } catch { /* exited */ } this.panes.clear() diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index d0a2f67..c349b97 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -60,6 +60,8 @@ export interface RunningRemoteAccessServer { pairingToken: string pairingUrl: string close: () => Promise + updateAllowedWorkspaceRoots(roots: string[]): void + stopResources(input: { terminal: boolean; agent: boolean; allowedRoots: string[] }): Promise<{ paneIds: string[]; bridgeIds: string[] }> } type RpcHandler = (params: Record) => unknown | Promise @@ -161,7 +163,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const auth = options.auth ?? new RemoteAccessAuth({ storePath: join(options.dataDir, 'remote-access-sessions.json') }) const pairingLimiter = new RemoteAccessRateLimiter(REMOTE_PAIR_ATTEMPTS_PER_WINDOW) const unauthenticatedLimiter = new RemoteAccessRateLimiter(REMOTE_UNAUTHENTICATED_ATTEMPTS_PER_WINDOW) - const allowedWorkspaceRoots = (options.allowedWorkspaceRoots?.length ? options.allowedWorkspaceRoots : [homedir()]) + let allowedWorkspaceRoots = (options.allowedWorkspaceRoots?.length ? options.allowedWorkspaceRoots : [homedir()]) .map(root => realpathSync(root)) const allowedPath = (candidate: unknown): string => { const raw = String(candidate ?? '').trim() @@ -196,7 +198,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions // workspace store. Desktop IPC historically accepts any caller-supplied root; // carrying that behavior onto the network would expose the whole host filesystem. const registeredRoot = (params: Record): string => { - const root = String(params.root ?? '') + const root = allowedPath(params.root) if (!workspaceService.list().some(workspace => workspace.path === root)) { throw Object.assign(new Error('filesystem root is not a registered workspace'), { remoteCode: 'FORBIDDEN' }) } @@ -234,7 +236,9 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions const handlers = new Map([ ['auth.sessions', () => auth.list()], ['auth.revoke', params => ({ revoked: auth.revoke(String(params.sessionId ?? '')) })], - ['workspaces.list', () => workspaceService.list()], + ['workspaces.list', () => workspaceService.list().filter(workspace => { + try { allowedPath(workspace.path); return true } catch { return false } + })], ['workspaces.inspectPath', params => { const path = allowedPath(params.path) if (!statSync(path).isDirectory()) throw new Error('path is not a directory') @@ -445,6 +449,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions }], ['bridge.prompt', params => agentService.prompt(String(params.bridgeId ?? ''), String(params.text ?? ''), params.options as PromptOptions | undefined)], ['bridge.compact', params => agentService.compact(String(params.bridgeId ?? ''))], + ['bridge.handoff', params => agentService.handoff(String(params.bridgeId ?? ''), String(params.sourceConversationKey ?? ''))], ['bridge.removeFollowUp', params => agentService.removeFollowUp(String(params.bridgeId ?? ''), String(params.followUpId ?? ''))], ['bridge.respondUserRequest', params => agentService.respond(params.response as Parameters[0])], // Returns { deferred, reason } when the change was refused mid-turn, so a @@ -587,6 +592,13 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions url, pairingToken: pairing.token, pairingUrl: `${url}/pair#token=${encodeURIComponent(pairing.token)}`, + updateAllowedWorkspaceRoots(roots) { allowedWorkspaceRoots = roots.map(root => realpathSync(root)) }, + async stopResources(input) { + const permitted = (cwd: string): boolean => input.allowedRoots.some(root => cwd === root || cwd.startsWith(root + sep)) + const paneIds = input.terminal ? ptyService.killWhere(() => true) : ptyService.killWhere(cwd => !permitted(cwd)) + const bridgeIds = await agentService.stopWhere(entry => input.agent || !permitted(entry.cwd)) + return { paneIds, bridgeIds } + }, close: () => new Promise((resolve, reject) => { clearInterval(attachmentSweep) for (const uploadId of [...attachmentUploads.keys()]) discardAttachmentUpload(uploadId) diff --git a/src/preload/index.ts b/src/preload/index.ts index 1e60967..3796b00 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -168,6 +168,14 @@ contextBridge.exposeInMainWorld('electronAPI', { bridgeCompact: (bridgeId: string) => ipcRenderer.invoke('bridge:compact', { bridgeId }), + bridgeHandoff: (bridgeId: string, sourceConversationKey: string, options: { + fromProvider?: string + toProvider?: string + model?: string + mode?: 'ask' | 'plan' | 'build' | 'full' + workspace?: { name?: string; path?: string; branch?: string } + }) => ipcRenderer.invoke('bridge:handoff', { bridgeId, sourceConversationKey, options }), + // Cancel a locally queued follow-up (claude) before the bridge sends it. bridgeRemoveFollowUp: (bridgeId: string, followUpId: string) => ipcRenderer.invoke('bridge:removeFollowUp', { bridgeId, followUpId }), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a48ce0c..461117f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -38,7 +38,7 @@ import { chatSessionOwnerWorkspaceId } from './hooks/chat-session-tab-owner' import type { Prompt as PromptDef, Skill as SkillDef } from './types/prompts' import { Icon } from './components/ui/Icon' import { LoadingScreen } from './components/ui/LoadingScreen' -import { MobileShell, useMobileShell, type MobileTab } from './components/ui/MobileShell' +import { MobileShell, useMobileShell } from './components/ui/MobileShell' import type { AgentActivityState } from './components/ui/AgentActivityIndicator' import { Onboarding } from './components/onboarding/Onboarding' import { NotificationBar } from './components/ui/NotificationBar' @@ -78,6 +78,7 @@ import { knownModelIds } from './hooks/useProviderModels' import { dockUsageProviderId } from './hooks/dock-usage-provider' import { ChatNotifications } from './components/thread/ChatNotifications' import { useGlobalShortcuts } from './hooks/useGlobalShortcuts' +import { useMobileWindowTabsAutoHide } from './hooks/useMobileWindowTabsAutoHide' import { LOCAL_SHORTCUTS, effectiveChord, matchesChord, type ActionId } from './shortcuts' import { useTerminalSessions } from './hooks/useTerminalSessions' import { useTerminalUnreadSync, useClearPane } from './stores/terminal-unread-store' @@ -130,6 +131,7 @@ const GIT_WIDTH_STORAGE = 'crewcode:gitWidthByTab:v1' const CHAT_UI_STORAGE = 'crewcode:chatUiByTab:v1' const WORKBENCH_PANES_STORAGE = 'crewcode:workbenchPanesByTab:v1' const CHANGES_DRAWER_STORAGE = 'crewcode:changesDrawerOpenBySurface:v1' +const MOBILE_DRAWER_SIDE_STORAGE = 'crewcode:mobileDrawerSide:v1' function readLastActiveWorkspaceId(): string { try { return localStorage.getItem(ACTIVE_WORKSPACE_STORAGE) ?? '' } catch { return '' } @@ -328,6 +330,11 @@ export default function App() { }, [setGitWidthByTab]) // ── Mobile shell ────────────────────────────────────────────────────────── const mobile = useMobileShell() + const [storedMobileDrawerSide, setMobileDrawerSide] = useLocalStorageJsonState<'left' | 'right'>(MOBILE_DRAWER_SIDE_STORAGE, 'left') + const mobileDrawerSide = storedMobileDrawerSide === 'right' ? 'right' : 'left' + // A desktop bottom-drawer preference must never turn into a bottom sheet on + // phones. Mobile keeps its own side preference so desktop layout is untouched. + const effectiveDrawerPosition = mobile.isMobile ? mobileDrawerSide : tweaks.drawerPosition // ── Tabs per workspace ─────────────────────────────────────────────────── const { @@ -497,6 +504,7 @@ export default function App() { model: '', mode: settings.defaultMode as ModeLevel, effort: 'medium', + initialBranch: settings.defaultBranchByWorkspace[activeWs] ?? '', }) useEffect(() => { if (activeTab?.kind === 'chat') chatSessions.ensureTab(activeTabId, activeWorkspace.name) @@ -585,6 +593,52 @@ export default function App() { return byTab }, [activeTab?.kind, activeTabId, allTabIds, chatSessions.sessionsByTab, ws.workspaces]) + const provisioningBranchSessionsRef = useRef(new Set()) + useEffect(() => { + for (const list of Object.values(chatSessions.sessionsByTab) as Session[][]) { + for (const session of list) { + const branch = session.initialBranch?.trim() + if (!branch || session.origin === 'delegated' || provisioningBranchSessionsRef.current.has(session.id)) continue + const workspaceId = workspaceByChatTabId[session.tabId] + const workspace = ws.workspaces.find(candidate => candidate.id === workspaceId) + if (!workspace || workspace.kind !== 'repo') continue + + provisioningBranchSessionsRef.current.add(session.id) + void (async () => { + try { + const selectionKey = worktreeSelectionKey(session.tabId, 'chat', session.id) + if (branch === workspace.branch) { + setSurfaceWorktreeIds(prev => ({ ...prev, [selectionKey]: null })) + } else { + let worktree = workspace.worktrees.find(candidate => candidate.branch === branch) + if (!worktree) { + const beforeCreate = await window.electronAPI?.worktreeList(workspace.path) + worktree = beforeCreate?.worktrees?.find(candidate => candidate.branch === branch) + } + if (!worktree) { + const created = await window.electronAPI?.worktreeCreate(workspace.path, branch) + if (!created?.path || created.error) throw new Error(created?.error ?? `Unable to create a worktree for ${branch}`) + const listed = await window.electronAPI?.worktreeList(workspace.path) + worktree = listed?.worktrees?.find(candidate => candidate.path === created.path || candidate.branch === branch) + if (!worktree) throw new Error(`Created ${branch}, but could not detect its worktree`) + await ws.refreshWorktrees(workspace.id) + } + setSurfaceWorktreeIds(prev => ({ ...prev, [selectionKey]: worktree!.id })) + } + // This field is a one-shot creation request. Clearing it protects an + // existing chat from being moved again after the user switches it. + chatSessions.update(session.tabId, session.id, { initialBranch: undefined }) + } catch (error) { + chatSessions.update(session.tabId, session.id, { initialBranch: undefined }) + show({ type: 'error', message: `default branch: ${(error as Error).message}`, duration: 5000 }) + } finally { + provisioningBranchSessionsRef.current.delete(session.id) + } + })() + } + } + }, [chatSessions, setSurfaceWorktreeIds, show, workspaceByChatTabId, ws]) + const validChatSessionTabIds = useMemo(() => new Set(Object.keys(workspaceByChatTabId)), [workspaceByChatTabId]) const validRuntimeTabIds = useMemo(() => { const ids = new Set(allTabIds) @@ -2179,6 +2233,12 @@ export default function App() { // below so its subscription stays off App. The Mission // tab and menulet read it through context via MissionControlHost/MenuletHost. const [menuletOpen, setMenuletOpen] = useState(false) + const [systemMonitorOpen, setSystemMonitorOpen] = useState(false) + const [windowTabsMenuOpen, setWindowTabsMenuOpen] = useState(false) + const windowTabsHidden = useMobileWindowTabsAutoHide({ + enabled: mobile.isMobile, + locked: windowTabsMenuOpen || drawerOpen || menuletOpen || systemMonitorOpen, + }) const openMissionControl = useCallback((): void => { setMenuletOpen(false) handleNewTab('mission') @@ -2301,7 +2361,7 @@ export default function App() { // Tab-specific PTY panes const tabPanes = pty.panes.filter(p => p.tabId === tabId) - if (tabKind === 'settings') return + if (tabKind === 'settings') return if (tabKind === 'plugins') return if (tabKind === 'mission') { return ( @@ -2803,6 +2863,14 @@ export default function App() { case 'start-canvas': startCanvasFromAnywhere(); return case 'updates': window.electronAPI?.updaterCheck?.(); return case 'docs': window.electronAPI?.openExternal?.('https://crewcode-docs.logixhub.icu'); return + case 'toggle-menulet': + setSystemMonitorOpen(false) + setMenuletOpen(open => !open) + return + case 'toggle-system-monitor': + setMenuletOpen(false) + setSystemMonitorOpen(open => !open) + return } }, [activeWs, tabs, setActiveTabId, handleNewTab, setPaletteOpen, setTweak, tweaks.showTerminal, startCanvasFromAnywhere, startCrewFromAnywhere]) @@ -2812,7 +2880,8 @@ export default function App() { setOpen={setDrawerOpen} height={tweaks.drawerHeight} width={tweaks.drawerWidth} - position={tweaks.drawerPosition} + position={effectiveDrawerPosition} + mobileOverlay={mobile.isMobile} active={activeWs} setActive={handleWsSelect} density={tweaks.density} @@ -3258,8 +3327,7 @@ export default function App() { isBridgeRunning={bridges.isBridgeRunning} > setSetting('onboardingCompleted', true)} /> )} + +
+ runPluginActionTarget(item.target, { source: 'plugin-menu' })} + onNewTabMenuOpenChange={setWindowTabsMenuOpen} + /> +
-
- {tweaks.drawerPosition === 'left' && workspacesPanel} +
+ {effectiveDrawerPosition === 'left' && workspacesPanel}
{splitVisible ? ( @@ -3403,7 +3499,7 @@ export default function App() {
{pluginSidebar}
- {tweaks.drawerPosition === 'right' && workspacesPanel} + {effectiveDrawerPosition === 'right' && workspacesPanel}
- {tweaks.drawerPosition === 'bottom' && workspacesPanel} + {effectiveDrawerPosition === 'bottom' && workspacesPanel} setTweak('density', v as TweakConfig['density'])} /> - setTweak('drawerPosition', v as TweakConfig['drawerPosition'])} /> - {tweaks.drawerPosition === 'bottom' + { + if (mobile.isMobile) setMobileDrawerSide(v === 'right' ? 'right' : 'left') + else setTweak('drawerPosition', v as TweakConfig['drawerPosition']) + }} + /> + {effectiveDrawerPosition === 'bottom' ? setTweak('drawerHeight', v)} /> : setTweak('drawerWidth', v)} />} diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index a933213..1317186 100644 --- a/src/renderer/src/components/chat/ChatPane.tsx +++ b/src/renderer/src/components/chat/ChatPane.tsx @@ -6,6 +6,7 @@ import type { Layout } from '../../hooks/useTerminalSessions' import { Splitter } from './Splitter' import { SoloChatView } from './SoloChatView' import { ExternalDirectoriesModal } from './ExternalDirectoriesModal' +import { HandoffCard, type HandoffSelection } from './HandoffCard' import { CrewBranch } from './CrewBranch' import { GitSidebar } from '../git/GitSidebar' import { TurnChangesDrawer } from '../thread/TurnChangesDrawer' @@ -279,6 +280,13 @@ export function ChatPane({ const [terminalHidden, setTerminalHidden] = useState(false) const [sendInFlight, setSendInFlight] = useState(false) const [externalDirsMode, setExternalDirsMode] = useState<'add' | 'remove' | null>(null) + const [handoffOpen, setHandoffOpen] = useState(false) + const [handoffBusy, setHandoffBusy] = useState(false) + const [handoffError, setHandoffError] = useState(null) + const openHandoff = useCallback(() => { + setHandoffError(null) + setHandoffOpen(true) + }, []) useEffect(() => { const open = () => setExternalDirsMode('add') window.addEventListener('crewcode:manage-external-directories', open) @@ -443,6 +451,11 @@ export function ChatPane({ // routing through the composer draft (which would lag a tick behind state). const text = (overrideText ?? composer).trim() if (!text) return + if (text === '/handoff') { + setComposer('') + openHandoff() + return + } // Cover the gap between appending the user message and bridge runtime state // propagation, so the loader appears immediately and stays through await. setSendInFlight(true) @@ -458,7 +471,7 @@ export function ChatPane({ } finally { setSendInFlight(false) } - }, [activeSession?.label, attachmentsRef, chatSessions, composer, messages.length, send, sendText, sessActive, tabId, workspace.name]) + }, [activeSession?.label, attachmentsRef, chatSessions, composer, messages.length, openHandoff, send, sendText, sessActive, setComposer, tabId, workspace.name]) const queueFollowUp = useCallback(() => { const text = composer.trim() @@ -544,12 +557,81 @@ export function ChatPane({ sendText, }) + const performHandoff = useCallback(async (selection: HandoffSelection) => { + if (!activeSession || handoffBusy) return + const sourceSession = activeSession + let target = selection.targetSessionId === 'new' + ? chatSessions.add(tabId, `Handoff from ${sourceSession.label}`) + : chatSessions.getSessions(tabId).find((session: any) => session.id === selection.targetSessionId) ?? null + if (!target) { + setHandoffError('Unable to create or find the destination chat.') + return + } + if (selection.targetSessionId === 'new') { + chatSessions.update(tabId, target.id, { + agentId: selection.provider, + model: selection.model, + effort: selection.effort, + }) + target = { ...target, agentId: selection.provider, model: selection.model, effort: selection.effort } + } + + const handoffId = `handoff-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` + const handoffTime = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + setMessagesForTab(target.id, prev => [...prev, { + kind: 'handoff', id: handoffId, time: handoffTime, status: 'started', + message: 'summarizing context for destination chat', percent: 35, + fromProvider: sourceSession.agentId, toProvider: target.agentId, + }]) + chatSessions.activate(tabId, target.id) + setHandoffBusy(true) + setHandoffError(null) + + const targetAgent = agents.find(agent => agent.id === target!.agentId) + if (!targetAgent || targetAgent.transport !== 'bridge') { + setMessagesForTab(target.id, prev => prev.map(message => message.kind === 'handoff' && message.id === handoffId + ? { ...message, status: 'failed', message: 'destination provider does not support chat handoff', percent: 100 } + : message)) + setHandoffBusy(false) + setHandoffError('The destination provider does not support bridge context handoff.') + return + } + + const targetMcp = resolveSessionMcpServers(mcpEnabled, mcpServers, target.mcpServerIds) + const started = await bridges.ensureBridge( + target.id, target.agentId, target.agentId, effectivePath, target.model || undefined, + target.effort, normalizeModeLevel(target.mode), undefined, false, targetMcp, true, target.externalDirectories ?? [], + ) + let result: { ok: boolean; error?: string } + if ('error' in started) result = { ok: false, error: started.error } + // Main stores local transcripts under the session-scoped `thread:` key. + // Passing the bare renderer session id makes every handoff look empty. + else result = await bridges.handoff(started.bridgeId, `thread:${sourceSession.id}`, { + fromProvider: sourceSession.agentId, + toProvider: target.agentId, + model: target.model || undefined, + mode: normalizeModeLevel(target.mode), + workspace: { name: workspace.name, path: effectivePath, branch: worktreeBranch ?? effectiveBranch }, + }) + + setMessagesForTab(target.id, prev => prev.map(message => message.kind === 'handoff' && message.id === handoffId + ? { ...message, status: result.ok ? 'completed' : 'failed', message: result.ok ? 'handoff complete' : 'handoff failed', percent: 100 } + : message)) + setHandoffBusy(false) + if (result.ok) setHandoffOpen(false) + else setHandoffError(result.error ?? 'Context handoff failed.') + }, [activeSession, agents, bridges, chatSessions, effectiveBranch, effectivePath, handoffBusy, mcpEnabled, mcpServers, setMessagesForTab, tabId, workspace.name, worktreeBranch]) + // A custom slash-command fires the moment it is picked. While the agent is // running the body is queued as a follow-up (mirroring composer send); idle, // it dispatches immediately with session-title/loader handling. const runCommand = useCallback((body: string) => { const text = body.trim() if (!text) return + if (text === '/handoff') { + openHandoff() + return + } if (text === '/add-dir' || text === '/remove-dir') { setExternalDirsMode(text === '/add-dir' ? 'add' : 'remove') return @@ -559,7 +641,7 @@ export function ChatPane({ return } void sendWithSessionTitle(text) - }, [isRunning, sendText, attachmentsRef, sendWithSessionTitle, workspace.kind, setMessages, chatSessions, tabId, bridges, activeAgentId]) + }, [isRunning, sendText, attachmentsRef, sendWithSessionTitle, openHandoff]) // Agents offered as chat providers / crew lanes. Terminal-only CLIs (Claude // Code) are filtered out here but stay available via the terminal column. @@ -690,6 +772,7 @@ export function ChatPane({ onStartCrew={() => crewCtl?.handleStartCrew?.()} onOpenCanvas={onOpenCanvas} onOpenTerminal={openHeaderTerminal} + onHandoff={openHandoff} composerMode={composerMode} setComposerMode={setComposerMode} composer={composer} @@ -779,6 +862,19 @@ export function ChatPane({ )}
+ { if (!handoffBusy) setHandoffOpen(false) }} + onConfirm={selection => { void performHandoff(selection) }} + /> void + onConfirm: (selection: HandoffSelection) => void +} + +export function HandoffCard({ + open, sourceSessionId, sessions, agents, defaultProvider, defaultModel, defaultEffort, + busy = false, error, onClose, onConfirm, +}: HandoffCardProps) { + const [destinationTab, setDestinationTab] = useState<'new' | 'used'>('new') + const [targetSessionId, setTargetSessionId] = useState(null) + const [provider, setProvider] = useState(defaultProvider) + const [model, setModel] = useState(defaultModel) + const [effort, setEffort] = useState(defaultEffort) + const availableAgents = useMemo(() => agents.filter(agent => agent.available && agent.transport === 'bridge'), [agents]) + const targetSessions = useMemo(() => sessions.filter(session => session.id !== sourceSessionId), [sessions, sourceSessionId]) + const detected = useProviderModels(provider, open && destinationTab === 'new', open && destinationTab === 'new') + const effortRows = effortRowsForProvider(provider) + const selectedExisting = destinationTab === 'used' + ? targetSessions.find(session => session.id === targetSessionId) ?? null + : null + + useEffect(() => { + if (!open) return + setDestinationTab('new') + setTargetSessionId(null) + setProvider(defaultProvider) + setModel(defaultModel) + setEffort(defaultEffort) + }, [open, defaultProvider, defaultModel, defaultEffort]) + + useEffect(() => { + if (destinationTab !== 'used') return + if (!targetSessions.some(session => session.id === targetSessionId)) { + setTargetSessionId(targetSessions[0]?.id ?? null) + } + }, [destinationTab, targetSessionId, targetSessions]) + + useEffect(() => { + if (!selectedExisting) return + setProvider(selectedExisting.agentId) + setModel(selectedExisting.model) + setEffort(selectedExisting.effort) + }, [selectedExisting]) + + useEffect(() => { + if (destinationTab !== 'new') return + if (effortRows.length > 0 && !effortRows.some(row => row.id === effort)) setEffort(effortRows[0].id) + }, [provider, destinationTab, effort, effortRows]) + + if (!open) return null + + const selectNewTab = () => { + setDestinationTab('new') + setProvider(defaultProvider) + setModel(defaultModel) + setEffort(defaultEffort) + } + const selectUsedTab = () => setDestinationTab('used') + const canConfirm = availableAgents.length > 0 && (destinationTab === 'new' || !!selectedExisting) + + return ( +
{ if (event.target === event.currentTarget && !busy) onClose() }}> +
+
+ +
+

Context handoff

+

Summarize this chat and continue it in another provider session.

+
+ +
+ +
+ + +
+ + {destinationTab === 'new' ? ( +
+ Start a clean destination + Choose the provider, model, and effort for this workspace. +
+ ) : ( +
+ {targetSessions.length === 0 ?

No other used chats are available in this workspace.

: null} + {targetSessions.map(session => ( + + ))} +
+ )} + +
+ + + +
+ + {selectedExisting ?

This used chat keeps its selected provider, model, effort, and existing transcript.

: null} + {error ?
{error}
: null} + +
+ + +
+
+
+ ) +} diff --git a/src/renderer/src/components/chat/SoloChatView.tsx b/src/renderer/src/components/chat/SoloChatView.tsx index 0c57b54..fa578d7 100644 --- a/src/renderer/src/components/chat/SoloChatView.tsx +++ b/src/renderer/src/components/chat/SoloChatView.tsx @@ -57,6 +57,7 @@ export interface SoloChatViewProps { onStartCrew: () => void onOpenCanvas?: () => void onOpenTerminal?: () => void + onHandoff?: () => void // Composer composerMode: Mode setComposerMode: (m: Mode) => void @@ -141,7 +142,7 @@ export function SoloChatView(props: SoloChatViewProps) { pendingGitDiff, setPendingGitDiff, hideHeader = false, agentLabel, modelLabel, voiceControl, - gitOpen, setGitOpen, github, dirtyCount = 0, changesOpen, changesCount, toggleChangesOpen, onStartCrew, onOpenCanvas, onOpenTerminal, + gitOpen, setGitOpen, github, dirtyCount = 0, changesOpen, changesCount, toggleChangesOpen, onStartCrew, onOpenCanvas, onOpenTerminal, onHandoff, composerMode, setComposerMode, composer, setComposer, onSend, onRunCommand, onQueueFollowUp, queuedFollowUps = [], onRemoveQueuedFollowUp, isRunning, loadingStatus = null, onStop, agentRequest, custodyHalt, onReauthorizeCustody, onAgentRequestResponse, agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, @@ -304,6 +305,7 @@ export function SoloChatView(props: SoloChatViewProps) { onOpenCanvas={onOpenCanvas} onOpenTerminal={onOpenTerminal} onOpenBrowser={onOpenBrowser} + onHandoff={onHandoff} delegationEnabled={delegationEnabled} onToggleDelegation={onToggleDelegation} modePromptsEnabled={modePromptsEnabled} diff --git a/src/renderer/src/components/chat/handoff-card.test.ts b/src/renderer/src/components/chat/handoff-card.test.ts new file mode 100644 index 0000000..a4c67d6 --- /dev/null +++ b/src/renderer/src/components/chat/handoff-card.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const card = readFileSync(fileURLToPath(new URL('./HandoffCard.tsx', import.meta.url)), 'utf8') +const pane = readFileSync(fileURLToPath(new URL('./ChatPane.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') + +describe('provider context handoff destination', () => { + it('uses the main-process transcript key for the source chat', () => { + expect(pane).toContain('bridges.handoff(started.bridgeId, `thread:${sourceSession.id}`') + expect(pane).not.toContain('bridges.handoff(started.bridgeId, sourceSession.id,') + }) + + it('separates new and used destinations into tabs and loads used chats on demand', () => { + expect(card).toContain("useState<'new' | 'used'>('new')") + expect(card).toContain('role="tablist"') + expect(card).toContain('>New chat') + expect(card).toContain('>Used chats {targetSessions.length}') + expect(card).toContain("destinationTab === 'used'") + expect(card).toContain('targetSessions.map(session =>') + expect(styles).toContain('.handoff-card-tabs {') + }) + + it('requires a selected used chat before enabling handoff', () => { + expect(card).toContain("destinationTab === 'new' || !!selectedExisting") + expect(card).toContain("destinationTab === 'new' ? 'new' : selectedExisting!.id") + }) +}) diff --git a/src/renderer/src/components/chat/mobile-chat-layout.test.ts b/src/renderer/src/components/chat/mobile-chat-layout.test.ts new file mode 100644 index 0000000..4b6cae9 --- /dev/null +++ b/src/renderer/src/components/chat/mobile-chat-layout.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') +const composer = readFileSync(join(__dirname, '../composer/Composer.tsx'), 'utf8') +const header = readFileSync(join(__dirname, '../thread/ChatHeader.tsx'), 'utf8') + +describe('mobile solo chat layout', () => { + it('uses a compact one-line header with a top-right actions menu', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.thr-h \{[\s\S]*?flex-wrap: nowrap;/) + expect(styles).toContain('.thr-h .actions .act-menu-wrap .act-label { display: none; }') + expect(header).toContain('mobile-chat-actions-trigger') + expect(styles).toContain('.thr-h .actions .act-menu-wrap .mobile-chat-actions-trigger {') + expect(styles).toContain('height: 22px;') + expect(styles).toContain('min-height: 36px;') + expect(header).toContain("window.innerWidth < COLLAPSE_WIDTH") + }) + + it('replaces the mobile model reveal with compact model and action menus', () => { + expect(composer).toContain(' { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.composer \{ width: 100%;/) + expect(styles).toMatch(/\.composer-wrap \{ padding: 4px 6px/) + expect(styles).toMatch(/\.composer textarea \{[^}]*font-size: 16px;/) + expect(styles).toContain('.mobile-composer-action-button,') + expect(styles).toContain('.mobile-composer-model-button {') + }) + + it('keeps embedded terminal and Git side panes out of the solo-chat column', () => { + expect(styles).toContain('.main > .termcol-outer,') + expect(styles).toContain('.chat-pane-row > .gs { display: none !important; }') + }) +}) diff --git a/src/renderer/src/components/composer/Composer.tsx b/src/renderer/src/components/composer/Composer.tsx index dadf528..2580eb7 100644 --- a/src/renderer/src/components/composer/Composer.tsx +++ b/src/renderer/src/components/composer/Composer.tsx @@ -18,6 +18,7 @@ import { VoiceOrb } from '../voice/VoiceOrb' import type { VoiceControlSurface } from '../../../../shared/voice-types' import { ComposerDictationButton } from './ComposerDictationButton' import { insertDictationText } from './composer-dictation-text' +import { MobileComposerActionMenu, MobileComposerModelMenu } from './MobileComposerMenus' const MODE_CYCLE: Mode[] = ['Ask', 'Plan', 'Build', 'Full'] @@ -172,8 +173,9 @@ export function Composer({ const valueRef = useRef(value) valueRef.current = value const modelRowRef = useRef(null) - // Keep the hover-revealed model row pinned open while a picker dropdown is up. + // Desktop keeps the hover row open while one of its pickers is active. const [modelPickerOpen, setModelPickerOpen] = useState(false) + const inputBlurTimerRef = useRef(null) const fileInputRef = useRef(null) const historyIndexRef = useRef(-1) const historyDraftRef = useRef('') @@ -366,6 +368,7 @@ export function Composer({ .map(s => ({ id: `skill:${s.id}`, kind: 'skill' as const, title: s.title, description: s.description, skill: s })) const builtInCommands = [ { id: 'builtin:compact', kind: 'command' as const, title: '/compact', description: 'compact the current provider session', body: '/compact' }, + { id: 'builtin:handoff', kind: 'command' as const, title: '/handoff', description: 'hand off context to a new or used chat', body: '/handoff' }, { id: 'builtin:add-dir', kind: 'command' as const, title: '/add-dir', description: 'attach an external directory to this session', body: '/add-dir' }, { id: 'builtin:remove-dir', kind: 'command' as const, title: '/remove-dir', description: 'remove an external directory from this session', body: '/remove-dir' }, ].filter(c => slashCategory && slashCategory !== 'command' ? false : (!search || c.title.toLowerCase().includes(search) || c.description.toLowerCase().includes(search))) @@ -388,7 +391,7 @@ export function Composer({ // draft untouched, and dispatch the body straight to the agent. Built-in // commands like /compact have no `command` and fall through to be inserted, // since they need send()'s special-case handling on Enter. - if (item.kind === 'command' && onRunCommand && (item.command || item.id === 'builtin:add-dir' || item.id === 'builtin:remove-dir')) { + if (item.kind === 'command' && onRunCommand && (item.command || item.id === 'builtin:handoff' || item.id === 'builtin:add-dir' || item.id === 'builtin:remove-dir')) { const next = (before + after).trimStart() onChange(next) setSlash(null) @@ -604,7 +607,18 @@ export function Composer({ onKeyUp={updateMentionFromCaret} onClick={updateMentionFromCaret} onPaste={onPaste} - onBlur={() => setTimeout(() => { setMention(null); setSlash(null) }, 120)} + onFocus={() => { + if (inputBlurTimerRef.current !== null) window.clearTimeout(inputBlurTimerRef.current) + inputBlurTimerRef.current = null + }} + onBlur={() => { + if (inputBlurTimerRef.current !== null) window.clearTimeout(inputBlurTimerRef.current) + inputBlurTimerRef.current = window.setTimeout(() => { + inputBlurTimerRef.current = null + setMention(null) + setSlash(null) + }, 120) + }} /> {mention && (
+
- - {branchPicker && ( - fileInputRef.current?.click()} + > + + + + {branchPicker && ( + + )} +
+
+ fileInputRef.current?.click()} + onOpenPrompts={onOpenPrompts} + branchPicker={branchPicker} /> - )} + +
{dictationScopeId ? (
- {/* Hover the thin strip at the bottom of the composer to slide the model - row down; it collapses when unhovered, but stays open while a picker - dropdown is active. */} + {/* Desktop reveals this row from the lower hover strip. Mobile replaces + it with the selected-model button in the main toolbar. */}
diff --git a/src/renderer/src/components/composer/MobileComposerMenus.tsx b/src/renderer/src/components/composer/MobileComposerMenus.tsx new file mode 100644 index 0000000..e66440f --- /dev/null +++ b/src/renderer/src/components/composer/MobileComposerMenus.tsx @@ -0,0 +1,280 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { Icon } from '../ui/Icon' +import { CreateBranchModal } from '../git/BranchPicker' +import type { GitBranchRef } from '../git/git-state' +import { prefetchProviderModels, useProviderModels } from '../../hooks/useProviderModels' +import type { McpServerConfig } from '../../hooks/useSettings' +import type { AgentInfo } from '../../types' +import { effortRowsForProvider, providerSupportsEffort, type EffortLevel } from './EffortPicker' +import type { Mode } from './ModeSegment' +import { PickerSheet, type PickerItem } from './PickerSheet' +import { PROVIDER_IMAGES, PROVIDER_META, providerImageClass } from './provider-meta' + +const MODE_COPY: Record = { + Ask: 'Read-only answers and discovery', + Plan: 'Fresh context and a markdown plan', + Build: 'Careful implementation with approvals', + Full: 'All tools pre-approved', +} + +const EFFORT_LABEL: Record = { + off: 'Off', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max', ultra: 'Ultra', +} + +function shortModel(id: string): string { + if (!id) return 'auto' + const slash = id.lastIndexOf('/') + return slash >= 0 ? id.slice(slash + 1) : id +} + +function providerIcon(provider: string, size = 18) { + const image = PROVIDER_IMAGES[provider] + return image + ? + : +} + +type ModelPage = 'root' | 'provider' | 'model' | 'effort' | 'mode' | 'mcp' + +interface MobileModelMenuProps { + agents: AgentInfo[] + activeAgentId: string + onSelectAgent: (id: string) => void + model: string + onSelectModel: (model: string) => void + effort: EffortLevel + onSelectEffort: (effort: EffortLevel) => void + mode: Mode + setMode: (mode: Mode) => void + mcpEnabled?: boolean + mcpServers?: McpServerConfig[] + selectedMcpIds?: string[] + onToggleMcp?: (id: string) => void +} + +export function MobileComposerModelMenu({ + agents, activeAgentId, onSelectAgent, + model, onSelectModel, effort, onSelectEffort, mode, setMode, + mcpEnabled = false, mcpServers = [], selectedMcpIds = [], onToggleMcp, +}: MobileModelMenuProps) { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [page, setPage] = useState('root') + const { list: models, loading } = useProviderModels(activeAgentId, open, open) + const activeProvider = agents.find(agent => agent.id === activeAgentId) + const selectedModel = models.find(item => item.id === model) + const mcpCount = selectedMcpIds.filter(id => mcpServers.some(server => server.id === id)).length + + useEffect(() => { if (!open) setPage('root') }, [open]) + + const rootItems = useMemo(() => [ + { + id: 'provider', label: 'Provider', + sub: activeProvider?.name ?? activeAgentId, + icon: providerIcon(activeAgentId), + }, + { + id: 'model', label: 'Model', + sub: selectedModel?.label ?? shortModel(model), + icon: , + }, + { + id: 'effort', label: 'Effort', + sub: EFFORT_LABEL[effort], + icon: , + disabled: effortRowsForProvider(activeAgentId).length === 0, + }, + { + id: 'mode', label: 'Mode', + sub: mode === 'Full' ? 'Full Access' : mode, + icon: , + }, + ...(mcpEnabled ? [{ + id: 'mcp', label: 'MCP servers', + sub: mcpCount > 0 ? `${mcpCount} selected` : 'none selected', + icon: , + }] : []), + ], [activeAgentId, activeProvider?.name, effort, mcpCount, mcpEnabled, mode, model, selectedModel?.label]) + + const pageItems = useMemo(() => { + const back: PickerItem = { id: '__back', label: 'Back', sub: 'Model settings', icon: } + if (page === 'provider') return [back, ...agents.map(agent => ({ + id: agent.id, + label: agent.name, + sub: agent.description ?? (agent.available ? 'available' : 'not available'), + disabled: !agent.available, + icon: providerIcon(agent.id), + }))] + if (page === 'model') return [back, ...models.map(item => ({ + id: item.id || '__default_model', + label: item.label || 'auto', + sub: item.id || 'Provider default', + icon: providerIcon(item.provider || activeAgentId), + }))] + if (page === 'effort') return [back, ...effortRowsForProvider(activeAgentId).map(item => ({ + id: item.id, label: item.label, sub: item.sub, icon: , + }))] + if (page === 'mode') return [back, ...(['Ask', 'Plan', 'Build', 'Full'] as Mode[]).map(item => ({ + id: item, label: item === 'Full' ? 'Full Access' : item, sub: MODE_COPY[item], icon: , + }))] + if (page === 'mcp') return [back, ...mcpServers.map(server => ({ + id: server.id, label: server.name, sub: [server.command, ...(server.args ?? [])].join(' '), icon: , + }))] + return rootItems + }, [activeAgentId, agents, mcpServers, models, page, rootItems]) + + const pick = (id: string) => { + if (id === '__back') { setPage('root'); return } + if (page === 'root') { setPage(id as ModelPage); return } + if (page === 'provider') { + void prefetchProviderModels(id, true) + if (!providerSupportsEffort(id, effort)) onSelectEffort(effortRowsForProvider(id)[0]?.id ?? 'off') + onSelectAgent(id) + setPage('root') + return + } + if (page === 'model') { onSelectModel(id === '__default_model' ? '' : id); setPage('root'); return } + if (page === 'effort') { onSelectEffort(id as EffortLevel); setPage('root'); return } + if (page === 'mode') { setMode(id as Mode); setPage('root'); return } + if (page === 'mcp') onToggleMcp?.(id) + } + + const header = page === 'root' ? 'MODEL SETTINGS' : page.toUpperCase() + const activeId = page === 'provider' ? activeAgentId + : page === 'model' ? (model || '__default_model') + : page === 'effort' ? effort + : page === 'mode' ? mode + : undefined + + return ( + <> + + setOpen(false)} + anchor={anchorRef.current} + header={header} + items={pageItems} + activeId={activeId} + multiSelect={page === 'mcp'} + selectedIds={page === 'mcp' ? selectedMcpIds : undefined} + onPick={pick} + closeOnPick={false} + className="mobile-composer-menu-sheet" + emptyLabel={page === 'mcp' ? 'No MCP servers configured' : 'No options available'} + width={330} + /> + + ) +} + +type ActionPage = 'root' | 'branches' + +interface MobileActionMenuProps { + onAttach: () => void + onOpenPrompts?: () => void + branchPicker?: { + currentBranch: string + branches: GitBranchRef[] + onCheckoutBranch?: (ref: string) => void + onCreateBranch?: (name: string) => void + onRefresh?: () => void + } +} + +export function MobileComposerActionMenu({ onAttach, onOpenPrompts, branchPicker }: MobileActionMenuProps) { + const anchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [page, setPage] = useState('root') + const [createBranchOpen, setCreateBranchOpen] = useState(false) + + useEffect(() => { if (!open) setPage('root') }, [open]) + useEffect(() => { if (open && page === 'branches') branchPicker?.onRefresh?.() }, [branchPicker, open, page]) + + const items = useMemo(() => { + if (page === 'root') return [ + { id: 'attach', label: 'Attach files', sub: 'Add files or images to this message', icon: }, + { id: 'prompts', label: 'Prompts & Skills', sub: 'Browse your prompt library', icon: , disabled: !onOpenPrompts }, + ...(branchPicker ? [{ id: 'branches', label: 'Branch', sub: branchPicker.currentBranch, icon: }] : []), + ] + if (!branchPicker) return [] + return [ + { id: '__back', label: 'Back', sub: 'Composer actions', icon: }, + ...branchPicker.branches.map(item => ({ + id: `branch:${item.name}`, + label: item.name, + sub: `${item.kind}${item.updated ? ` · ${item.updated}` : ''}`, + icon: , + })), + { id: '__create', label: 'Create branch…', sub: `From ${branchPicker.currentBranch}`, icon: }, + ] + }, [branchPicker, onOpenPrompts, page]) + + const pick = (id: string) => { + if (id === '__back') { setPage('root'); return } + if (page === 'root') { + if (id === 'attach') { setOpen(false); onAttach(); return } + if (id === 'prompts') { setOpen(false); onOpenPrompts?.(); return } + if (id === 'branches') { setPage('branches'); return } + } + if (id === '__create') { setOpen(false); setCreateBranchOpen(true); return } + if (id.startsWith('branch:')) { + setOpen(false) + const name = id.slice('branch:'.length) + const branch = branchPicker?.branches.find(item => item.name === name) + const ref = branch?.kind === 'remote' && !name.startsWith('origin/') ? `origin/${name}` : name + branchPicker?.onCheckoutBranch?.(ref) + } + } + + return ( + <> + + setOpen(false)} + anchor={anchorRef.current} + header={page === 'root' ? 'COMPOSER ACTIONS' : 'BRANCHES'} + items={items} + activeId={page === 'branches' ? `branch:${branchPicker?.currentBranch ?? ''}` : undefined} + onPick={pick} + closeOnPick={false} + className="mobile-composer-menu-sheet" + width={330} + /> + {branchPicker && ( + setCreateBranchOpen(false)} + /> + )} + + ) +} diff --git a/src/renderer/src/components/composer/PickerSheet.tsx b/src/renderer/src/components/composer/PickerSheet.tsx index 72abecc..5f30c8b 100644 --- a/src/renderer/src/components/composer/PickerSheet.tsx +++ b/src/renderer/src/components/composer/PickerSheet.tsx @@ -31,12 +31,14 @@ interface PickerSheetProps { selectedIds?: string[] emptyLabel?: string // shown in place of "no matches" className?: string // optional picker-specific sizing/styling + /** Keep the sheet mounted after a row pick (used by navigable mobile menus). */ + closeOnPick?: boolean } export function PickerSheet({ open, onClose, anchor, header, searchPlaceholder, query, onQuery, items, activeId, onPick, defaultIcon, width = 260, placement = 'auto', - multiSelect = false, selectedIds, emptyLabel, className, + multiSelect = false, selectedIds, emptyLabel, className, closeOnPick = !multiSelect, }: PickerSheetProps) { const ref = useRef(null) @@ -85,7 +87,14 @@ export function PickerSheet({ // filter, contain) or clips with overflow — e.g. the crew config panel's // animated lane cards and overflow:hidden frame. return createPortal( -
+ <> +
-
, +
+ , document.body, ) } diff --git a/src/renderer/src/components/composer/mobile-composer-menus.test.ts b/src/renderer/src/components/composer/mobile-composer-menus.test.ts new file mode 100644 index 0000000..f0ed7e5 --- /dev/null +++ b/src/renderer/src/components/composer/mobile-composer-menus.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const menus = readFileSync(fileURLToPath(new URL('./MobileComposerMenus.tsx', import.meta.url)), 'utf8') +const composer = readFileSync(fileURLToPath(new URL('./Composer.tsx', import.meta.url)), 'utf8') +const picker = readFileSync(fileURLToPath(new URL('./PickerSheet.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') + +describe('mobile composer menus', () => { + it('shows the chosen model on one navigable settings button', () => { + expect(menus).toContain('className="mobile-composer-model-button"') + expect(menus).toContain("selectedModel?.label ?? shortModel(model)") + for (const page of ["'provider'", "'model'", "'effort'", "'mode'", "'mcp'"]) { + expect(menus).toContain(page) + } + expect(menus).toContain("header={header}") + expect(menus).toContain('closeOnPick={false}') + }) + + it('consolidates files, prompts, and branch controls under Actions', () => { + expect(menus).toContain('className="mobile-composer-action-button"') + expect(menus).toContain("label: 'Attach files'") + expect(menus).toContain("label: 'Prompts & Skills'") + expect(menus).toContain("label: 'Branch'") + expect(menus).toContain("label: 'Create branch…'") + expect(composer).toContain('onAttach={() => fileInputRef.current?.click()}') + }) + + it('keeps desktop controls and enables non-closing picker navigation', () => { + expect(composer).toContain('className="desktop-composer-actions"') + expect(styles).toContain('.desktop-composer-actions { display: flex;') + expect(styles).toContain('.mobile-composer-actions { display: none; }') + expect(styles).toContain('background-color: transparent !important;') + expect(styles).toContain('-webkit-appearance: none;') + expect(styles).toContain('box-shadow: none !important;') + expect(picker).toContain('closeOnPick = !multiSelect') + expect(picker).toContain('if (closeOnPick) onClose()') + }) +}) diff --git a/src/renderer/src/components/composer/mobile-picker-sheet.test.ts b/src/renderer/src/components/composer/mobile-picker-sheet.test.ts new file mode 100644 index 0000000..fdd98ea --- /dev/null +++ b/src/renderer/src/components/composer/mobile-picker-sheet.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const picker = readFileSync(fileURLToPath(new URL('./PickerSheet.tsx', import.meta.url)), 'utf8') +const styles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') +const mobileStyles = styles.slice(styles.indexOf('@media (max-width: 768px)')) + +describe('mobile composer picker sheets', () => { + it('provides a dismissible mobile backdrop', () => { + expect(picker).toContain('className="picker-sheet-backdrop"') + expect(picker).toContain('aria-label="Close picker"') + expect(picker).toContain('onClick={onClose}') + expect(styles).toContain('.picker-sheet-backdrop { display: none; }') + expect(mobileStyles).toMatch(/\.picker-sheet-backdrop \{[\s\S]*?display: block;/) + }) + + it('renders as a compact viewport-bounded bottom sheet on mobile', () => { + expect(mobileStyles).toMatch(/\.picker-sheet \{[\s\S]*?bottom: 0 !important;/) + expect(mobileStyles).toContain('max-height: min(54dvh, 410px) !important;') + expect(mobileStyles).toContain('.picker-sheet.model-picker-sheet { height: min(50dvh, 350px); }') + expect(mobileStyles).toContain('.picker-row { min-height: 40px;') + expect(mobileStyles).toContain('.picker-search input { min-width: 0; font-size: 16px; }') + }) +}) diff --git a/src/renderer/src/components/settings/BrainAuthorizationSection.tsx b/src/renderer/src/components/settings/BrainAuthorizationSection.tsx new file mode 100644 index 0000000..685ef28 --- /dev/null +++ b/src/renderer/src/components/settings/BrainAuthorizationSection.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from 'react' +import type { BrainAccessScope } from '../../../../shared/hub-relay-types' +import { brainAuthorizationRelay } from '../../runtime/brain-authorization-runtime' + +const SCOPES: Array<{ id: BrainAccessScope; label: string; detail: string }> = [ + { id: 'workspace:read', label: 'Workspace read', detail: 'Files, Git status, and workspace discovery' }, + { id: 'workspace:write', label: 'Workspace write', detail: 'File changes, Git mutations, and attachments' }, + { id: 'terminal', label: 'Terminal', detail: 'Create and control Brain-local shells' }, + { id: 'agent', label: 'Agents', detail: 'Start providers, prompts, tools, and MCP' }, +] +interface Policy { + scopes: BrainAccessScope[]; roots: string[]; updatedAt: number + audit: Array<{ at: number; scopes: BrainAccessScope[]; roots: string[] }> +} + +export function BrainAuthorizationSection() { + const relay = brainAuthorizationRelay() + const [policy, setPolicy] = useState(null) + const [scopes, setScopes] = useState([]) + const [roots, setRoots] = useState([]) + const [newRoot, setNewRoot] = useState('') + const [message, setMessage] = useState('') + const [saving, setSaving] = useState(false) + useEffect(() => { + if (!relay) return + void relay.transport.rpc('brain.authorization.get', {}).then(next => { + setPolicy(next); setScopes(next.scopes); setRoots(next.roots) + }).catch(error => setMessage((error as Error).message)) + }, [relay]) + const reducing = useMemo(() => !!policy && (policy.scopes.some(scope => !scopes.includes(scope)) || policy.roots.some(root => !roots.includes(root))), [policy, roots, scopes]) + if (!relay) return null + + const save = async () => { + const trimmed = newRoot.trim() + const nextRoots = trimmed && !roots.includes(trimmed) ? [...roots, trimmed] : roots + if (scopes.length > 0 && nextRoots.length === 0) { setMessage('At least one workspace root is required while scopes are enabled.'); return } + if (reducing && !window.confirm('Reducing Brain authority immediately stops affected agents and terminals. Continue?')) return + setSaving(true); setMessage('') + try { + const result = await relay.transport.rpc<{ policy: Policy; stopped: { paneIds: string[]; bridgeIds: string[] } }>('brain.authorization.update', { scopes, roots: nextRoots }) + setPolicy(result.policy); setScopes(result.policy.scopes); setRoots(result.policy.roots); setNewRoot('') + await relay.reconnect({ force: true }) + const stopped = result.stopped.paneIds.length + result.stopped.bridgeIds.length + setMessage(stopped ? `Saved. ${stopped} affected resource${stopped === 1 ? '' : 's'} stopped.` : 'Authorization saved and secure tunnel renewed.') + } catch (error) { setMessage((error as Error).message) } + finally { setSaving(false) } + } + + return ( +
+

Brain Authorization

remote scopes & workspace roots
+
+
Brain-local policy
Stored and enforced on the enrolled Brain. The Hub cannot read or widen these grants.
+ {SCOPES.map(item =>
+
{item.label}
{item.detail}
+
)} +
+

Workspace Roots

absolute directories permitted on Brain
+
+ {roots.map(root =>
{root}
)} +
setNewRoot(event.target.value)} placeholder="/absolute/path/on/brain" style={{ flex: 1, minWidth: 0 }} />
+ {reducing &&
Reducing authority immediately stops affected live agents and terminals.
} + {message &&
{message}
} +
+
+ {policy?.audit.length ?
Local audit history ({policy.audit.length}){policy.audit.slice(-10).reverse().map((event, index) =>
{new Date(event.at).toLocaleString()} · scopes: {event.scopes.join(', ') || 'none'} · roots: {event.roots.join(', ') || 'none'}
)}
: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsScreen.tsx b/src/renderer/src/components/settings/SettingsScreen.tsx index 078f8ed..bbe7e71 100644 --- a/src/renderer/src/components/settings/SettingsScreen.tsx +++ b/src/renderer/src/components/settings/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Icon, type IconName } from '../ui/Icon' import { useSettings, @@ -31,14 +31,15 @@ import { type ProfileIconPreset, } from '../profile/UserProfileAvatar' import { PROVIDER_IMAGES, providerImageClass } from '../composer/provider-meta' -import type { AppBuildInfo, UpdaterEvent, GhStatus, AgentInfo } from '../../types' +import type { AppBuildInfo, UpdaterEvent, GhStatus, AgentInfo, Workspace } from '../../types' import type { CompletionProviderId } from '../../../../shared/agent-completion-types' import { LOCAL_VOICE_SPEED_MAX, LOCAL_VOICE_SPEED_MIN, type LocalVoiceDevice, } from '../../../../shared/voice-types' -import { getCrewCodeClient } from '../../runtime/crewcode-client' +import { getCrewCodeClient, getCrewCodeRuntime } from '../../runtime/crewcode-client' +import { BrainAuthorizationSection } from './BrainAuthorizationSection' import type { EditorThemeId } from '../../../../shared/editor-theme-types' import type { RemoteVoiceProviderId, @@ -288,7 +289,49 @@ function ProfileSection({ state, set }: { state: SettingsState; set: SetSetting /* ---------- Section: General ---------- */ -function GeneralSection({ state, set }: { state: SettingsState; set: SetSetting }) { +function GeneralSection({ state, set, workspace }: { state: SettingsState; set: SetSetting; workspace?: Workspace | null }) { + const [detectedBranches, setDetectedBranches] = useState([]) + const [branchesLoading, setBranchesLoading] = useState(false) + const [branchesError, setBranchesError] = useState('') + + useEffect(() => { + let cancelled = false + if (!workspace || workspace.kind !== 'repo') { + setDetectedBranches([]) + setBranchesError('') + setBranchesLoading(false) + return () => { cancelled = true } + } + setBranchesLoading(true) + setBranchesError('') + void window.electronAPI?.gitBranches(workspace.path) + .then(result => { + if (cancelled) return + if (result?.error) { + setDetectedBranches([]) + setBranchesError(result.error) + return + } + const names = new Set((result?.branches ?? []).map(branch => branch.name).filter(Boolean)) + if (workspace.branch) names.add(workspace.branch) + for (const worktree of workspace.worktrees ?? []) if (worktree.branch) names.add(worktree.branch) + setDetectedBranches([...names].sort((a, b) => a.localeCompare(b))) + }) + .catch(error => { + if (!cancelled) setBranchesError((error as Error).message || 'Unable to detect branches') + }) + .finally(() => { if (!cancelled) setBranchesLoading(false) }) + return () => { cancelled = true } + }, [workspace?.id, workspace?.kind, workspace?.path, workspace?.branch, workspace?.worktrees]) + + const selectedDefaultBranch = workspace ? state.defaultBranchByWorkspace[workspace.id] ?? '' : '' + const setDefaultBranch = (branch: string) => { + if (!workspace) return + const next = { ...state.defaultBranchByWorkspace } + if (branch) next[workspace.id] = branch + else delete next[workspace.id] + set('defaultBranchByWorkspace', next) + } const selectNotificationSound = (value: string) => { const sound = normalizeNotificationSound(value) set('notificationSound', sound) @@ -316,6 +359,24 @@ function GeneralSection({ state, set }: { state: SettingsState; set: SetSetting
value={state.defaultMode} options={['ask','plan','build','full']} onChange={v => set('defaultMode', v)} />
+
+
+
Default branch for new chats
+
New chat sessions in {workspace?.name ?? 'the active workspace'} start on this detected branch. Existing chats keep their current branch.
+ {branchesError ?
{branchesError}
: null} +
+ +
On launch
@@ -2165,8 +2226,14 @@ const SECTIONS: NavGroup[] = [ /* ---------- Root ---------- */ -export function SettingsScreen() { +export function SettingsScreen({ activeWorkspace }: { activeWorkspace?: Workspace | null } = {}) { const { state, set, savedAt } = useSettings() + const webRuntime = getCrewCodeRuntime().kind === 'web' + const sections = useMemo(() => webRuntime + ? SECTIONS.map(group => group.group === 'connectivity' + ? { ...group, items: [...group.items, { id: 'brain-authorization', label: 'Brain Access', icon: 'server' as IconName }] } + : group) + : SECTIONS, [webRuntime]) const [query, setQuery] = useState('') const searchRef = useRef(null) @@ -2263,7 +2330,7 @@ export function SettingsScreen() {
- + @@ -2336,6 +2403,7 @@ export function SettingsScreen() { + {webRuntime && } diff --git a/src/renderer/src/components/settings/brain-authorization-settings.test.ts b/src/renderer/src/components/settings/brain-authorization-settings.test.ts new file mode 100644 index 0000000..3fee9ca --- /dev/null +++ b/src/renderer/src/components/settings/brain-authorization-settings.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +describe('Brain authorization settings placement', () => { + it('mounts Brain authorization in web Settings rather than a floating overlay', () => { + const settings = readFileSync(join(__dirname, 'SettingsScreen.tsx'), 'utf8') + const section = readFileSync(join(__dirname, 'BrainAuthorizationSection.tsx'), 'utf8') + const connection = readFileSync(join(__dirname, '../../runtime/WebConnectionScreen.tsx'), 'utf8') + + expect(settings).toContain("id: 'brain-authorization', label: 'Brain Access'") + expect(settings).toContain('{webRuntime && }') + expect(section).toContain('id="brain-authorization" className="ss-section"') + expect(connection).not.toContain(' { + it('persists a workspace-scoped default and lists detected branches', () => { + expect(settingsHook).toContain('defaultBranchByWorkspace: Record') + expect(settingsHook).toContain('defaultBranchByWorkspace: {}') + expect(settingsScreen).toContain('window.electronAPI?.gitBranches(workspace.path)') + expect(settingsScreen).toContain('Default branch for new chats') + expect(settingsScreen).toContain("set('defaultBranchByWorkspace', next)") + }) + + it('captures the selected default only when a session is created', () => { + expect(sessions).toContain('initialBranch: d.initialBranch?.trim() || undefined') + expect(app).toContain('initialBranch: settings.defaultBranchByWorkspace[activeWs] ??') + expect(app).toContain("worktreeSelectionKey(session.tabId, 'chat', session.id)") + }) + + it('selects or provisions the matching worktree and then clears the request', () => { + expect(app).toContain('workspace.worktrees.find(candidate => candidate.branch === branch)') + expect(app).toContain('worktreeCreate(workspace.path, branch)') + expect(app).toContain('{ initialBranch: undefined }') + expect(sessions).toContain('initialBranch: undefined,') + }) +}) diff --git a/src/renderer/src/components/settings/mobile-settings-layout.test.ts b/src/renderer/src/components/settings/mobile-settings-layout.test.ts new file mode 100644 index 0000000..8231080 --- /dev/null +++ b/src/renderer/src/components/settings/mobile-settings-layout.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const settingsStyles = readFileSync(fileURLToPath(new URL('../../styles/settings.css', import.meta.url)), 'utf8') +const appStyles = readFileSync(fileURLToPath(new URL('../../styles/styles.css', import.meta.url)), 'utf8') +const drawer = readFileSync(fileURLToPath(new URL('../workspaces/WorkspacesDrawer.tsx', import.meta.url)), 'utf8') +const mobileSettings = settingsStyles.slice(settingsStyles.indexOf('/* ---------- Mobile settings ---------- */')) +const mobileApp = appStyles.slice(appStyles.indexOf('@media (max-width: 768px)')) + +describe('mobile Settings layout', () => { + it('removes the mobile titlebar while retaining the tab strip', () => { + expect(mobileApp).toContain('.titlebar { display: none; }') + expect(mobileApp).toContain('.wintabs {') + expect(mobileApp).not.toContain('.wintabs { display: none; }') + expect(mobileApp).toContain('top: 40px;') + }) + + it('turns Settings into a stacked layout with horizontally scrollable categories', () => { + expect(mobileSettings).toContain('.settings-shell {') + expect(mobileSettings).toContain('flex-direction: column;') + expect(mobileSettings).toContain('.ss-nav-list {') + expect(mobileSettings).toContain('overflow-x: auto;') + expect(mobileSettings).toContain('.ss-detail { flex: 1; min-height: 0; min-width: 0; }') + }) + + it('stacks setting rows and gives controls phone-safe widths and input sizing', () => { + expect(mobileSettings).toMatch(/\.ss-row,[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/) + expect(mobileSettings).toContain('.ss-slider { width: 100%; max-width: 100%; }') + expect(mobileSettings).toContain('.ss-select { width: 100%; min-width: 0; min-height: 38px; }') + expect(mobileSettings).toContain('.ss-toggle[role="switch"] {') + expect(mobileSettings).toContain('flex: 0 0 42px;') + expect(mobileSettings).toContain('height: 24px;') + expect(mobileSettings).toContain('.ss-toggle[role="switch"].on::after { transform: translateX(18px); }') + expect(mobileSettings).toContain('.settings-shell textarea { font-size: 16px; }') + }) + + it('moves former titlebar destinations into the workspace drawer App tab', () => { + for (const entry of [ + "label: 'Settings'", + "label: 'Archive'", + "label: 'Docs'", + "label: 'Check for updates'", + ]) expect(drawer).toContain(entry) + expect(drawer).toContain("action: { kind: 'open-tab', tab: 'settings' }") + expect(drawer).toContain("action: { kind: 'open-tab', tab: 'archive' }") + expect(drawer).toContain("action: { kind: 'docs' }") + expect(drawer).toContain("action: { kind: 'updates' }") + }) +}) diff --git a/src/renderer/src/components/system/SystemMonitor.tsx b/src/renderer/src/components/system/SystemMonitor.tsx index c870f40..08fe377 100644 --- a/src/renderer/src/components/system/SystemMonitor.tsx +++ b/src/renderer/src/components/system/SystemMonitor.tsx @@ -188,6 +188,8 @@ function buildGroups( // subtree, not the whole App. interface SystemMonitorMountProps { + open?: boolean + onOpenChange?: (open: boolean) => void terminals: TerminalDaemon[] workspaces: MonitorWorkspace[] onKillTerminal: (id: string) => void @@ -198,9 +200,16 @@ interface SystemMonitorMountProps { // Memoized: App passes only stable props (useMemo/useCallback), so this bails // out of App's per-token re-renders and updates only on its own 2s stat ticks. export const SystemMonitorMount = memo(function SystemMonitorMount({ + open: controlledOpen, onOpenChange, terminals, workspaces, onKillTerminal, onOpenTerminal, onOpenDaemon, }: SystemMonitorMountProps) { - const [open, setOpen] = useState(false) + const [internalOpen, setInternalOpen] = useState(false) + const open = controlledOpen ?? internalOpen + const setOpen = (next: boolean | ((current: boolean) => boolean)) => { + const value = typeof next === 'function' ? next(open) : next + if (controlledOpen === undefined) setInternalOpen(value) + onOpenChange?.(value) + } const monitor = useSystemStats(open) const active = terminals.length + (monitor.stats?.bridgeCount ?? 0) diff --git a/src/renderer/src/components/thread/ChatHeader.tsx b/src/renderer/src/components/thread/ChatHeader.tsx index f355833..d845ed3 100644 --- a/src/renderer/src/components/thread/ChatHeader.tsx +++ b/src/renderer/src/components/thread/ChatHeader.tsx @@ -30,6 +30,7 @@ interface ChatHeaderProps { onOpenCanvas?: () => void onOpenTerminal?: () => void onOpenBrowser?: () => void + onHandoff?: () => void agentLabel?: string modelLabel?: string voiceControl?: VoiceControlSurface @@ -57,6 +58,7 @@ interface HeaderAction { badge?: string | number /** Plugin actions sit in their own group, separated from the built-in tools. */ group: 'plugin' | 'tool' + disabled?: boolean } // Below this header width the tool pills collapse into a single dropdown so a @@ -85,7 +87,7 @@ async function copyText(text: string): Promise { export function ChatHeader({ repo, branch, path, view, setView, - github, dirtyCount = 0, worktreeBranch, isGitRepo = true, gitOpen, onToggleGit, changesOpen, onToggleChanges, changesCount, onStartCrew, onOpenCanvas, onOpenTerminal, onOpenBrowser, + github, dirtyCount = 0, worktreeBranch, isGitRepo = true, gitOpen, onToggleGit, changesOpen, onToggleChanges, changesCount, onStartCrew, onOpenCanvas, onOpenTerminal, onOpenBrowser, onHandoff, agentLabel, modelLabel, delegationEnabled, onToggleDelegation, modePromptsEnabled, modePromptsLocked = false, onToggleModePrompts, voiceControl, @@ -93,7 +95,8 @@ export function ChatHeader({ }: ChatHeaderProps) { const { state: settings, set: setSetting } = useSettings() const [pathCopied, setPathCopied] = React.useState(false) - const [collapsed, setCollapsed] = React.useState(false) + const [collapsed, setCollapsed] = React.useState(() => typeof window !== 'undefined' && window.innerWidth < COLLAPSE_WIDTH) + const [mobileLayout, setMobileLayout] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768) const [menuOpen, setMenuOpen] = React.useState(false) const copyResetRef = React.useRef(null) const headerRef = React.useRef(null) @@ -133,6 +136,13 @@ export function ChatHeader({ return () => ro.disconnect() }, []) + React.useEffect(() => { + const update = () => setMobileLayout(window.innerWidth <= 768) + update() + window.addEventListener('resize', update) + return () => window.removeEventListener('resize', update) + }, []) + // Close the dropdown on outside click / Escape. React.useEffect(() => { if (!menuOpen) return @@ -161,6 +171,18 @@ export function ChatHeader({ badge: item.text, group: 'plugin', })), + ...(mobileLayout && onToggleModePrompts ? [{ + key: 'mode-prompt', group: 'tool', icon: 'bot', label: 'Mode prompt', + title: modePromptsLocked + ? `Mode prompt ${modePromptsEnabled ? 'was enabled' : 'was disabled'} when this session started` + : modePromptsEnabled ? 'Turn off the CrewCode mode prompt' : 'Turn on the CrewCode mode prompt', + onClick: onToggleModePrompts, active: modePromptsEnabled, disabled: modePromptsLocked, + } as HeaderAction] : []), + ...(mobileLayout && onToggleDelegation ? [{ + key: 'delegation', group: 'tool', icon: 'crew', label: 'Delegate', + title: delegationEnabled ? 'Turn agent delegation off' : 'Allow this agent to delegate work', + onClick: onToggleDelegation, active: delegationEnabled, + } as HeaderAction] : []), { key: 'verbose-logs', group: 'tool', icon: settings.hideVerboseAgentLogs ? 'eyeOff' : 'eye', @@ -169,15 +191,16 @@ export function ChatHeader({ onClick: () => setSetting('hideVerboseAgentLogs', !settings.hideVerboseAgentLogs), active: settings.hideVerboseAgentLogs, }, - { key: 'canvas', group: 'tool', icon: 'workbench', label: 'Workbench Mode', title: 'Open Workbench Mode for chats and terminals', onClick: onOpenCanvas }, - { key: 'terminal', group: 'tool', icon: 'terminal', label: 'Terminal', title: 'Open a terminal in this worktree', onClick: onOpenTerminal }, - { key: 'browser', group: 'tool', icon: 'globe', label: 'Browser', title: 'Open the in-app browser', onClick: onOpenBrowser }, + { key: 'canvas', group: 'tool', icon: 'workbench', label: 'Workbench Mode', title: 'Open Workbench Mode for chats and terminals', onClick: onOpenCanvas }, + ...(!mobileLayout ? [{ key: 'terminal', group: 'tool', icon: 'terminal', label: 'Terminal', title: 'Open a terminal in this worktree', onClick: onOpenTerminal } as HeaderAction] : []), + { key: 'browser', group: 'tool', icon: 'globe', label: 'Browser', title: 'Open the in-app browser', onClick: onOpenBrowser }, + ...(onHandoff ? [{ key: 'handoff', group: 'tool', icon: 'refresh', label: 'Handoff', title: 'Hand off this context to another chat', onClick: onHandoff } as HeaderAction] : []), ...(onStartCrew ? [{ key: 'crew', group: 'tool', icon: 'crew', label: 'Crew', title: 'Start a crew session', onClick: onStartCrew } as HeaderAction] : []), - { + ...(!mobileLayout ? [{ key: 'git', group: 'tool', icon: 'gitBranch', label: 'Git', title: gitOpen ? 'Close git sidebar' : 'Open git sidebar', onClick: onToggleGit, active: gitOpen, - }, + } as HeaderAction] : []), ...(onToggleChanges ? [{ key: 'changes', group: 'tool', icon: 'changes', label: 'Changes', title: changesOpen ? 'Close changes drawer' : 'Review uncommitted changes', @@ -299,7 +322,7 @@ export function ChatHeader({
- {open && ( -
-
- {segments.map((segment, i) => ( -
{segment}
- ))} + +
+
+
+
+ {segments.map((segment, index) => ( +
+ {segment} +
+ ))} +
- )} +
) } diff --git a/src/renderer/src/components/thread/TurnWorkLog.tsx b/src/renderer/src/components/thread/TurnWorkLog.tsx index 64336c8..cb50b6d 100644 --- a/src/renderer/src/components/thread/TurnWorkLog.tsx +++ b/src/renderer/src/components/thread/TurnWorkLog.tsx @@ -222,7 +222,7 @@ function Filename({ name, path, onOpen }: FilenameProps) { return ( + +
+
+
+ {rows.map((row, i) => { + const isErr = row.status === 'error' || row.kind === 'error' + const canExpand = hasExpandableBody(row) + const showBody = expanded[i] && canExpand + const status: WorkLogRowStatus = row.status ?? 'done' + const hasInlineDiagnostics = (row.diagnostics?.length ?? 0) > 0 + + return (
canExpand && setExpanded(e => ({ ...e, [i]: !e[i] }))} + key={i} + className="min-w-0 [animation:cc-fade-up_240ms_cubic-bezier(0.23,1,0.32,1)_both]" > - - - - - - - -
- {hasInlineDiagnostics && ( - - )} - {showBody && ( -
- +
canExpand && setExpanded(current => ({ ...current, [i]: !current[i] }))} + onKeyDown={(event) => { + if (event.currentTarget !== event.target || !canExpand || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + setExpanded(current => ({ ...current, [i]: !current[i] })) + }} + className={`group -mx-[3px] flex min-h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-md px-[3px] py-1 text-left transition-colors duration-150 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-cc-accent ${canExpand ? 'cursor-pointer hover:bg-cc-hover' : ''} ${isErr ? 'text-cc-danger' : ''}`} + > + + + + + {canExpand && ( + + + + )} + + + + +
- )} -
- ) - })} + + {hasInlineDiagnostics && } + {showBody && ( +
+ +
+ )} +
+ ) + })} +
- )} +
) } diff --git a/src/renderer/src/components/ui/AppMenu.tsx b/src/renderer/src/components/ui/AppMenu.tsx index 0a4ebdf..f529fe7 100644 --- a/src/renderer/src/components/ui/AppMenu.tsx +++ b/src/renderer/src/components/ui/AppMenu.tsx @@ -23,6 +23,8 @@ export type AppMenuAction = | { kind: 'start-canvas' } | { kind: 'docs' } | { kind: 'updates' } + | { kind: 'toggle-menulet' } + | { kind: 'toggle-system-monitor' } interface AppMenuItem { id: string diff --git a/src/renderer/src/components/ui/MobileShell.tsx b/src/renderer/src/components/ui/MobileShell.tsx index 2155cb9..e8f187e 100644 --- a/src/renderer/src/components/ui/MobileShell.tsx +++ b/src/renderer/src/components/ui/MobileShell.tsx @@ -1,7 +1,5 @@ import { useState, useEffect, useRef, useCallback, Fragment } from 'react' -import { Icon, type IconName } from './Icon' - -export type MobileTab = 'chat' | 'terminal' | 'editor' | 'git' | 'more' +import { Icon } from './Icon' interface SheetProps { id: string @@ -164,91 +162,18 @@ function Sheet({ title, children, open, onClose, maxHeight = 'calc(100vh - 120px ) } -interface BottomNavProps { - activeTab: MobileTab - onTabChange: (tab: MobileTab) => void - unreadCounts?: Record - sheets: Record - onSheetToggle: (id: string) => void -} - -function BottomNav({ activeTab, onTabChange, unreadCounts, sheets, onSheetToggle }: BottomNavProps) { - const tabs: { id: MobileTab; icon: IconName; label: string; sheetId?: string }[] = [ - { id: 'chat', icon: 'chat', label: 'Chat' }, - { id: 'terminal', icon: 'terminal', label: 'Terminal', sheetId: 'terminal' }, - { id: 'editor', icon: 'code', label: 'Editor' }, - { id: 'git', icon: 'branch', label: 'Git', sheetId: 'git' }, - { id: 'more', icon: 'more', label: 'More', sheetId: 'more' }, - ] - - return ( - - ) -} - interface MobileShellProps { children: React.ReactNode - activeTab: MobileTab - onTabChange: (tab: MobileTab) => void + isMobile: boolean sheets: Record onSheetToggle: (id: string) => void - unreadCounts?: Record } -export function MobileShell({ children, activeTab, onTabChange, sheets, onSheetToggle, unreadCounts }: MobileShellProps) { - const [isMobile, setIsMobile] = useState(false) - useEffect(() => { - const check = () => setIsMobile(window.innerWidth <= 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) +export function MobileShell({ children, isMobile, sheets, onSheetToggle }: MobileShellProps) { if (!isMobile) return <>{children} return ( -
-
{children}
- [k, v.open]))} onSheetToggle={onSheetToggle} /> +
+
{children}
{Object.entries(sheets).map(([id, sheet]) => sheet.open && ( onSheetToggle(id)}>{sheet.content} ))} @@ -257,36 +182,23 @@ export function MobileShell({ children, activeTab, onTabChange, sheets, onSheetT } export function useMobileShell() { - const [activeTab, setActiveTab] = useState('chat') - const [sheets, setSheets] = useState>({}) + const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth <= 768) - const onTabChange = useCallback((tab: MobileTab) => { - setActiveTab(tab) - setSheets(prev => { - const next = { ...prev } - let dirty = false - for (const k of Object.keys(next)) if (next[k].open) { next[k] = { ...next[k], open: false }; dirty = true } - return dirty ? next : prev - }) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth <= 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) }, []) + const [sheets, setSheets] = useState>({}) const onSheetToggle = useCallback((id: string) => { setSheets(prev => ({ ...prev, [id]: { ...prev[id], open: !prev[id]?.open, title: prev[id]?.title ?? id, content: prev[id]?.content ?? null } })) }, []) - const openSheet = useCallback((id: string, title: string, content: React.ReactNode) => { - setSheets(prev => { - const next: typeof prev = {} - for (const [k, v] of Object.entries(prev)) next[k] = k === id ? { open: true, title, content } : { ...v, open: false } - if (!next[id]) next[id] = { open: true, title, content } - else next[id] = { open: true, title, content } - return next - }) - }, []) - const closeSheet = useCallback((id: string) => { setSheets(prev => ({ ...prev, [id]: { ...prev[id], open: false } })) }, []) - return { activeTab, onTabChange, sheets, onSheetToggle, openSheet, closeSheet, setActiveTab } + return { isMobile, sheets, onSheetToggle, closeSheet } } diff --git a/src/renderer/src/components/ui/WindowTabs.test.ts b/src/renderer/src/components/ui/WindowTabs.test.ts index 6c8d0b4..e06372d 100644 --- a/src/renderer/src/components/ui/WindowTabs.test.ts +++ b/src/renderer/src/components/ui/WindowTabs.test.ts @@ -1,8 +1,36 @@ +import { readFileSync } from 'fs' +import { join } from 'path' import { describe, expect, it } from 'vitest' import { NEW_TAB_ACTIONS } from './WindowTabs' describe('WindowTabs new-tab menu', () => { + it('is mounted by App for desktop and mobile layouts', () => { + const app = readFileSync(join(__dirname, '../../App.tsx'), 'utf8') + const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') + expect(app).toContain("
") + expect(app).toContain(' { + const app = readFileSync(join(__dirname, '../../App.tsx'), 'utf8') + const tabs = readFileSync(join(__dirname, './WindowTabs.tsx'), 'utf8') + const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') + expect(app).toContain('useMobileWindowTabsAutoHide({') + expect(app).toContain('onNewTabMenuOpenChange={setWindowTabsMenuOpen}') + expect(tabs).toContain('onNewTabMenuOpenChange?.(newMenuOpen)') + expect(styles).toContain('.window-tabs.mobile-tabs-hidden {') + expect(styles).toContain('transform: translateY(-100%);') + }) + + it('replaces the mobile bottom nav with the scrollable tab strip and add menu', () => { + const shell = readFileSync(join(__dirname, './MobileShell.tsx'), 'utf8') + const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') + expect(shell).not.toContain('mobile-bottom-nav') + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.tab-add-wrap \.tab-menu/) + }) + it('offers the built-in control, studio, and Git workspace pages', () => { expect(NEW_TAB_ACTIONS).toEqual(expect.arrayContaining([ { kind: 'mission', icon: 'grid', label: 'Control Center' }, diff --git a/src/renderer/src/components/ui/WindowTabs.tsx b/src/renderer/src/components/ui/WindowTabs.tsx index 35bea3a..32875ae 100644 --- a/src/renderer/src/components/ui/WindowTabs.tsx +++ b/src/renderer/src/components/ui/WindowTabs.tsx @@ -48,6 +48,7 @@ interface WindowTabsProps { onReorder?: (tabId: string, beforeTabId: string | null) => void pluginMenuItems?: WindowTabPluginMenuItem[] onPluginMenuItem?: (item: WindowTabPluginMenuItem) => void + onNewTabMenuOpenChange?: (open: boolean) => void } const TAB_ICONS: Record = { @@ -157,7 +158,7 @@ export const WindowTabs = memo(function WindowTabs({ tabs, activeId, onActivate, onClose, onAppMenuAction, activeKind, appMenuFootStatus, crewTabs, splitGroups = [], splitTabIds = [], splitPrimaryTabId, onSplit, onCloseSplitGroup, onPin, onUnpin, onRename, onColor, onReorder, - pluginMenuItems = [], onPluginMenuItem, + pluginMenuItems = [], onPluginMenuItem, onNewTabMenuOpenChange, }: WindowTabsProps) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tab: Tab } | null>(null) const [draggingTabId, setDraggingTabId] = useState(null) @@ -196,6 +197,12 @@ export const WindowTabs = memo(function WindowTabs({ setCtxMenu({ x, y, tab }) }, []) + useEffect(() => { + onNewTabMenuOpenChange?.(newMenuOpen) + }, [newMenuOpen, onNewTabMenuOpenChange]) + + useEffect(() => () => onNewTabMenuOpenChange?.(false), [onNewTabMenuOpenChange]) + useEffect(() => { if (!newMenuOpen) return const onMouseDown = (e: MouseEvent): void => { diff --git a/src/renderer/src/components/workspaces/WorkspaceDock.tsx b/src/renderer/src/components/workspaces/WorkspaceDock.tsx index 0038503..d181350 100644 --- a/src/renderer/src/components/workspaces/WorkspaceDock.tsx +++ b/src/renderer/src/components/workspaces/WorkspaceDock.tsx @@ -221,6 +221,27 @@ interface AgentInfoProps { resetDescription?: string | null } +function MobileProviderUsage({ agentId, providerUsed, providerLimit }: Pick) { + const pct = providerLimit && providerLimit > 0 + ? Math.min(100, Math.max(0, ((providerUsed ?? 0) / providerLimit) * 100)) + : 0 + const color = pct > 90 ? 'var(--destructive)' : pct > 70 ? 'var(--warning)' : 'var(--crew-green-bright, #2f9d72)' + + return ( + + {PROVIDER_IMAGES[agentId ?? ''] ? ( + + ) : } + + {pct.toFixed(0)}% + + ) +} + function AgentInfoPill({ agentId = 'claude', status = 'idle', providerUsed = 0, providerLimit = 0, resetDescription }: AgentInfoProps) { const [open, setOpen] = useState(false) const meta = PROVIDER_META[agentId] ?? { name: agentId, icon: 'bot' } @@ -372,6 +393,7 @@ export function WorkspaceDock({ none — click to add )} + {pluginStatusItems.map(item => ( +
+
+ )) + } + function renderWorkspaceRow(ws: Workspace) { return (
@@ -356,7 +394,10 @@ export function WorkspacesDrawer({ active={ws.id === active} agentActivity={workspaceAgentStatus[ws.id]} displayPath={workspaceDisplayPath(ws.path, homePath)} - onClick={() => setActive(ws.id)} + onClick={() => { + setActive(ws.id) + if (mobileOverlay) setOpen(false) + }} onRename={onRenameWorkspace ? (name) => onRenameWorkspace(ws.id, name) : undefined} />
@@ -455,6 +496,14 @@ export function WorkspacesDrawer({ return ( <> + {mobileOverlay && open && ( + )} @@ -501,31 +550,26 @@ export function WorkspacesDrawer({
{drawerTab === 'app' ? ( -
setSectionClosed(p => ({ ...p, __features: !p['__features'] }))} - > - {APP_FEATURES.map(f => ( -
-
- -
-
- ))} -
+ <> +
setSectionClosed(p => ({ ...p, __app_destinations: !p['__app_destinations'] }))} + > + {renderAppRows(APP_DESTINATIONS)} +
+
setSectionClosed(p => ({ ...p, __features: !p['__features'] }))} + > + {renderAppRows(APP_FEATURES)} +
+ ) : ( <> {(() => { @@ -559,7 +603,7 @@ export function WorkspacesDrawer({
) : (
- {panes.map(pane => ( -
-
- {pane.title} -
- {pane.kind === 'chat' && pane.modePrompt && ( - + {panes.map(pane => { + const isChat = pane.kind === 'chat' + const menuOpen = paneMenuId === pane.id + return ( +
+
+ {pane.title} + {isMobile ? ( + // Phone: collapse the action cluster into a single + // overflow button. The popover carries prompt toggle, + // verbose-log toggle, and close. Add chat / Add terminal + // live on the page-level FAB. +
+ + {menuOpen && ( +
+ {isChat && pane.modePrompt && ( + + )} + {isChat && ( + + )} + +
+ )} +
+ ) : ( + // Desktop: keep the full inline cluster (mode prompt, + // logs toggle, Add chat, Add terminal, close). +
+ {isChat && pane.modePrompt && ( + + )} + {isChat && ( + + )} + + + +
)} - {pane.kind === 'chat' && ( - - )} -
Add chat
-
Add terminal
-
-
-
{pane.content}
-
- ))} +
{pane.content}
+ + ) + })} +
+ )} + + {/* Phone-only FAB: in the empty-hub case the start cards already expose + the Add buttons. Once the user has at least one pane, the only way + to add another is from inside that pane's title bar. On phones that + bar overflows, so we surface a sticky FAB with the same actions. */} + {isMobile && panes.length > 0 && ( +
+ {fabOpen && ( +
+ + +
+ )} +
)} diff --git a/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts b/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts new file mode 100644 index 0000000..1515a19 --- /dev/null +++ b/src/renderer/src/components/canvas/mobile-canvas-mode.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/styles.css'), 'utf8') +const canvas = readFileSync(join(__dirname, 'CanvasMode.tsx'), 'utf8') + +describe('mobile canvas mode layout', () => { + it('renders the Add chat / Add terminal controls as buttons (a11y)', () => { + expect(canvas).toMatch(/ ) } -export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnstage, onStageAll, onUnstageAll }: GitPageChangesProps) { +export function GitPageChanges({ repoPath, comparisonRef, changes, hasUnpushed, onStage, onUnstage, onStageAll, onUnstageAll }: GitPageChangesProps) { const staged = useMemo(() => changes.filter(change => change.staged), [changes]) const unstaged = useMemo(() => changes.filter(change => !change.staged), [changes]) const [selected, setSelected] = useState(null) const [diff, setDiff] = useState<{ loading: boolean; patch: string; error?: string }>({ loading: false, patch: '' }) useEffect(() => { - if (!selected || changes.every(change => change.path !== selected.path || change.staged !== selected.staged)) { + const current = selected + ? changes.find(change => change.path === selected.path && change.staged === selected.staged) + : undefined + if (!selected || !current) { const first = changes[0] - setSelected(first ? { path: first.path, staged: first.staged, title: `${first.staged ? 'staged' : 'unstaged'}: ${first.path}` } : null) + setSelected(first ? { + path: first.path, + staged: first.staged, + title: comparisonRef ? `vs ${comparisonRef}: ${first.path}` : `${first.staged ? 'staged' : 'unstaged'}: ${first.path}`, + } : null) + return } - }, [changes, selected]) + const title = comparisonRef ? `vs ${comparisonRef}: ${current.path}` : `${current.staged ? 'staged' : 'unstaged'}: ${current.path}` + if (selected.title !== title) setSelected({ ...selected, title }) + }, [changes, comparisonRef, selected]) useEffect(() => { let cancelled = false @@ -74,7 +88,11 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst return } setDiff({ loading: true, patch: '' }) - window.electronAPI?.gitDiff(repoPath, selected.path, selected.staged) + const client = getCrewCodeClient() + const request = comparisonRef + ? client.gitDiffVsRef(repoPath, comparisonRef, selected.path) + : client.gitDiff(repoPath, selected.path, selected.staged) + request .then(result => { if (cancelled) return if (result?.error) setDiff({ loading: false, patch: '', error: result.error }) @@ -84,10 +102,14 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst if (!cancelled) setDiff({ loading: false, patch: '', error: String(error) }) }) return () => { cancelled = true } - }, [repoPath, selected]) + }, [repoPath, comparisonRef, selected]) const select = (change: GitChange) => { - setSelected({ path: change.path, staged: change.staged, title: `${change.staged ? 'staged' : 'unstaged'}: ${change.path}` }) + setSelected({ + path: change.path, + staged: change.staged, + title: comparisonRef ? `vs ${comparisonRef}: ${change.path}` : `${change.staged ? 'staged' : 'unstaged'}: ${change.path}`, + }) } return ( @@ -95,7 +117,7 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst
- Changes + {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'}

{changes.length || 'No'} changed file{changes.length === 1 ? '' : 's'}

{hasUnpushed && ahead} @@ -108,7 +130,15 @@ export function GitPageChanges({ repoPath, changes, hasUnpushed, onStage, onUnst )} {unstaged.length > 0 && (
-
Changes · {unstaged.length}
+
+ {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'} · {unstaged.length} + {unstaged.some(change => change.stageable !== false) && ( + + )} +
{unstaged.map(change => select(change)} onStage={onStage} onUnstage={onUnstage} />)}
)} diff --git a/src/renderer/src/components/git/GitSidebar.tsx b/src/renderer/src/components/git/GitSidebar.tsx index 5bead9f..6501ce3 100644 --- a/src/renderer/src/components/git/GitSidebar.tsx +++ b/src/renderer/src/components/git/GitSidebar.tsx @@ -310,11 +310,13 @@ interface ChangesBodyProps { onUnstageAll?: (paths: string[]) => void onDiscard?: (path: string) => void onOpenDiff?: (path: string, staged: boolean) => void + comparisonRef?: string } -function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, hasUnpushed, onOpenDiff }: ChangesBodyProps) { +function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, hasUnpushed, onOpenDiff, comparisonRef }: ChangesBodyProps) { const staged = changes.filter(c => c.staged) const unstaged = changes.filter(c => !c.staged) + const stageableUnstaged = unstaged.filter(c => c.stageable !== false) return ( <> @@ -352,8 +354,8 @@ function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, ha {unstaged.length > 0 && ( <>
- Changes · {unstaged.length} - + {comparisonRef ? `Changes vs ${comparisonRef}` : 'Changes'} · {unstaged.length} + {stageableUnstaged.length > 0 && }
{unstaged.map(f => ( @@ -369,11 +371,13 @@ function ChangesBody({ changes, onStage, onUnstage, onStageAll, onUnstageAll, ha {f.add ? +{f.add} : null} {f.del ? −{f.del} : null} - + {f.stageable !== false && ( + + )}
))}
@@ -928,7 +932,7 @@ export function GitSidebar({ {!hideSections.changes && ( toggle('changes')} @@ -942,6 +946,7 @@ export function GitSidebar({ onUnstageAll={onUnstageAll} onDiscard={onDiscardFile} onOpenDiff={onOpenFileDiff} + comparisonRef={state.comparisonRef} /> )} diff --git a/src/renderer/src/components/git/git-state.ts b/src/renderer/src/components/git/git-state.ts index f59054b..113828e 100644 --- a/src/renderer/src/components/git/git-state.ts +++ b/src/renderer/src/components/git/git-state.ts @@ -18,6 +18,8 @@ export interface GitChange { path: string // full path from repo root name: string // basename for display dir: string // dirname + trailing slash + /** False for committed differences that exist only relative to the comparison branch. */ + stageable?: boolean add?: number del?: number } @@ -88,6 +90,8 @@ export interface GitState { isRepo?: boolean // false when the folder isn't a git repo yet hasRemote?: boolean // false when no git remote is configured hasUpstream?: boolean // false when the current branch has not been pushed/tracked + /** Settings-selected branch used as the review/comparison base. */ + comparisonRef?: string } export interface GitPublishOpts { diff --git a/src/renderer/src/components/mission/MCComponents.tsx b/src/renderer/src/components/mission/MCComponents.tsx index 66f22ee..c244df7 100644 --- a/src/renderer/src/components/mission/MCComponents.tsx +++ b/src/renderer/src/components/mission/MCComponents.tsx @@ -7,6 +7,7 @@ import type { MCAgent, MCProject, FeedEvent, FeedKind, AgentKind, AgentStatus, AgentMode, Filter, Grouping, } from './missionTypes' +import { deriveMissionStats } from './mission-stats' // ── small shared bits ────────────────────────────────────────────────────── @@ -47,40 +48,33 @@ export const fmtCost = (n: number): string => `$${n.toFixed(2)}` // ── top stat strip ───────────────────────────────────────────────────────── export function StatStrip({ agents }: { agents: MCAgent[] }) { - const count = (s: AgentStatus): number => agents.filter(a => a.status === s).length - const total = agents.length - const blocked = count('blocked') - const running = count('running') - const idle = count('idle') - const done = count('done') - const worktrees = new Set(agents.map(a => `${a.projectId}/${a.worktree}`)).size - const tokens = agents.reduce((s, a) => s + a.tokens, 0) + const stats = deriveMissionStats(agents) return (
agents - {total} + {stats.agents}
-
+
blocked - {blocked} + {stats.blocked}
running - {running} + {stats.running}
idle - {idle} + {stats.idle}
done - {done} + {stats.done}
worktrees - {worktrees}·{fmtTokens(tokens)} tokens + {stats.worktrees}·{fmtTokens(stats.tokens)} tokens
) @@ -155,15 +149,18 @@ const GROUPINGS: { id: Grouping; label: string }[] = [ ] interface ToolbarProps { - filter: Filter - setFilter: (f: Filter) => void - grouping: Grouping - setGrouping: (g: Grouping) => void - agents: MCAgent[] - onSpawn?: () => void + filter: Filter + setFilter: (f: Filter) => void + grouping: Grouping + setGrouping: (g: Grouping) => void + agents: MCAgent[] + onSpawn?: () => void + /** Phone-only — opens the activity feed sheet. Rendered as a pill on mobile. */ + onOpenActivity?: () => void + activityCount?: number } -export function Toolbar({ filter, setFilter, grouping, setGrouping, agents, onSpawn }: ToolbarProps) { +export function Toolbar({ filter, setFilter, grouping, setGrouping, agents, onSpawn, onOpenActivity, activityCount }: ToolbarProps) { const counts: Record = TOOLBAR_FILTERS.reduce((acc, f) => { acc[f.id] = f.id === 'all' ? agents.length : agents.filter(a => a.status === f.id).length return acc @@ -178,27 +175,33 @@ export function Toolbar({ filter, setFilter, grouping, setGrouping, agents, onSp ))}
-
- group by -
- - {openGroup && ( -
- {GROUPINGS.map(g => ( - - ))} -
+
+ {onOpenActivity && ( + )} + group by +
+ + {openGroup && ( +
+ {GROUPINGS.map(g => ( + + ))} +
+ )} +
+ +
- - -
) } diff --git a/src/renderer/src/components/mission/MissionControl.tsx b/src/renderer/src/components/mission/MissionControl.tsx index 9950801..bb67f8d 100644 --- a/src/renderer/src/components/mission/MissionControl.tsx +++ b/src/renderer/src/components/mission/MissionControl.tsx @@ -4,7 +4,9 @@ import type { AgentUserResponse } from '../../types' import { StatStrip, BlockingBanner, Toolbar, AgentCard, GroupHeader, ActivityFeed, } from './MCComponents' +import { deriveMissionStats } from './mission-stats' import { DEFAULT_HUB_TITLE, useHubTitle } from './useHubTitle' +import { useMobileLayout } from '../../hooks/useMobileLayout' import type { RegisteredPluginMissionWidget } from '../../../../shared/plugin-types' function groupAgents(agents: MCAgent[], projects: MCProject[], grouping: Grouping): Group[] { @@ -66,15 +68,22 @@ interface MissionControlProps { onRespondRequest?: (response: AgentUserResponse) => void | Promise pluginMissionWidgets?: RegisteredPluginMissionWidget[] onPluginMissionWidget?: (target: { pluginId: string; sidebarPanel?: string; tab?: string; command?: string }) => void + /** + * Phone-only — the activity feed is moved out of layout into a MobileShell + * sheet. When this is set, `.mc-side` is hidden on mobile and a header + * "Activity" button calls it to reveal the sheet. + */ + onOpenActivity?: () => void } export function MissionControl({ agents, projects, feed, onOpenAgent, onPauseAgent, onResumeAgent, onSpawnAgent, onRespondRequest, - pluginMissionWidgets = [], onPluginMissionWidget, + pluginMissionWidgets = [], onPluginMissionWidget, onOpenActivity, }: MissionControlProps) { const [filter, setFilter] = useState('all') const [grouping, setGrouping] = useState('project') + const { isMobile } = useMobileLayout() // Hero title — Mission Control's own label, independent of CrewSession.name. const { title: hubTitle, setTitle: setHubTitle } = useHubTitle() @@ -112,7 +121,7 @@ export function MissionControl({ [filtered, projects, grouping], ) - const worktreeCount = new Set(agents.map(a => `${a.projectId}/${a.worktree}`)).size + const missionStats = deriveMissionStats(agents) return (
@@ -144,7 +153,7 @@ export function MissionControl({ {hubTitle || DEFAULT_HUB_TITLE} )} - {agents.length} agents · {projects.length} projects · {worktreeCount} worktrees + {missionStats.agents} agents · {projects.length} projects · {missionStats.worktrees} worktrees
@@ -157,6 +166,8 @@ export function MissionControl({ grouping={grouping} setGrouping={setGrouping} agents={agents} onSpawn={onSpawnAgent} + onOpenActivity={isMobile ? onOpenActivity : undefined} + activityCount={feed.length} />
@@ -197,21 +208,23 @@ export function MissionControl({
-
- {pluginMissionWidgets.length > 0 && ( -
-
plugin widgets
-
- {pluginMissionWidgets.map(widget => ( - - ))} + {!isMobile && ( +
+ {pluginMissionWidgets.length > 0 && ( +
+
plugin widgets
+
+ {pluginMissionWidgets.map(widget => ( + + ))} +
-
- )} - -
+ )} + +
+ )}
) } diff --git a/src/renderer/src/components/mission/MissionDataContext.tsx b/src/renderer/src/components/mission/MissionDataContext.tsx index 08e2fad..d76e16b 100644 --- a/src/renderer/src/components/mission/MissionDataContext.tsx +++ b/src/renderer/src/components/mission/MissionDataContext.tsx @@ -4,6 +4,7 @@ import { useMessagesStore } from '../../stores/chat-messages-store' import { useMissionData, type MissionData, type UseMissionDataOpts } from './useMissionData' import { MissionControl } from './MissionControl' import { Menulet, MenuletTrigger } from './Menulet' +import { ActivityFeed } from './MCComponents' import type { AgentUserResponse, Message } from '../../types' import type { RegisteredPluginMissionWidget } from '../../../../shared/plugin-types' @@ -122,7 +123,7 @@ interface MissionPluginProps { } /** Mission Control tab body — reads mission data from context, not App. */ -export function MissionControlHost({ onOpenAgent, onPauseAgent, onResumeAgent, onSpawnAgent, onRespondRequest, pluginMissionWidgets = [], onPluginMissionWidget }: McHandlers & MissionPluginProps) { +export function MissionControlHost({ onOpenAgent, onPauseAgent, onResumeAgent, onSpawnAgent, onRespondRequest, pluginMissionWidgets = [], onPluginMissionWidget, onOpenActivity }: McHandlers & MissionPluginProps & { onOpenActivity?: () => void }) { const { agents, projects, feed } = useMissionDataValue() return ( ) } @@ -147,6 +149,17 @@ interface MenuletHostProps extends McHandlers { onOpenHub: () => void } +/** + * Standalone activity feed for the mobile Mission Control sheet. Lives inside + * the `MissionDataProvider` so it can read the same `feed` and `projects` + * slice that the page consumes; the parent just drops it into a `MobileShell` + * sheet via `useMobileShell().sheets['mission-activity']`. + */ +export function MissionActivitySheetHost() { + const { feed, projects } = useMissionDataValue() + return +} + /** Menulet trigger + popover — reads live agent counts from context, not App. */ export function MenuletHost({ open, onToggle, onClose, onOpenHub, onOpenAgent, onPauseAgent, onResumeAgent, onSpawnAgent, onRespondRequest, diff --git a/src/renderer/src/components/mission/mission-stats.test.ts b/src/renderer/src/components/mission/mission-stats.test.ts new file mode 100644 index 0000000..e77f1ba --- /dev/null +++ b/src/renderer/src/components/mission/mission-stats.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { deriveMissionStats } from './mission-stats' + +describe('deriveMissionStats', () => { + it('uses Mission Control status and project/worktree semantics', () => { + const stats = deriveMissionStats([ + { status: 'running', projectId: 'one', worktree: 'main', tokens: 10 }, + { status: 'blocked', projectId: 'one', worktree: 'main', tokens: 20 }, + { status: 'done', projectId: 'one', worktree: 'feature', tokens: 30 }, + { status: 'idle', projectId: 'two', worktree: 'main', tokens: 40 }, + ]) + expect(stats).toEqual({ agents: 4, blocked: 1, running: 1, idle: 1, done: 1, worktrees: 3, tokens: 100 }) + }) +}) diff --git a/src/renderer/src/components/mission/mission-stats.ts b/src/renderer/src/components/mission/mission-stats.ts new file mode 100644 index 0000000..03a16cf --- /dev/null +++ b/src/renderer/src/components/mission/mission-stats.ts @@ -0,0 +1,25 @@ +import type { AgentStatus, MCAgent } from './missionTypes' + +export interface MissionStats { + agents: number + blocked: number + running: number + idle: number + done: number + worktrees: number + tokens: number +} + +/** Canonical aggregation used by every Mission Control stat presentation. */ +export function deriveMissionStats(agents: ReadonlyArray>): MissionStats { + const count = (status: AgentStatus): number => agents.filter(agent => agent.status === status).length + return { + agents: agents.length, + blocked: count('blocked'), + running: count('running'), + idle: count('idle'), + done: count('done'), + worktrees: new Set(agents.map(agent => `${agent.projectId}/${agent.worktree}`)).size, + tokens: agents.reduce((total, agent) => total + agent.tokens, 0), + } +} diff --git a/src/renderer/src/components/mission/mobile-mission-control.test.ts b/src/renderer/src/components/mission/mobile-mission-control.test.ts new file mode 100644 index 0000000..489c6b6 --- /dev/null +++ b/src/renderer/src/components/mission/mobile-mission-control.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/mission-control.css'), 'utf8') +const control = readFileSync(join(__dirname, 'MissionControl.tsx'), 'utf8') +const ctx = readFileSync(join(__dirname, 'MissionDataContext.tsx'),'utf8') +const comps = readFileSync(join(__dirname, 'MCComponents.tsx'), 'utf8') +const app = readFileSync(join(__dirname, '../../App.tsx'), 'utf8') + +describe('mobile mission control layout', () => { + it('exposes a MissionActivitySheetHost inside MissionDataProvider', () => { + expect(ctx).toMatch(/export function MissionActivitySheetHost/) + expect(ctx).toMatch(/ActivityFeed/) + }) + + it('wires the activity sheet into MobileShell sheets map', () => { + expect(app).toMatch(/'mission-activity':[\s\S]{0,160}MissionActivitySheetHost/) + }) + + it('passes onOpenActivity to MissionControlHost on mobile only', () => { + expect(app).toMatch(/onOpenActivity=\{mobile\.isMobile \?[^}]+onSheetToggle\('mission-activity'\)[^}]+:[^}]+undefined\}/) + }) + + it('renders an Activity pill in the toolbar when onOpenActivity is set', () => { + expect(comps).toMatch(/onOpenActivity[\s\S]{0,40}mc-activity-btn/) + }) + + it('hides the inline side feed and reveals it only via the sheet on phones', () => { + expect(control).toMatch(/\{!isMobile && \([\s\S]*?
/) + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-side \{ display: none; \}/) + }) + + it('collapses the 6-column StatStrip to 3 columns below 768px and 2 below 480px', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-stats \{ grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/) + expect(styles).toMatch(/@media \(max-width: 480px\)[\s\S]*?\.mc-stats \{ grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/) + }) + + it('drops the agent grid floor so cards fit on a 360px viewport', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-grid \{ grid-template-columns: repeat\(auto-fill, minmax\(min\(280px, 100%\), 1fr\)\);/) + }) + + it('reworks BlockingBanner into a two-row layout for narrow widths', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-banner-row \{[\s\S]*?grid-template-columns: 28px minmax\(0, 1fr\);[\s\S]*?grid-template-rows: auto auto;/) + expect(styles).toMatch(/\.mc-banner-reply \{[\s\S]*?grid-column: 1 \/ -1; grid-row: 2;/) + }) + + it('keeps touch targets at 36px or larger for actionable controls below 768px', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-icobtn \{ width: 36px; height: 36px;/) + expect(styles).toMatch(/\.mc-spawn \{[\s\S]*?min-height: 36px/) + expect(styles).toMatch(/\.mc-card-actions button \{ width: 36px; height: 36px;/) + }) + + it('prevents iOS auto-zoom on the banner reply input', () => { + expect(styles).toMatch(/\.mc-banner-reply input \{[\s\S]*?font-size: 16px;/) + }) + + it('groups the toolbar action controls into a right-aligned second row on phones', () => { + expect(comps).toMatch(/
/) + // The desktop spacer is hidden so the action row can right-align + // independently of the filter row. + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.mc-toolbar > \.grow \{ display: none;/) + expect(styles).toMatch(/\.mc-toolbar-actions \{[\s\S]*?justify-content: flex-end;[\s\S]*?flex: 1 1 100%;/) + }) +}) \ No newline at end of file diff --git a/src/renderer/src/components/promptBuilder/PromptBuilder.tsx b/src/renderer/src/components/promptBuilder/PromptBuilder.tsx index 4ca6765..934f845 100644 --- a/src/renderer/src/components/promptBuilder/PromptBuilder.tsx +++ b/src/renderer/src/components/promptBuilder/PromptBuilder.tsx @@ -1,5 +1,6 @@ import { useMemo, useRef, useState } from 'react' import { Icon } from '../ui/Icon' +import { useMobileLayout } from '../../hooks/useMobileLayout' import { PromptCard } from './PromptCard' import { PromptDetail, type MdMode } from './PromptDetail' import type { PromptLibrary } from '../../hooks/usePromptLibrary' @@ -33,6 +34,13 @@ export function PromptBuilder({ lib, onUseInChat, onApplySkill }: PromptBuilderP const [favOnly, setFavOnly] = useState(false) const [layout, setLayout] = useState<'cards' | 'rows'>('cards') + // On phones the rail and detail can't share the screen. The `view` state + // toggles between them; CSS hides whichever is inactive via the + // `data-view` attribute on `.pb`. We default to `'list'` so a phone user + // opening the page lands on the catalogue first. + const { isMobile } = useMobileLayout() + const [view, setView] = useState<'list' | 'detail'>('list') + // Custom category management UI const [catMenuOpen, setCatMenuOpen] = useState(false) const [newCatName, setNewCatName] = useState('') @@ -130,9 +138,10 @@ export function PromptBuilder({ lib, onUseInChat, onApplySkill }: PromptBuilderP } return ( -
+
@@ -284,6 +384,7 @@ export function PromptBuilder({ lib, onUseInChat, onApplySkill }: PromptBuilderP onToggleEnabled={handleToggleSkillEnabled} onDuplicate={handleDuplicate} onDelete={handleDelete} + onBack={isMobile ? () => setView('list') : undefined} /> ) : (
diff --git a/src/renderer/src/components/promptBuilder/PromptDetail.tsx b/src/renderer/src/components/promptBuilder/PromptDetail.tsx index 01206f7..ef8910b 100644 --- a/src/renderer/src/components/promptBuilder/PromptDetail.tsx +++ b/src/renderer/src/components/promptBuilder/PromptDetail.tsx @@ -25,11 +25,18 @@ interface PromptDetailProps { * live session binding, not editor content. */ onToggleEnabled?: () => void + /** + * Phone-only — return to the prompt library. When set, a `[< Back]` + * button is rendered at the front of the detail header. The button is + * hidden on desktop via CSS (`.pd-back-btn { display: none }` by default + * and `inline-flex` only inside `@media (max-width: 768px)`). + */ + onBack?: () => void } export function PromptDetail({ p, kind, mdMode, setMdMode, customCategories = [], onCommit, onUseInChat, onApplySkill, onDuplicate, onDelete, - onToggleEnabled, + onToggleEnabled, onBack, }: PromptDetailProps) { const [draft, setDraft] = useState(p) const [savedAt, setSavedAt] = useState('saved') @@ -82,6 +89,11 @@ export function PromptDetail({ return (
+ {onBack && ( + + )}
diff --git a/src/renderer/src/components/promptBuilder/mobile-prompt-builder.test.ts b/src/renderer/src/components/promptBuilder/mobile-prompt-builder.test.ts new file mode 100644 index 0000000..eec2d50 --- /dev/null +++ b/src/renderer/src/components/promptBuilder/mobile-prompt-builder.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const styles = readFileSync(join(__dirname, '../../styles/prompt-builder.css'), 'utf8') +const builder = readFileSync(join(__dirname, 'PromptBuilder.tsx'), 'utf8') +const detail = readFileSync(join(__dirname, 'PromptDetail.tsx'), 'utf8') + +describe('mobile prompt builder layout', () => { + it('exposes a list/detail navigation state driven by `useMobileLayout`', () => { + expect(builder).toMatch(/useMobileLayout/) + expect(builder).toMatch(/setView\('detail'\)|setView\("detail"\)/) + expect(builder).toMatch(/setView\('list'\)|setView\("list"\)/) + expect(builder).toMatch(/data-view=/) + }) + + it('collapses the page to a single column below 414px and hides the inactive view', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb \{ grid-template-columns: 1fr;/) + expect(styles).toContain('.pb[data-view="list"] .pb-right { display: none; }') + expect(styles).toContain('.pb[data-view="detail"] .pb-left { display: none; }') + }) + + it('tightens the two-pane grid for tablet widths (769–1024px)', () => { + expect(styles).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.pb \{ grid-template-columns: 300px 1fr;/) + }) + + it('stacks the markdown source/preview split on phones and caps source height', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pd-body\.md-mode-split \{ grid-template-columns: 1fr;/) + expect(styles).toMatch(/\.pd-body\.md-mode-split \.pd-source \{ min-height: 180px; max-height: 45vh;/) + }) + + it('renders a Back button on the detail header when onBack is set', () => { + expect(detail).toMatch(/onBack\?:/) + expect(detail).toMatch(/onBack && \(/) + expect(detail).toContain('pd-back-btn') + expect(detail).toContain('chevLeft') + }) + + it('hides the Back button on desktop and shows it only below 768px', () => { + expect(styles).toMatch(/\.pd-back-btn \{[\s\S]*?display: none/) + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pd-back-btn \{[\s\S]*?display: inline-flex/) + }) + + it('keeps touch targets at 36px or larger for actionable controls below 768px', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-icobtn \{ width: 36px; height: 36px;/) + expect(styles).toMatch(/\.pd-icobtn \{ width: 36px; height: 36px;/) + expect(styles).toMatch(/\.pd-mdt-btn \{ width: 36px; height: 36px;/) + expect(styles).toMatch(/\.pd-save \{[\s\S]*?min-height: 36px/) + }) + + it('prevents iOS auto-zoom by setting 16px on inputs inside the page', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-search input[\s\S]*?font-size: 16px;/) + }) + + it('hides keyboard-shortcut noise below 768px', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-search \.kbd,[\s\S]*?\.pb-foot-kbd,/) + }) + + it('collapses the PromptPicker popover into a bottom-anchored full-bleed sheet on phones', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.ppicker \{[\s\S]*?top: auto; bottom: 0;[\s\S]*?border-radius: 16px 16px 0 0/) + expect(styles).toMatch(/\.ppicker-fill \{[\s\S]*?width: 100%;[\s\S]*?border-left: 0;[\s\S]*?border-bottom: 1px solid var\(--border\)/) + }) + + it('stacks the pb-left title row on phones so the tab strip gets full width', () => { + expect(builder).toMatch(/isMobile \? \(\s*<>\s*
/) + expect(builder).toMatch(/
/) + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-title-row \{[\s\S]*?flex-direction: column;[\s\S]*?align-items: center;/) + expect(styles).toContain('.pb-new {') + }) + + it('scrolls the category chips horizontally on phones and moves the trailing icon tools to a separate row', () => { + expect(styles).toMatch(/\.pb-cats \{[\s\S]*?flex-direction: column;/) + expect(styles).toMatch(/\.pb-cats-scroll \{[\s\S]*?overflow-x: auto;/) + expect(styles).toMatch(/\.pb-cats-tools \{[\s\S]*?justify-content: flex-end;/) + expect(styles).toMatch(/\.pb-cats-spacer \{ display: none;/) + }) + + it('keeps the pb-left header compact and centered on phones', () => { + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-left-h \{ padding: 12px 16px 10px; gap: 8px;/) + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-title-row \{[\s\S]*?flex-direction: column;/) + expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.pb-new \{[\s\S]*?align-self: center;[\s\S]*?padding: 8px 18px;/) + }) + + it('renders pb-left as a floating centered card on phones', () => { + expect(builder).toMatch(/