diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf8c486..be0da78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,25 @@ name: CI on: workflow_dispatch: + inputs: + signed_release: + description: Build signed and notarized macOS dry-run artifacts after verification. + type: boolean + required: false + default: false + release_tag: + description: Required for signed_release; must match package.json, with or without v. + type: string + required: false + default: '' pull_request: push: branches: [main] tags: ["*"] +permissions: + contents: read + jobs: verify: runs-on: macos-15 @@ -22,23 +36,15 @@ jobs: - run: npm test - run: npm run build - artifact: - if: startsWith(github.ref, 'refs/tags/') + signed_release: + name: Signed macOS dry-run + if: github.event_name == 'workflow_dispatch' && inputs.signed_release needs: verify - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run package - - run: npm run smoke - - uses: actions/upload-artifact@v4 - with: - name: runta-crew-macos-arm64 - path: | - release/*.dmg - release/*.zip - if-no-files-found: error + permissions: + contents: write + uses: ./.github/workflows/publish-release.yml + with: + tag: ${{ inputs.release_tag }} + ref: ${{ github.sha }} + dry_run: true + secrets: inherit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..866dd7d --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,200 @@ +name: Publish macOS Release + +on: + release: + types: [published] + workflow_call: + inputs: + tag: + type: string + required: true + ref: + type: string + required: false + default: '' + dry_run: + type: boolean + required: false + default: true + workflow_dispatch: + inputs: + tag: + description: Release tag matching package.json, with or without a v prefix. + type: string + required: true + ref: + description: Git ref for a manual dry-run; defaults to the selected workflow ref. + type: string + required: false + default: '' + dry_run: + description: Sign, notarize, and validate Actions artifacts without uploading Release assets. + type: boolean + required: true + default: true + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + package_macos: + name: Sign and notarize macOS arm64 + runs-on: macos-15 + environment: apple-release + timeout-minutes: 90 + outputs: + version: ${{ steps.release.outputs.version }} + release_tag: ${{ steps.release.outputs.release_tag }} + dry_run: ${{ steps.release.outputs.dry_run }} + source_sha: ${{ steps.release.outputs.source_sha }} + + steps: + - name: Require Apple release configuration + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ vars.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ vars.APPLE_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + [[ "$(uname -m)" == arm64 ]] || { echo '::error::An ARM64 macOS runner is required'; exit 1; } + missing=() + for name in APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_NOTARY_KEY_BASE64 APPLE_NOTARY_KEY_ID APPLE_NOTARY_ISSUER_ID; do + if [[ -z "${!name}" ]]; then missing+=("$name"); fi + done + if (( ${#missing[@]} )); then + echo "::error::Missing Apple release configuration: ${missing[*]}" + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.dry_run && (inputs.ref || github.sha) || inputs.tag || github.event.release.tag_name }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + architecture: arm64 + cache: npm + + - name: Validate release source and version + id: release + env: + RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + MANUAL_REF: ${{ inputs.ref }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + import fs from 'node:fs'; + const { version } = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const tag = process.env.RELEASE_TAG; + const dryRun = process.env.DRY_RUN === 'true'; + assert.match(version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/); + assert.equal(tag?.replace(/^v/, ''), version, 'Release tag must match package.json version'); + assert.ok(dryRun || !process.env.MANUAL_REF, 'A manual ref override requires dry_run=true'); + const source = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + if (!dryRun) { + const tagged = execFileSync('git', ['rev-parse', '--verify', `refs/tags/${tag}^{commit}`], { encoding: 'utf8' }).trim(); + assert.equal(source, tagged, 'Publishing must build the exact release tag'); + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nrelease_tag=${tag}\ndry_run=${dryRun}\nsource_sha=${source}\n`); + console.log(`Release ${tag}; source ${source}; dry-run ${dryRun}`); + JS + + - name: Install dependencies + run: npm ci + + - name: Verify source + run: | + set -euo pipefail + log_file="$RUNNER_TEMP/runta-crew-verification.log" + if (npm run typecheck && npm run lint && npm test) >"$log_file" 2>&1; then + cat "$log_file" + else + cat "$log_file" >&2 + exit 1 + fi + + - name: Build signed and notarized release + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ vars.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ vars.APPLE_NOTARY_ISSUER_ID }} + run: npm run package:release + + - name: Smoke-test packaged app and require final artifacts + run: | + set -euo pipefail + log_file="$RUNNER_TEMP/runta-crew-release-smoke.log" + if npm run smoke >"$log_file" 2>&1; then + cat "$log_file" + else + cat "$log_file" >&2 + exit 1 + fi + shopt -s nullglob + dmgs=(release/*.dmg) + zips=(release/*.zip) + (( ${#dmgs[@]} > 0 && ${#zips[@]} > 0 )) + for file in "${dmgs[@]}" "${zips[@]}"; do + [[ -s "$file" && -s "$file.blockmap" ]] + done + [[ -s release/latest-mac.yml ]] + + - name: Upload verified macOS artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runta-crew-macos-arm64-${{ steps.release.outputs.version }} + path: | + release/*.dmg + release/*.zip + release/*.blockmap + release/latest-mac.yml + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + publish_assets: + name: Upload GitHub Release assets + needs: package_macos + if: needs.package_macos.outputs.dry_run == 'false' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: runta-crew-macos-arm64-${{ needs.package_macos.outputs.version }} + path: release + + - name: Upload assets to the existing release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.package_macos.outputs.release_tag }} + SOURCE_SHA: ${{ needs.package_macos.outputs.source_sha }} + run: | + set -euo pipefail + gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" >/dev/null + [[ "$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha)" == "$SOURCE_SHA" ]] || { echo '::error::Release tag moved after the build'; exit 1; } + shopt -s nullglob + assets=(release/*.dmg release/*.zip release/*.blockmap release/latest-mac.yml) + destination="$RUNNER_TEMP/runta-crew-release-assets" + mkdir -p "$destination" + for asset in "${assets[@]}"; do + [[ -s "$asset" ]] + name="${asset##*/}" + cp "$asset" "$destination/${name// /-}" + done + gh release upload --repo "$GITHUB_REPOSITORY" --clobber "$RELEASE_TAG" "$destination"/* diff --git a/README.md b/README.md index 5609c38..c961c7d 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,12 @@ npm ci npm run dev ``` -The development app connects to: +The app defaults to: -- Cloud Agents API: `https://api.runta.com` -- Runta Dashboard: `https://dashboard.runta.com` +- Cloud Agents API: `https://api.runta.me` +- Runta Dashboard: `https://dashboard.runta.me` + +Development builds can override these addresses in Connection settings. The E2E script accepts a `RUNTA_CREW_E2E_ENDPOINT` override. Runta Crew uses the real Cloud Agents API. There is no local demo transport or silent mock fallback. @@ -67,7 +69,43 @@ npm run package npm run smoke ``` -Artifacts are written to `release/`. Local builds are ad-hoc signed; public distribution requires Runta signing, notarization, and an approved update channel. +Artifacts are written to `release/`. `npm run package` creates ad-hoc signed builds for local testing; these are not public distribution artifacts. + +Packaging converts the DMG to native ULMO (LZMA level 9), verifies its contents, and refreshes its blockmap and update metadata. ULMO requires macOS 10.15 or later; Runta Crew requires macOS 13 or later. The DMG compression step leaves ZIP artifacts unchanged. + +## Signed macOS releases + +The [release workflow](.github/workflows/publish-release.yml) runs `npm run package:release`. Public releases require a real **Developer ID Application** certificate, successful Apple notarization, and stapled app and DMG tickets. ULMO level 9 compression happens before final DMG signing; release hashes, blockmaps, and update metadata must describe the final artifacts. + +Configure these values in **runta-dev/runta-crew → Settings → Secrets and variables → Actions**, using **New repository secret** and **New repository variable**: + +| Kind | Name | Value | +| --- | --- | --- | +| Secret | `APPLE_CERTIFICATE_BASE64` | Base64 of a Developer ID Application `.p12` export containing its private key. | +| Secret | `APPLE_CERTIFICATE_PASSWORD` | Password protecting that `.p12` export. | +| Secret | `APPLE_NOTARY_KEY_BASE64` | Base64 of an App Store Connect team API key `.p8` file. | +| Variable | `APPLE_NOTARY_KEY_ID` | The API key's Key ID. | +| Variable | `APPLE_NOTARY_ISSUER_ID` | The team's Issuer ID. | + +These names follow the [Runta CLI release workflow](https://github.com/runta-dev/runta/blob/integration/.github/workflows/publish-release.yml). Store all five values at repository scope in `runta-crew`; the workflow reads them through its existing `secrets` and `vars` contexts. Its `apple-release` environment remains the release job's environment, with no duplicate values required there. + +On macOS, copy each encoded file directly to the clipboard, then paste it into the matching GitHub secret before running the next command: + +```bash +# Paste into APPLE_CERTIFICATE_BASE64. +base64 < "/path/to/DeveloperIDApplication.p12" | tr -d '\n' | pbcopy + +# Paste into APPLE_NOTARY_KEY_BASE64. +base64 < "/path/to/AuthKey.p8" | tr -d '\n' | pbcopy +``` + +Enter the certificate password directly in GitHub's secret form. Keep private keys and passwords out of commits, logs, terminal arguments, and chat. + +Once the workflow is on the repository's default branch, **Actions → Publish macOS Release** provides manual runs with `dry_run=true` by default. Enter a tag matching `package.json`'s version, with an optional `v` prefix. The optional `ref` override is available only for dry runs; otherwise the workflow builds the release tag. + +To validate a feature branch before the release workflow reaches the default branch, dispatch the existing **CI** workflow on that branch with `signed_release=true` and `release_tag` matching `package.json`. This entry reuses the release workflow in dry-run mode, including signing, notarization, and artifact verification. + +A dry run still performs signing, notarization, stapling, and verification, then saves DMG, ZIP, blockmaps, and `latest-mac.yml` in the Actions artifact `runta-crew-macos-arm64-`. It skips GitHub Release uploads. Publishing a GitHub Release triggers the workflow automatically; a manual run with `dry_run=false` uploads to an existing release. Use a dry run to validate the environment first. ## Under the hood diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..446fe17 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/electron/main/index.ts b/electron/main/index.ts index f263ce8..5301aeb 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2,21 +2,33 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, net, Notificati import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { extname, join, basename } from "node:path"; import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; import type { AppSettings, CloudRequest, CloudStreamEvent, DeviceAuthorizationStatus } from "../../src/shared/desktop"; import { DEFAULT_RUNTA_API_URL, DEFAULT_RUNTA_DASHBOARD_URL, deviceAuthorizationRequest, deviceAuthorizationUrl, deviceTokenRequest, deviceTokenUrl, normalizeRuntaApiUrl, normalizeRuntaDashboardUrl } from "../../src/shared/runtaEndpoints"; +import { cloudRunEventsPath } from "../../src/shared/cloudStreamPath"; +import { isTrustedVncRenderer, VncOriginGrants, type VncOriginContext } from "./vncOrigin"; const devServerUrl = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL; const isDev = Boolean(devServerUrl); +const expectedRendererUrl = isDev && devServerUrl ? new URL(devServerUrl).href : pathToFileURL(join(__dirname, "../renderer/index.html")).href; const credentialFile = () => join(app.getPath("userData"), "credentials.bin"); const settingsFile = () => join(app.getPath("userData"), "settings.json"); const defaultSettings: AppSettings = { endpoint: DEFAULT_RUNTA_API_URL, dashboardUrl: DEFAULT_RUNTA_DASHBOARD_URL, theme: "light", notifications: true }; let settings: AppSettings = defaultSettings; let authorizationStatus: DeviceAuthorizationStatus = "idle"; -const selectedAttachmentPaths = new Map(); +const selectedAttachments = new Map(); const cloudStreams = new Map(); const cloudStreamSenders = new Set(); let mainWindow: BrowserWindow | undefined; let pendingDeepLinkAgentId: string | undefined; +const vncOriginGrants = new VncOriginGrants(); + +function vncOriginContext(): VncOriginContext | undefined { + if (!mainWindow || mainWindow.isDestroyed() || !settings.dashboardUrl) return; + const frame = mainWindow.webContents.mainFrame; + if (frame.detached || !isTrustedVncRenderer(frame.url, frame.origin, expectedRendererUrl)) return; + return { endpoint: settings.endpoint, dashboardUrl: settings.dashboardUrl, rendererUrl: frame.url, rendererOrigin: frame.origin, webContentsId: mainWindow.webContents.id }; +} app.setName("Runta Crew"); @@ -59,7 +71,12 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); - mainWindow = window; window.on("closed", () => { if (mainWindow === window) mainWindow = undefined; }); + mainWindow = window; window.on("closed", () => { if (mainWindow === window) { mainWindow = undefined; vncOriginGrants.clear(); } }); + window.webContents.session.webRequest.onBeforeSendHeaders({ urls: ["wss://*/*"], types: ["webSocket"] }, (details, callback) => { + const context = vncOriginContext(); + const requestHeaders = context ? vncOriginGrants.headersFor(details, context) : undefined; + callback(requestHeaders ? { requestHeaders } : {}); + }); if (isDev && devServerUrl) void window.loadURL(devServerUrl); else void window.loadFile(join(__dirname, "../renderer/index.html")); window.webContents.setWindowOpenHandler(({ url }) => { @@ -73,7 +90,12 @@ function createWindow() { window.webContents.once("did-finish-load", () => { if (pendingDeepLinkAgentId) { window.webContents.send("deep-link:agent", pendingDeepLinkAgentId); pendingDeepLinkAgentId = undefined; } const smokeMarker = process.env.RUNTA_CREW_SMOKE_MARKER; - if (smokeMarker) { writeFileSync(smokeMarker, "ready\n"); app.quit(); return; } + if (smokeMarker) { + const frame = window.webContents.mainFrame; + const context = vncOriginContext(); + writeFileSync(smokeMarker, JSON.stringify({ ready: true, rendererUrl: frame.url, rendererOrigin: frame.origin, vncOrigin: context?.rendererOrigin })); + app.quit(); return; + } const screenshotPath = process.env.RUNTA_CREW_SCREENSHOT_PATH; if (screenshotPath) globalThis.setTimeout(() => { void window.webContents.capturePage().then((image) => { writeFileSync(screenshotPath, image.toPNG()); app.quit(); }); }, 1200); }); @@ -113,11 +135,13 @@ ipcMain.handle("desktop:openExternal", (_event, url: string) => { }); ipcMain.handle("settings:get", () => settings); ipcMain.handle("settings:set", (_event, next: AppSettings) => { + vncOriginGrants.clear(); settings = { ...next, endpoint: isDev ? next.endpoint : defaultSettings.endpoint, dashboardUrl: isDev ? next.dashboardUrl : defaultSettings.dashboardUrl }; writeFileSync(settingsFile(), JSON.stringify(settings, null, 2), { mode: 0o600 }); return settings; }); ipcMain.handle("credentials:has", () => existsSync(credentialFile()) && readFileSync(credentialFile()).length > 0); ipcMain.handle("credentials:set", (_event, token: string | null) => { + vncOriginGrants.clear(); if (!token) { if (existsSync(credentialFile())) rmSync(credentialFile()); authorizationStatus = "idle"; return false; } if (!safeStorage.isEncryptionAvailable()) throw new Error("OS credential encryption is unavailable"); writeFileSync(credentialFile(), safeStorage.encryptString(token), { mode: 0o600 }); @@ -125,44 +149,47 @@ ipcMain.handle("credentials:set", (_event, token: string | null) => { }); ipcMain.handle("auth:status", () => authorizationStatus); ipcMain.handle("auth:logout", async () => { + vncOriginGrants.clear(); if (!existsSync(credentialFile()) || readFileSync(credentialFile()).length === 0) { authorizationStatus = "idle"; return true; } if (!safeStorage.isEncryptionAvailable()) throw new Error("OS credential encryption is unavailable"); if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); const token = safeStorage.decryptString(readFileSync(credentialFile())); const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; - const response = await net.fetch(new URL("v1/auth/token", apiBase).toString(), { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); + const response = await net.fetch(new URL("v2/auth/token", apiBase).toString(), { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); if (!response.ok && response.status !== 401) throw new Error(`Runta key revocation failed (${response.status})`); + vncOriginGrants.clear(); if (existsSync(credentialFile())) rmSync(credentialFile()); authorizationStatus = "idle"; return true; }); ipcMain.handle("auth:start", async () => { if (!settings.endpoint || !settings.dashboardUrl) throw new Error("API and Dashboard URLs are required"); - const dashboardUrl = settings.dashboardUrl; - const response = await net.fetch(deviceAuthorizationUrl(dashboardUrl), { + const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; + const response = await net.fetch(deviceAuthorizationUrl(apiBase), { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(deviceAuthorizationRequest(`Runta Crew on ${process.platform}`)), + body: JSON.stringify(deviceAuthorizationRequest(`Runta Crew on ${process.platform}`, settings.dashboardUrl)), }); if (!response.ok) throw new Error(`Device authorization failed (${response.status})`); - const authorization = await response.json() as { deviceCode: string; userCode: string; verificationUriComplete: string; expiresAt: string; interval: number }; + const envelope = await response.json() as { data: { device_code: string; user_code: string; verification_uri_complete: string; expires_at: string; interval: number } }; authorizationStatus = "pending"; const poll = async () => { - let interval = Math.max(5, authorization.interval || 5); - const expiresAt = Date.parse(authorization.expiresAt); + let interval = Math.max(5, envelope.data.interval || 5); + const expiresAt = Date.parse(envelope.data.expires_at); while (authorizationStatus === "pending") { await new Promise((resolve) => setTimeout(resolve, interval * 1000)); if (Number.isFinite(expiresAt) && Date.now() >= expiresAt) { authorizationStatus = "expired"; return; } let tokenResponse: Response; try { - tokenResponse = await net.fetch(deviceTokenUrl(dashboardUrl), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deviceTokenRequest(authorization.deviceCode)) }); + tokenResponse = await net.fetch(deviceTokenUrl(apiBase), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deviceTokenRequest(envelope.data.device_code)) }); } catch { continue; } if (tokenResponse.ok) { - const token = await tokenResponse.json() as { accessToken: string }; + const token = await tokenResponse.json() as { access_token: string }; if (!safeStorage.isEncryptionAvailable()) { authorizationStatus = "error"; return; } - writeFileSync(credentialFile(), safeStorage.encryptString(token.accessToken), { mode: 0o600 }); + vncOriginGrants.clear(); + writeFileSync(credentialFile(), safeStorage.encryptString(token.access_token), { mode: 0o600 }); authorizationStatus = "authorized"; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } if (process.platform === "darwin") app.focus({ steal: true }); @@ -176,10 +203,10 @@ ipcMain.handle("auth:start", async () => { } }; void poll(); - await shell.openExternal(authorization.verificationUriComplete); - return { verificationUrl: authorization.verificationUriComplete, userCode: authorization.userCode, expiresAt: authorization.expiresAt }; + await shell.openExternal(envelope.data.verification_uri_complete); + return { verificationUrl: envelope.data.verification_uri_complete, userCode: envelope.data.user_code, expiresAt: envelope.data.expires_at }; }); -ipcMain.handle("cloud:request", async (_event, request: CloudRequest) => { +ipcMain.handle("cloud:request", async (event, request: CloudRequest) => { if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); if (!existsSync(credentialFile()) || !safeStorage.isEncryptionAvailable()) throw new Error("Runta API token is not configured"); const encrypted = readFileSync(credentialFile()); @@ -187,21 +214,34 @@ ipcMain.handle("cloud:request", async (_event, request: CloudRequest) => { const token = safeStorage.decryptString(encrypted); const endpoint = new URL(`${settings.endpoint.replace(/\/+$/, "")}/`); const url = new URL(request.path.replace(/^\/+/, ""), endpoint); - const apiPrefix = `${endpoint.pathname.replace(/\/+$/, "")}/v1/`; + const apiPrefix = `${endpoint.pathname.replace(/\/+$/, "")}/v2/`; if (url.origin !== endpoint.origin || !url.pathname.startsWith(apiPrefix)) throw new Error("Cloud request path is not allowed"); + const vncContext = vncOriginContext(); + const vncRevision = vncOriginGrants.revision; + const isComputerSessionRequest = request.method === "POST" && /^agents\/[^/]+\/computer-sessions$/.test(url.pathname.slice(apiPrefix.length)); const response = await net.fetch(url.toString(), { method: request.method, + ...(isComputerSessionRequest ? { redirect: "error" as const } : {}), headers: { authorization: `Bearer ${token}`, ...(request.body === undefined ? {} : { "content-type": "application/json" }) }, body: request.body === undefined ? undefined : JSON.stringify(request.body), }); const text = await response.text(); let body: unknown; if (text) { try { body = JSON.parse(text) as unknown; } catch { body = text; } } + // Electron net.fetch does not populate Response.url reliably; reject redirects + // for session requests and retain the validated original URL instead. + if (isComputerSessionRequest && vncContext?.webContentsId === event.sender.id && !event.sender.isDestroyed()) { + const senderFrame = event.senderFrame; + const mainFrame = event.sender.mainFrame; + if (senderFrame && !senderFrame.detached && senderFrame.frameTreeNodeId === mainFrame.frameTreeNodeId && senderFrame.url === vncContext.rendererUrl && senderFrame.origin === vncContext.rendererOrigin) { + body = vncOriginGrants.remember({ url: url.href, method: request.method, status: response.status, body }, vncContext, vncRevision, randomUUID()) ?? body; + } + } return { status: response.status, body }; }); ipcMain.on("cloud:stream:subscribe", (event, value: { subscriptionId?: unknown; path?: unknown }) => { const subscriptionId = typeof value?.subscriptionId === "string" && /^\d{1,10}$/.test(value.subscriptionId) ? value.subscriptionId : undefined; - const path = typeof value?.path === "string" && /^\/v1\/agents\/[A-Za-z0-9._-]{1,160}\/runs\/[A-Za-z0-9._-]{1,160}\/events(?:\?after=-?\d+)?$/.test(value.path) ? value.path : undefined; + const path = cloudRunEventsPath(value?.path); if (!subscriptionId || !path) return; const senderId = event.sender.id; const key = `${senderId}:${subscriptionId}`; if (!cloudStreamSenders.has(senderId)) { @@ -244,12 +284,33 @@ ipcMain.handle("attachments:choose", async () => { const attachments = result.filePaths.flatMap((path) => { const size = statSync(path).size; if (size > 25 * 1024 * 1024) return []; - const id = randomUUID(); selectedAttachmentPaths.set(id, path); + const id = randomUUID(); selectedAttachments.set(id, { path }); return [{ id, name: basename(path), size, mediaType: mediaTypeForPath(path) }]; }); if (attachments.length !== result.filePaths.length) await dialog.showMessageBox({ type: "warning", title: "Some files were not attached", message: "Runta Crew supports files up to 25 MB." }); return attachments; }); +ipcMain.handle("attachments:addImage", (_event, value: { name?: unknown; mediaType?: unknown; base64?: unknown }) => { + const name = typeof value?.name === "string" ? basename(value.name).slice(0, 255) : ""; + const mediaType = typeof value?.mediaType === "string" ? value.mediaType : ""; + const base64 = typeof value?.base64 === "string" ? value.base64 : ""; + if (!name || !/^image\/(?:gif|jpeg|png|webp)$/i.test(mediaType) || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) throw new Error("Clipboard image is invalid"); + const size = Buffer.byteLength(base64, "base64"); + if (size <= 0 || size > 25 * 1024 * 1024) throw new Error("Clipboard image exceeds 25 MB"); + const id = randomUUID(); selectedAttachments.set(id, { name, mediaType, base64 }); + return { id, name, mediaType, size }; +}); +ipcMain.handle("attachments:read", (_event, id: unknown) => { + if (typeof id !== "string") throw new Error("Attachment ID is invalid"); + const selected = selectedAttachments.get(id); + if (!selected) throw new Error("Attachment is no longer available"); + if (!("path" in selected)) return { name: selected.name, mediaType: selected.mediaType, base64: selected.base64 }; + const { path } = selected; + if (!existsSync(path)) throw new Error("Attachment is no longer available"); + const size = statSync(path).size; + if (size > 25 * 1024 * 1024) throw new Error("Attachment exceeds 25 MB"); + return { name: basename(path), mediaType: mediaTypeForPath(path), base64: readFileSync(path).toString("base64") }; +}); ipcMain.handle("notifications:show", (event, value: { title?: unknown; body?: unknown }) => { const title = typeof value?.title === "string" ? value.title.slice(0, 120) : ""; const body = typeof value?.body === "string" ? value.body.slice(0, 500) : ""; diff --git a/electron/main/vncOrigin.test.ts b/electron/main/vncOrigin.test.ts new file mode 100644 index 0000000..1edcda0 --- /dev/null +++ b/electron/main/vncOrigin.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { isTrustedVncRenderer, VncOriginGrants, type VncOriginContext } from "./vncOrigin"; + +const now = Date.parse("2026-09-09T08:00:00Z"); +const nonce = "94b34170-31b4-453b-8135-afcdde214035"; +const sessionUrl = `wss://vnc.runta.me/?crew_session=${nonce}`; +const context: VncOriginContext = { endpoint: "https://api.runta.me", dashboardUrl: "https://dashboard.runta.me", rendererUrl: "http://localhost:5173/", rendererOrigin: "http://localhost:5173", webContentsId: 7 }; +const body = { agent_id: "agent-1", expires_at: new Date(now + 15_000).toISOString(), channels: { vnc: { websocket_url: "wss://vnc.runta.me/", protocols: ["binary", "vnc-ticket.issued-ticket"] } } }; +const response = { url: "https://api.runta.me/v2/agents/agent-1/computer-sessions", method: "POST", status: 200, body }; +// Chromium does not expose Sec-WebSocket-Protocol to Electron's request hook. +const request = { url: sessionUrl, method: "GET", resourceType: "webSocket", webContentsId: 7, requestHeaders: { Origin: "http://localhost:5173", Upgrade: "websocket", Other: "preserved" } }; + +function authorized() { + const grants = new VncOriginGrants(); + grants.remember(response, context, grants.revision, nonce, now); + return grants; +} + +describe("VncOriginGrants", () => { + it.each([200, 201])("binds HTTP %i sessions to a one-use URL without altering issued tickets or other metadata", (status) => { + const grants = new VncOriginGrants(); + const returned = grants.remember({ ...response, status }, context, grants.revision, nonce, now); + expect(returned).toEqual({ ...body, channels: { vnc: { ...body.channels.vnc, websocket_url: sessionUrl } } }); + expect(body.channels.vnc.websocket_url).toBe("wss://vnc.runta.me/"); + expect(new URL(sessionUrl).search).not.toContain("issued-ticket"); + expect(grants.headersFor(request, context, now)).toEqual({ ...request.requestHeaders, Origin: "https://dashboard.runta.me" }); + expect(request.requestHeaders.Origin).toBe("http://localhost:5173"); + expect(grants.headersFor(request, context, now + 1)).toBeUndefined(); + }); + + it("handles case-insensitive headers and the packaged file Origin", () => { + const grants = new VncOriginGrants(); + const packaged = { ...context, rendererUrl: "file:///Applications/Runta%20Crew.app/Contents/Resources/app.asar/out/renderer/index.html", rendererOrigin: "file://" }; + grants.remember(response, packaged, grants.revision, nonce, now); + expect(grants.headersFor(request, packaged, now)).toBeUndefined(); + expect(grants.headersFor({ ...request, requestHeaders: { origin: "null" } }, packaged, now)).toBeUndefined(); + expect(grants.headersFor({ ...request, requestHeaders: { origin: "file://" } }, packaged, now)).toEqual({ Origin: "https://dashboard.runta.me" }); + }); + + it.each([ + { url: "wss://vnc.runta.me/" }, + { url: "wss://vnc.runta.me/?crew_session=unissued" }, + { url: `${sessionUrl}&extra=1` }, + { url: `wss://vnc.runta.me/other?crew_session=${nonce}` }, + { url: `wss://other.example/?crew_session=${nonce}` }, + { url: `ws://vnc.runta.me/?crew_session=${nonce}` }, + { resourceType: "xhr" }, { method: "POST" }, + { webContentsId: 8 }, { webContentsId: undefined }, + ])("leaves unrelated requests unchanged without consuming the authorized grant: %j", (change) => { + const grants = authorized(); + expect(grants.headersFor({ ...request, ...change }, context, now)).toBeUndefined(); + expect(grants.headersFor(request, context, now)).toBeDefined(); + }); + + it.each>([ + { Origin: "https://other.example" }, { Origin: "null" }, {}, + { Origin: "http://localhost:5173", origin: "http://localhost:5173" }, + ])("does not adapt missing, unrelated or ambiguous Origins: %j", (requestHeaders) => { + expect(authorized().headersFor({ ...request, requestHeaders }, context, now)).toBeUndefined(); + }); + + it.each([ + { endpoint: "https://api.other.example" }, + { dashboardUrl: "https://dashboard.other.example" }, + { rendererUrl: "http://localhost:5173/changed" }, + { rendererOrigin: "https://other.example" }, + { webContentsId: 8 }, + ])("requires the same API, Dashboard, renderer and window context: %j", (change) => { + expect(authorized().headersFor(request, { ...context, ...change }, now)).toBeUndefined(); + }); + + it("rejects expired grants and late responses after credentials or settings change", () => { + const grants = authorized(); + expect(grants.headersFor(request, context, now + 15_000)).toBeUndefined(); + const oldRevision = grants.revision; + grants.clear(); + expect(grants.remember(response, context, oldRevision, nonce, now)).toBeUndefined(); + expect(grants.headersFor(request, context, now)).toBeUndefined(); + expect(grants.remember(response, context, grants.revision, nonce, now)).toBeDefined(); + grants.clear(); + expect(grants.headersFor(request, context, now)).toBeUndefined(); + }); + + it.each([ + { status: 401 }, { method: "GET" }, + { url: "https://api.other.example/v2/agents/agent-1/computer-sessions" }, + { url: "https://api.runta.me/v2/agents" }, + { url: "https://api.runta.me/v2/agents/agent-1/computer-sessions?extra=1" }, + { body: {} }, { body: { ...body, expires_at: "invalid" } }, + { body: { ...body, expires_at: new Date(now).toISOString() } }, + { body: { ...body, channels: { vnc: { ...body.channels.vnc, websocket_url: "ws://vnc.runta.me/" } } } }, + { body: { ...body, channels: { vnc: { ...body.channels.vnc, websocket_url: sessionUrl } } } }, + { body: { ...body, channels: { vnc: { ...body.channels.vnc, protocols: ["binary", "vnc-ticket.one", "vnc-ticket.two"] } } } }, + ])("does not authorize unrelated or malformed responses: %j", (change) => { + const grants = new VncOriginGrants(); + expect(grants.remember({ ...response, ...change }, context, grants.revision, nonce, now)).toBeUndefined(); + expect(grants.headersFor(request, context, now)).toBeUndefined(); + }); + + it("requires a main-generated UUID and rejects nonce collisions", () => { + const grants = new VncOriginGrants(); + expect(grants.remember(response, context, grants.revision, "renderer-value", now)).toBeUndefined(); + expect(grants.remember(response, context, grants.revision, nonce, now)).toBeDefined(); + expect(grants.remember(response, context, grants.revision, nonce, now)).toBeUndefined(); + }); + + it("authorizes only the exact expected packaged or development document and its native origin", () => { + const file = "file:///Applications/Runta%20Crew.app/Contents/Resources/app.asar/out/renderer/index.html"; + expect(isTrustedVncRenderer(file, "file://", file)).toBe(true); + expect(isTrustedVncRenderer(file, "null", file)).toBe(false); + expect(isTrustedVncRenderer("file:///tmp/untrusted.html", "file://", file)).toBe(false); + expect(isTrustedVncRenderer(`${file}#changed`, "file://", file)).toBe(false); + expect(isTrustedVncRenderer("file://server/app.html", "file://", "file://server/app.html")).toBe(false); + expect(isTrustedVncRenderer(context.rendererUrl, context.rendererOrigin, context.rendererUrl)).toBe(true); + expect(isTrustedVncRenderer(context.rendererUrl, "file://", context.rendererUrl)).toBe(false); + for (const url of ["about:blank", "data:text/html,app", "blob:https://example.com/id"]) { + expect(isTrustedVncRenderer(url, "null", url)).toBe(false); + } + }); + + it.each([ + { rendererUrl: "file:///app/index.html", rendererOrigin: "null" }, + { rendererUrl: "about:blank", rendererOrigin: "null" }, + { rendererUrl: "data:text/html,app", rendererOrigin: "null" }, + { rendererUrl: "http://localhost:5173/", rendererOrigin: "file://" }, + ])("rejects untrusted or opaque renderer origins at grant registration: %j", (renderer) => { + const grants = new VncOriginGrants(); + expect(grants.remember(response, { ...context, ...renderer }, grants.revision, nonce, now)).toBeUndefined(); + }); + + it("keeps fresh packaged nonce grants isolated from other requests and previous grants", () => { + const grants = new VncOriginGrants(); + const packaged = { ...context, rendererUrl: "file:///Applications/Runta%20Crew.app/Contents/Resources/app.asar/out/renderer/index.html", rendererOrigin: "file://" }; + const fileRequest = { ...request, requestHeaders: { Origin: "file://" } }; + const nextNonce = "565f98c4-4a95-42cf-ad76-249d7c19a540"; + grants.remember(response, packaged, grants.revision, nonce, now); + grants.remember(response, packaged, grants.revision, nextNonce, now); + expect(grants.headersFor(fileRequest, context, now)).toBeUndefined(); + expect(grants.headersFor(fileRequest, packaged, now)).toBeDefined(); + expect(grants.headersFor(fileRequest, packaged, now)).toBeUndefined(); + expect(grants.headersFor({ ...fileRequest, url: `wss://vnc.runta.me/?crew_session=${nextNonce}` }, packaged, now)).toBeDefined(); + }); +}); diff --git a/electron/main/vncOrigin.ts b/electron/main/vncOrigin.ts new file mode 100644 index 0000000..21d020c --- /dev/null +++ b/electron/main/vncOrigin.ts @@ -0,0 +1,101 @@ +export interface VncOriginContext { + endpoint: string; + dashboardUrl: string; + rendererUrl: string; + rendererOrigin: string; + webContentsId: number; +} + +interface VncGrant { + context: VncOriginContext; + url: string; + expiresAt: number; + origin: string; +} + +interface WebSocketRequest { + url: string; + method: string; + resourceType: string; + webContentsId?: number; + requestHeaders: Record; +} + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function sameContext(left: VncOriginContext, right: VncOriginContext): boolean { + return left.endpoint === right.endpoint && left.dashboardUrl === right.dashboardUrl && left.rendererUrl === right.rendererUrl && left.rendererOrigin === right.rendererOrigin && left.webContentsId === right.webContentsId; +} + +export function isTrustedVncRenderer(rendererUrl: string, rendererOrigin: string, expectedRendererUrl: string): boolean { + if (rendererUrl !== expectedRendererUrl) return false; + try { + const url = new URL(rendererUrl); + // Chromium serializes a local file origin as file://, while Node's URL + // reports null. Accept only the native origin of the known app document. + if (url.protocol === "file:") return !url.host && rendererOrigin === "file://"; + return ["http:", "https:"].includes(url.protocol) && rendererOrigin === url.origin; + } catch { return false; } +} + +function header(headers: Record, name: string): string | undefined { + const entries = Object.entries(headers).filter(([key]) => key.toLowerCase() === name); + return entries.length === 1 ? entries[0][1] : undefined; +} + +// Only an authenticated computer-session response can authorize this desktop Origin. +export class VncOriginGrants { + private grants: VncGrant[] = []; + private generation = 0; + + get revision(): number { return this.generation; } + + clear(): void { this.grants = []; this.generation += 1; } + + remember(response: { url: string; method: string; status: number; body: unknown }, context: VncOriginContext, revision: number, nonce: string, now = Date.now()): Record | undefined { + this.grants = this.grants.filter((grant) => grant.expiresAt > now); + if (revision !== this.generation || response.method !== "POST" || ![200, 201].includes(response.status) || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(nonce)) return; + if (!isTrustedVncRenderer(context.rendererUrl, context.rendererOrigin, context.rendererUrl)) return; + try { + const endpoint = new URL(context.endpoint); + const dashboard = new URL(context.dashboardUrl); + const source = new URL(response.url); + const prefix = `${endpoint.pathname.replace(/\/+$/, "")}/v2/agents/`; + if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || dashboard.protocol !== "https:" || dashboard.username || dashboard.password) return; + if (source.origin !== endpoint.origin || source.search || source.hash || !source.pathname.startsWith(prefix) || !/^[^/]+\/computer-sessions$/.test(source.pathname.slice(prefix.length))) return; + const body = object(response.body); + const channels = object(body?.channels); + const vnc = object(channels?.vnc); + const expiresAt = typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN; + if (!body || !channels || !vnc || typeof vnc.websocket_url !== "string" || !Number.isFinite(expiresAt) || expiresAt <= now) return; + const url = new URL(vnc.websocket_url); + if (url.protocol !== "wss:" || url.username || url.password || url.hash || url.search || url.pathname !== "/") return; + const protocols = vnc.protocols; + if (!Array.isArray(protocols) || protocols.length !== 2 || !protocols.includes("binary")) return; + const ticket = protocols.find((value: unknown): value is string => typeof value === "string" && /^vnc-ticket\.[A-Za-z0-9._~-]+$/.test(value)); + if (!ticket) return; + // Electron hides Sec-WebSocket-Protocol from this hook. A main-generated, + // one-use URL nonce binds the request; the gateway still validates its ticket. + url.searchParams.set("crew_session", nonce); + if (this.grants.some((grant) => grant.url === url.href)) return; + this.grants.push({ context: { ...context }, url: url.href, expiresAt: Math.min(expiresAt, now + 5 * 60_000), origin: dashboard.origin }); + this.grants = this.grants.slice(-32); + return { ...body, channels: { ...channels, vnc: { ...vnc, websocket_url: url.href } } }; + } catch { /* Invalid session metadata cannot authorize an Origin change. */ } + } + + headersFor(request: WebSocketRequest, context: VncOriginContext, now = Date.now()): Record | undefined { + this.grants = this.grants.filter((grant) => grant.expiresAt > now); + if (request.resourceType !== "webSocket" || request.method !== "GET" || request.webContentsId !== context.webContentsId) return; + if (header(request.requestHeaders, "origin") !== context.rendererOrigin) return; + const index = this.grants.findIndex((candidate) => candidate.url === request.url && sameContext(candidate.context, context)); + if (index === -1) return; + const [grant] = this.grants.splice(index, 1); + const headers = { ...request.requestHeaders }; + for (const key of Object.keys(headers)) if (key.toLowerCase() === "origin") delete headers[key]; + headers.Origin = grant.origin; + return headers; + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c1cf5d8..1c86b2a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -29,7 +29,7 @@ const bridge: DesktopBridge = { return () => { ipcRenderer.removeListener("cloud:stream:event", handler); ipcRenderer.send("cloud:stream:unsubscribe", subscriptionId); }; }, }, - attachments: { choose: () => ipcRenderer.invoke("attachments:choose") }, + attachments: { choose: () => ipcRenderer.invoke("attachments:choose"), addImage: (image) => ipcRenderer.invoke("attachments:addImage", image), read: (id) => ipcRenderer.invoke("attachments:read", id) }, notifications: { show: (notification) => ipcRenderer.invoke("notifications:show", notification), setBadge: (count) => ipcRenderer.invoke("notifications:setBadge", count), diff --git a/package-lock.json b/package-lock.json index 86c612f..c97bfc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "runta-crew", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "runta-crew", - "version": "0.1.0", + "version": "0.1.2", "hasInstallScript": true, "dependencies": { + "@novnc/novnc": "^1.5.0", "@vitejs/plugin-react": "^5.0.4", "boring-avatars": "^2.0.4", "clsx": "^2.1.1", @@ -27,13 +28,15 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitest/coverage-v8": "^3.2.4", + "app-builder-lib": "26.15.3", "electron": "^44.0.0", - "electron-builder": "^26.0.12", + "electron-builder": "26.15.3", "electron-vite": "^4.0.0", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "js-yaml": "4.3.1", "jsdom": "^26.1.0", "typescript": "^5.8.3", "typescript-eslint": "^8.48.1", @@ -2007,6 +2010,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@novnc/novnc": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.5.0.tgz", + "integrity": "sha512-4yGHOtUCnEJUCsgEt/L78eeJu00kthurLBWXFiaXfonNx0pzbs6R/3gJb1byZe6iAE8V9MF0syQb0xIL8MSOtQ==", + "license": "MPL-2.0" + }, "node_modules/@peculiar/asn1-schema": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", diff --git a/package.json b/package.json index c3ee056..ddd29d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { "name": "runta-crew", - "version": "0.1.0", + "version": "0.1.2", "private": true, + "repository": { + "type": "git", + "url": "https://github.com/runta-dev/runta-crew.git" + }, "type": "module", "main": "out/main/index.js", "description": "Desktop client for Runta Cloud Agents", @@ -13,7 +17,8 @@ "dev": "node scripts/rebrand-electron.mjs && electron-vite dev", "postinstall": "node scripts/ensure-electron.mjs", "build": "electron-vite build", - "package": "npm run build && electron-builder --mac --publish never", + "package": "npm run build && electron-builder --mac --publish never -c.mac.identity=- -c.mac.hardenedRuntime=false && node scripts/compress-dmg.mjs", + "package:release": "node scripts/package-release.mjs", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json", "lint": "eslint .", "test": "vitest run", @@ -22,6 +27,7 @@ "smoke": "node scripts/smoke.mjs" }, "dependencies": { + "@novnc/novnc": "^1.5.0", "@vitejs/plugin-react": "^5.0.4", "boring-avatars": "^2.0.4", "clsx": "^2.1.1", @@ -40,14 +46,16 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitest/coverage-v8": "^3.2.4", + "app-builder-lib": "26.15.3", "electron": "^44.0.0", - "electron-builder": "^26.0.12", + "electron-builder": "26.15.3", "electron-vite": "^4.0.0", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^26.1.0", + "js-yaml": "4.3.1", "typescript": "^5.8.3", "typescript-eslint": "^8.48.1", "vite": "^7.0.0", @@ -81,7 +89,9 @@ ] } ], - "identity": null + "hardenedRuntime": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.plist" } } } diff --git a/scripts/compress-dmg.mjs b/scripts/compress-dmg.mjs new file mode 100644 index 0000000..9f98535 --- /dev/null +++ b/scripts/compress-dmg.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { gunzipSync } from "node:zlib"; +import { buildBlockMap } from "app-builder-lib/out/targets/blockmap/blockmap.js"; +import { dump, load } from "js-yaml"; + +const execute = promisify(execFile); +const artifactName = (url) => path.basename(decodeURIComponent(new URL(url, "https://artifacts.invalid/").pathname)); +const sha512 = async (file) => { + const hash = createHash("sha512"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("base64"); +}; +async function imageChecksum(file) { + const { stdout } = await execute("hdiutil", ["checksum", "-type", "SHA256", file]); + const checksum = stdout.match(/SHA256\s+\$([a-f\d]{64})/i)?.[1]; + assert.ok(checksum, "hdiutil did not return a decoded-image SHA256"); + return checksum.toLowerCase(); +} + +async function replacePrepared(files, staging) { + const backups = []; + try { + for (const [index, { source, destination }] of files.entries()) { + const backup = path.join(staging, `backup-${index}`); + let existed = true; + try { await rename(destination, backup); } catch (error) { if (error.code !== "ENOENT") throw error; existed = false; } + backups.push({ destination, backup: existed ? backup : undefined }); + await rename(source, destination); + } + } catch (error) { + try { + for (const entry of backups.reverse()) { + await rm(entry.destination, { force: true }); + if (entry.backup) await rename(entry.backup, entry.destination); + } + } catch (rollbackError) { + throw Object.assign(new AggregateError([error, rollbackError], `Could not restore artifacts; backups retained at ${staging}`), { retainStaging: true }); + } + throw error; + } +} + +export async function compressDmgRelease(outputDirectory, version, { finalizeImage } = {}) { + const directory = path.resolve(outputDirectory); + const manifestFile = path.join(directory, "latest-mac.yml"); + const originalManifest = await readFile(manifestFile, "utf8"); + const manifest = load(originalManifest); + assert.equal(manifest?.version, version, "latest-mac.yml must describe the current package version"); + assert.ok(Array.isArray(manifest.files), "latest-mac.yml must list release files"); + const entries = manifest.files.filter((entry) => typeof entry.url === "string" && artifactName(entry.url).endsWith(".dmg")); + assert.ok(entries.length, "latest-mac.yml contains no current DMG artifacts"); + const diskImages = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".dmg")).map((entry) => entry.name); + const selected = []; + for (const entry of entries) { + const name = artifactName(entry.url); + const matches = diskImages.filter((file) => file === name || file.replaceAll(" ", "-") === name); + assert.equal(matches.length, 1, `Expected one local DMG for ${name}`); + const file = path.join(directory, matches[0]); + assert.ok(!selected.some((item) => item.file === file), "DMG appears more than once in release metadata"); + const originalStat = await stat(file); + assert.equal(originalStat.size, entry.size, `Size mismatch for ${name}`); + assert.equal(await sha512(file), entry.sha512, `SHA512 mismatch for ${name}`); + selected.push({ entry, file, name, originalStat }); + } + + const staging = await mkdtemp(path.join(directory, ".dmg-compression-")); + let retainStaging = false; + try { + const prepared = []; + for (const [index, item] of selected.entries()) { + const converted = path.join(staging, `${index}.dmg`); + const blockmap = `${converted}.blockmap`; + const originalImageChecksum = await imageChecksum(item.file); + await execute("hdiutil", ["convert", item.file, "-format", "ULMO", "-imagekey", "lzma-level=9", "-o", converted]); + await execute("hdiutil", ["verify", converted]); + assert.equal((await execute("hdiutil", ["imageinfo", "-format", converted])).stdout.trim(), "ULMO"); + assert.equal(await imageChecksum(converted), originalImageChecksum, "Compression changed the disk image contents"); + // Signing and stapling mutate the container; complete them before hashing + // the downloadable artifact or generating its differential-update map. + if (finalizeImage) await finalizeImage(converted); + // The explicit third argument creates a sidecar, without appending data to the DMG. + const info = await buildBlockMap(converted, "gzip", blockmap); + assert.equal(info.size, (await stat(converted)).size); + assert.equal(info.sha512, await sha512(converted)); + const map = JSON.parse(gunzipSync(await readFile(blockmap))); + assert.equal(map.version, "2"); + assert.equal(map.files.flatMap((file) => file.sizes).reduce((sum, size) => sum + size, 0), info.size); + item.entry.sha512 = info.sha512; + item.entry.size = info.size; + if (typeof manifest.path === "string" && artifactName(manifest.path) === item.name) { + manifest.sha512 = info.sha512; + if ("size" in manifest) manifest.size = info.size; + } + prepared.push({ source: converted, destination: item.file }, { source: blockmap, destination: `${item.file}.blockmap` }); + } + const nextManifest = dump(manifest, { lineWidth: -1, noRefs: true }); + assert.deepEqual(load(nextManifest), manifest, "Release metadata did not round-trip"); + const stagedManifest = path.join(staging, "latest-mac.yml"); + await writeFile(stagedManifest, nextManifest); + // Refuse to replace a different build that appeared while compression ran. + assert.equal(await readFile(manifestFile, "utf8"), originalManifest, "Release metadata changed during compression"); + for (const item of selected) { + const current = await stat(item.file); + assert.ok(current.ino === item.originalStat.ino && current.size === item.originalStat.size && current.mtimeMs === item.originalStat.mtimeMs, "DMG changed during compression"); + } + prepared.push({ source: stagedManifest, destination: manifestFile }); + await replacePrepared(prepared, staging); + for (const item of selected) console.log(`Compressed ${path.basename(item.file)} with ULMO level 9: ${item.originalStat.size} → ${item.entry.size} bytes`); + } catch (error) { + retainStaging = error.retainStaging === true; + throw error; + } finally { + if (!retainStaging) await rm(staging, { recursive: true, force: true }); + } +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const metadata = JSON.parse(await readFile(path.join(project, "package.json"), "utf8")); + await compressDmgRelease(process.argv[2] ?? path.resolve(project, metadata.build?.directories?.output ?? "release"), metadata.version); +} diff --git a/scripts/compress-dmg.test.mjs b/scripts/compress-dmg.test.mjs new file mode 100644 index 0000000..f16d25b --- /dev/null +++ b/scripts/compress-dmg.test.mjs @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { dump } from "js-yaml"; +import { compressDmgRelease } from "./compress-dmg.mjs"; + +const directories = []; +afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); + +async function fixture({ version = "0.1.1", staleHash = false } = {}) { + const directory = await mkdtemp(path.join(os.tmpdir(), "crew-dmg-test-")); + directories.push(directory); + const image = "not a disk image"; + const manifest = dump({ version, files: [{ url: "Runta-Crew-0.1.1-arm64.dmg", size: image.length, sha512: staleHash ? "stale" : createHash("sha512").update(image).digest("base64") }], path: "existing.zip", sha512: "zip-hash", releaseNotes: "preserve notes" }); + const files = { "Runta Crew-0.1.1-arm64.dmg": image, "Runta Crew-0.1.1-arm64.dmg.blockmap": "original blockmap", "latest-mac.yml": manifest, "existing.zip": "original zip", "Old-0.0.1.dmg": "older release" }; + await Promise.all(Object.entries(files).map(([name, content]) => writeFile(path.join(directory, name), content))); + return { directory, files }; +} + +async function expectUnchanged({ directory, files }) { + expect((await readdir(directory)).sort()).toEqual(Object.keys(files).sort()); + for (const [name, content] of Object.entries(files)) expect(await readFile(path.join(directory, name), "utf8")).toBe(content); +} + +it("refuses metadata from an older package before touching release artifacts", async () => { + const value = await fixture({ version: "0.1.0" }); + await expect(compressDmgRelease(value.directory, "0.1.1")).rejects.toThrow("current package version"); + await expectUnchanged(value); +}); + +it("refuses a DMG that differs from the completed build metadata", async () => { + const value = await fixture({ staleHash: true }); + await expect(compressDmgRelease(value.directory, "0.1.1")).rejects.toThrow("SHA512 mismatch"); + await expectUnchanged(value); +}); + +it("preserves the DMG, blockmap, ZIP, metadata, and older releases when image conversion fails", async () => { + const value = await fixture(); + await expect(compressDmgRelease(value.directory, "0.1.1")).rejects.toThrow(); + await expectUnchanged(value); +}); diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 12a67c7..aeb7e78 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -const endpoint = (process.env.RUNTA_CREW_E2E_ENDPOINT || "https://api.runta.com").replace(/\/+$/, ""); +const endpoint = (process.env.RUNTA_CREW_E2E_ENDPOINT || "https://api.runta.me").replace(/\/+$/, ""); const token = process.env.RUNTA_CREW_E2E_TOKEN; if (!token) throw new Error("RUNTA_CREW_E2E_TOKEN is required"); @@ -15,23 +15,23 @@ async function request(path, init = {}) { return body; } -const profile = await request("/v1/me"); -assert.equal(typeof profile.data?.user_id, "string", "user-authorized device token must expose /v1/me"); +const profile = await request("/v2/me"); +assert.equal(typeof profile.data?.user_id, "string", "user-authorized device token must expose /v2/me"); -const providers = await request("/v1/model-providers"); +const providers = await request("/v2/model-providers"); const provider = providers.model_providers?.[0]; assert.equal(typeof provider?.id, "string", "an E2E model provider is required"); const suffix = Date.now().toString(36); let agent; try { - agent = await request("/v1/agents", { + agent = await request("/v2/agents", { method: "POST", body: JSON.stringify({ name: `runta-crew-e2e-${suffix}`, model_provider: { type: "managed", id: provider.id } }), }); assert.equal(typeof agent.id, "string"); - const run = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs`, { + const run = await request(`/v2/agents/${encodeURIComponent(agent.id)}/runs`, { method: "POST", body: JSON.stringify({ prompt: "Reply with RUNTA_CREW_E2E_OK." }), }); @@ -40,7 +40,7 @@ try { const deadline = Date.now() + 180_000; let completed; while (Date.now() < deadline) { - completed = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(run.id)}`); + completed = await request(`/v2/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(run.id)}`); if (["finished", "failed", "cancelled"].includes(completed.status)) break; await new Promise((resolve) => setTimeout(resolve, 2_000)); } @@ -48,5 +48,5 @@ try { assert.match(completed.result || "", /RUNTA_CREW_E2E_OK/); console.log(`Runta Crew E2E passed for agent ${agent.id}`); } finally { - if (agent?.id) await request(`/v1/agents/${encodeURIComponent(agent.id)}?delete_runtime=true`, { method: "DELETE" }); + if (agent?.id) await request(`/v2/agents/${encodeURIComponent(agent.id)}?delete_runtime=true`, { method: "DELETE" }); } diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..829f8cc --- /dev/null +++ b/scripts/package-release.mjs @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { setTimeout as delay } from "node:timers/promises"; +import { build, Platform, Arch } from "electron-builder"; +import { compressDmgRelease } from "./compress-dmg.mjs"; + +const execute = promisify(execFile); +export const requiredReleaseEnvironment = ["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_NOTARY_KEY_BASE64", "APPLE_NOTARY_KEY_ID", "APPLE_NOTARY_ISSUER_ID"]; + +export function releaseCredentials(env) { + const missing = requiredReleaseEnvironment.filter((name) => !env[name]?.trim()); + if (missing.length) throw new Error(`Missing Apple release configuration: ${missing.join(", ")}`); + return Object.fromEntries(requiredReleaseEnvironment.map((name) => [name, env[name]])); +} + +export function developerIdIdentity(output) { + const matches = [...output.matchAll(/\b([A-Fa-f0-9]{40})\s+"Developer ID Application: [^"\n]+"/g)]; + assert.equal(matches.length, 1, "The certificate must contain exactly one valid Developer ID Application identity with its private key"); + return matches[0][1]; +} + +export function verifySignatureDetails(details, { application = false } = {}) { + assert.match(details, /^Authority=Developer ID Application: .+/m, "Expected a Developer ID Application signature"); + assert.match(details, /^TeamIdentifier=[A-Z0-9]{10}$/m, "Expected an Apple developer team"); + assert.match(details, /^Timestamp=.+/m, "Expected a secure signing timestamp"); + if (application) { + assert.match(details, /^Identifier=com\.runta\.crew$/m); + assert.match(details, /^CodeDirectory .*\bruntime\b/m, "Expected hardened runtime"); + } +} + +export function verifyGatekeeperAssessment(output) { + assert.match(output, /^source=Notarized Developer ID$/m, "Gatekeeper must accept the notarized signature, not a local security override"); +} + +export function signingCommandFailure(command, operation, code, stderr, sensitive, redactions = []) { + const label = command === "security" ? `${command} ${operation}` : command; + let detail = stderr || ""; + if (sensitive) { + // Report known native diagnostics without ever copying arbitrary output from + // a credential-handling command (or execFile's argv-bearing error message). + const diagnostics = [ + ["MAC verification failed", "PKCS#12 password verification failed; check APPLE_CERTIFICATE_PASSWORD."], + ["Unknown format", "The certificate is not a supported PKCS#12 export; check APPLE_CERTIFICATE_BASE64."], + ["User interaction is not allowed", "The signing keychain requires interaction."], + ["specified keychain already exists", "The temporary signing keychain already exists."], + ["specified keychain could not be found", "The temporary signing keychain could not be found."], + ["specified item could not be found", "The signing keychain item could not be found."], + ["One or more parameters", "macOS rejected a signing keychain parameter."] + ]; + const known = diagnostics.find(([native]) => detail.includes(native))?.[1]; + // security's stderr contains OSStatus diagnostics, not the imported data. + // Keep only its diagnostic lines and scrub credentials before reporting an + // unfamiliar error; never use execFile's error.message (which includes argv). + const native = detail.split("\n").filter((line) => /^security: /.test(line)).join("\n"); + const secrets = redactions.flatMap((value) => [value, ...value.split(/\r?\n/)]).filter(Boolean).sort((left, right) => right.length - left.length); + const sanitized = secrets.reduce((value, secret) => value.replaceAll(secret, "[redacted]"), native); + detail = known ?? (sanitized || "Native diagnostic omitted because this command handles credentials."); + } + return `${label} failed (exit ${code ?? "unknown"})${detail ? `\n${detail}` : ""}`; +} + +export async function packageRelease() { + const credentials = releaseCredentials(process.env); + assert.equal(process.platform, "darwin", "Signed macOS releases must be built on macOS"); + const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const metadata = JSON.parse(await readFile(path.join(project, "package.json"), "utf8")); + const output = path.join(project, "release"); + const staging = await mkdtemp(path.join(tmpdir(), "runta-crew-signing-")); + const keychain = path.join(staging, "signing.keychain-db"); + const certificate = path.join(staging, "developer-id.p12"); + const notaryKey = path.join(staging, "AuthKey.p8"); + const keychainPassword = randomBytes(32).toString("base64"); + let keychainCreated = false; + let originalKeychains; + // Do not let inherited signing/debug variables override the release policy or + // propagate certificate material into build subprocesses or diagnostic logs. + const originalEnvironment = { ...process.env }; + for (const name of Object.keys(process.env)) { + if (name.startsWith("APPLE_") || name.startsWith("CSC_") || name === "DEBUG") delete process.env[name]; + } + const run = async (command, args, { sensitive = false } = {}) => { + try { return await execute(command, args, { cwd: project, maxBuffer: 16 * 1024 * 1024 }); } + catch (error) { + // execFile's error message includes argv, including keychain passwords. + throw new Error(signingCommandFailure(command, args[0], error.code, sensitive ? error.stderr : error.stderr || error.stdout, sensitive, [...Object.values(credentials), keychainPassword])); + } + }; + const verifySignature = async (file, application = false) => { + await run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", file]); + const result = await run("codesign", ["--display", "--verbose=4", file]); + verifySignatureDetails(result.stderr, { application }); + }; + const notarize = async (file) => { + console.log(`Submitting ${path.basename(file)} for Apple notarization…`); + const result = await run("xcrun", ["notarytool", "submit", file, "--key", notaryKey, "--key-id", credentials.APPLE_NOTARY_KEY_ID, "--issuer", credentials.APPLE_NOTARY_ISSUER_ID, "--wait", "--timeout", "30m", "--output-format", "json"]); + const submission = JSON.parse(result.stdout); + assert.equal(submission.status, "Accepted", `Apple notarization failed: ${submission.status} (submission ${submission.id})`); + console.log(`Apple notarization accepted: ${submission.id}`); + }; + const staple = async (file) => { + // Apple's ticket can take a short time to become available after Accepted. + for (let attempt = 0; ; attempt++) { + try { await run("xcrun", ["stapler", "staple", file]); break; } + catch (error) { if (attempt === 3) throw error; await delay(5_000); } + } + await run("xcrun", ["stapler", "validate", file]); + }; + const verifyApp = async (file) => { + await verifySignature(file, true); + await run("xcrun", ["stapler", "validate", file]); + const assessment = await run("spctl", ["--assess", "--type", "execute", "--verbose=2", file]); + verifyGatekeeperAssessment(assessment.stdout + assessment.stderr); + }; + try { + await writeFile(certificate, Buffer.from(credentials.APPLE_CERTIFICATE_BASE64, "base64"), { mode: 0o600 }); + await writeFile(notaryKey, Buffer.from(credentials.APPLE_NOTARY_KEY_BASE64, "base64"), { mode: 0o600 }); + await run("security", ["create-keychain", "-p", keychainPassword, keychain], { sensitive: true }); + keychainCreated = true; + await run("security", ["set-keychain-settings", "-lut", "7200", keychain]); + await run("security", ["unlock-keychain", "-p", keychainPassword, keychain], { sensitive: true }); + await run("security", ["import", certificate, "-P", credentials.APPLE_CERTIFICATE_PASSWORD, "-T", "/usr/bin/codesign", "-t", "cert", "-f", "pkcs12", "-k", keychain], { sensitive: true }); + await run("security", ["set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-k", keychainPassword, keychain], { sensitive: true }); + originalKeychains = (await run("security", ["list-keychains", "-d", "user"])).stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line.trim())); + await run("security", ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains]); + const identity = developerIdIdentity((await run("security", ["find-identity", "-v", "-p", "codesigning", keychain])).stdout); + process.env.CSC_KEYCHAIN = keychain; + console.log((await run("npm", ["run", "build"])).stdout); + let appVerified = false; + await build({ + projectDir: project, + targets: Platform.MAC.createTarget(["dmg", "zip"], Arch.arm64), + publish: "never", + config: { + forceCodeSigning: true, + mac: { identity, type: "distribution", hardenedRuntime: true, strictVerify: true, entitlements: "build/entitlements.mac.plist", entitlementsInherit: "build/entitlements.mac.plist", notarize: false }, + dmg: { sign: false }, + afterSign: async ({ appOutDir }) => { + const app = path.join(appOutDir, "Runta Crew.app"); + await verifySignature(app, true); + const archive = path.join(staging, "Runta Crew.zip"); + await run("ditto", ["-c", "-k", "--keepParent", app, archive]); + await notarize(archive); + await staple(app); + await verifyApp(app); + appVerified = true; + } + } + }); + assert.ok(appVerified, "The app must be signed, notarized, and stapled before packaging"); + await compressDmgRelease(output, metadata.version, { + finalizeImage: async (file) => { + await run("codesign", ["--force", "--timestamp", "--keychain", keychain, "--sign", identity, file]); + await verifySignature(file); + await notarize(file); + await staple(file); + await verifySignature(file); + await run("hdiutil", ["verify", file]); + const assessment = await run("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", file]); + verifyGatekeeperAssessment(assessment.stdout + assessment.stderr); + const mount = path.join(staging, "dmg-mount"); + await run("hdiutil", ["attach", "-readonly", "-nobrowse", "-mountpoint", mount, file]); + try { await verifyApp(path.join(mount, "Runta Crew.app")); } + finally { + try { await run("hdiutil", ["detach", mount]); } + catch { await delay(1_000); await run("hdiutil", ["detach", mount]); } + } + } + }); + // Check the ZIP's embedded app too: a valid expanded build alone does not + // prove that either downloadable container carries the stapled signature. + const zip = path.join(output, `Runta Crew-${metadata.version}-arm64-mac.zip`); + const extracted = path.join(staging, "zip-check"); + await run("ditto", ["-x", "-k", zip, extracted]); + await verifyApp(path.join(extracted, "Runta Crew.app")); + console.log("Release verified: Developer ID signatures, Apple tickets, DMG and ZIP contents."); + } finally { + try { if (originalKeychains) await run("security", ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } + finally { + try { if (keychainCreated) await run("security", ["delete-keychain", keychain], { sensitive: true }); } + finally { + try { await rm(staging, { recursive: true, force: true }); } + finally { + for (const name of Object.keys(process.env)) if (!(name in originalEnvironment)) delete process.env[name]; + Object.assign(process.env, originalEnvironment); + } + } + } + } +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + try { await packageRelease(); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/package-release.test.mjs b/scripts/package-release.test.mjs new file mode 100644 index 0000000..063f586 --- /dev/null +++ b/scripts/package-release.test.mjs @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { developerIdIdentity, releaseCredentials, signingCommandFailure, verifyGatekeeperAssessment, verifySignatureDetails } from "./package-release.mjs"; + +describe("Apple release admission", () => { + it("identifies credential command failures without echoing native credential output", () => { + const failure = signingCommandFailure("security", "import", 1, "SecKeychainItemImport: MAC verification failed during PKCS12 import secret-material", true); + expect(failure).toContain("security import failed"); + expect(failure).toContain("APPLE_CERTIFICATE_PASSWORD"); + expect(failure).not.toContain("secret-material"); + expect(signingCommandFailure("security", "unlock-keychain", 1, "unknown secret-material", true)).not.toContain("secret-material"); + const unknown = signingCommandFailure("security", "import", 1, "security: SecKeychainItemImport: Unable to decode the provided data. secret-material\nunrelated output", true, ["secret-material"]); + expect(unknown).toContain("Unable to decode the provided data."); + expect(unknown).not.toContain("secret-material"); + expect(unknown).not.toContain("unrelated output"); + }); + + it("reports missing configuration names without echoing credential material", () => { + expect(() => releaseCredentials({ APPLE_CERTIFICATE_BASE64: "private-certificate" })).toThrow("APPLE_CERTIFICATE_PASSWORD"); + expect(() => releaseCredentials({ APPLE_CERTIFICATE_BASE64: "private-certificate" })).not.toThrow("private-certificate"); + }); + + it("requires a unique distribution identity with a private key", () => { + const hash = "A".repeat(40); + const valid = ` 1) ${hash} "Developer ID Application: Runta (ABCDE12345)"\n 1 valid identities found`; + expect(developerIdIdentity(valid)).toBe(hash); + expect(() => developerIdIdentity(valid.replace("Developer ID Application", "Apple Development"))).toThrow(); + expect(() => developerIdIdentity("0 valid identities found")).toThrow(); + expect(() => developerIdIdentity(`${valid}\n${valid}`)).toThrow(); + }); +}); + +describe("distributed app signature verification", () => { + const signed = "Identifier=com.runta.crew\nCodeDirectory v=20500 size=100 flags=0x10000(runtime) hashes=1+7 location=embedded\nAuthority=Developer ID Application: Runta (ABCDE12345)\nTeamIdentifier=ABCDE12345\nTimestamp=Sep 10, 2026 at 12:00:00\n"; + + it("rejects the previously shipped linker signature", () => { + expect(() => verifySignatureDetails("Identifier=Electron\nSignature=adhoc\nTeamIdentifier=not set\nInfo.plist=not bound", { application: true })).toThrow(); + }); + + it("requires the app identity, hardened runtime, and a secure timestamp", () => { + expect(() => verifySignatureDetails(signed, { application: true })).not.toThrow(); + for (const bad of [signed.replace("(runtime)", "(adhoc)"), signed.replace("com.runta.crew", "Electron"), signed.replace(/^Timestamp=.*\n/m, ""), signed.replace("TeamIdentifier=ABCDE12345", "TeamIdentifier=not set")]) { + expect(() => verifySignatureDetails(bad, { application: true })).toThrow(); + } + }); + + it("does not mistake disabled local Gatekeeper for a successful release assessment", () => { + expect(() => verifyGatekeeperAssessment("Runta Crew.app: accepted\nsource=Notarized Developer ID\n")).not.toThrow(); + expect(() => verifyGatekeeperAssessment("Runta Crew.app: accepted\noverride=security disabled\n")).toThrow(); + expect(() => verifyGatekeeperAssessment("Runta Crew.app: accepted\nsource=Developer ID\n")).toThrow(); + }); +}); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index d2c1d9e..cb0a9a0 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1,14 +1,21 @@ +import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; const appBinary = join(process.cwd(), "release/mac-arm64/Runta Crew.app/Contents/MacOS/Runta Crew"); if (!existsSync(appBinary)) throw new Error(`Packaged app is missing: ${appBinary}`); +execFileSync("codesign", ["--verify", "--deep", "--strict", join(process.cwd(), "release/mac-arm64/Runta Crew.app")], { stdio: "pipe" }); const smokeRoot = mkdtempSync(join(tmpdir(), "runta-crew-smoke-")); const marker = join(smokeRoot, "ready"); const child = spawn(appBinary, [`--user-data-dir=${join(smokeRoot, "profile")}`], { env: { ...process.env, RUNTA_CREW_SMOKE_MARKER: marker }, stdio: "pipe" }); const deadline = Date.now() + 20_000; while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200)); -if (!existsSync(marker) || readFileSync(marker, "utf8").trim() !== "ready") { child.kill(); throw new Error("Packaged app did not finish loading within 20 seconds"); } -console.log("Native smoke passed: packaged renderer finished loading."); +if (!existsSync(marker)) { child.kill(); throw new Error("Packaged app did not finish loading within 20 seconds"); } +const result = JSON.parse(readFileSync(marker, "utf8")); +assert.equal(result.ready, true); +assert.equal(new URL(result.rendererUrl).protocol, "file:"); +assert.equal(result.rendererOrigin, "file://"); +assert.equal(result.vncOrigin, result.rendererOrigin, "VNC must use the packaged renderer's native Origin"); +console.log("Native smoke passed: packaged renderer loaded with the correct VNC Origin."); diff --git a/src/clients/http/RuntaCloudAgentsClient.test.ts b/src/clients/http/RuntaCloudAgentsClient.test.ts index 508a0ee..ef76419 100644 --- a/src/clients/http/RuntaCloudAgentsClient.test.ts +++ b/src/clients/http/RuntaCloudAgentsClient.test.ts @@ -7,6 +7,14 @@ import type { ConversationEvent } from "@/domain/types"; afterEach(() => { delete window.runtaCrew; }); describe("RuntaCloudAgentsClient", () => { + it("creates an authenticated VNC session with the server-provided websocket protocols", async () => { + const request = vi.fn(async () => ({ status: 201, body: { channels: { vnc: { websocket_url: "wss://vnc.example.test/", protocols: ["binary", "vnc-ticket.ticket"] } } } })); + window.runtaCrew = { cloud: { request, subscribe: () => () => undefined } } as unknown as DesktopBridge; + + await expect(new RuntaCloudAgentsClient().openComputer("agent/one")).resolves.toEqual({ url: "wss://vnc.example.test/", protocols: ["binary", "vnc-ticket.ticket"], mode: "remote" }); + expect(request).toHaveBeenCalledWith({ method: "POST", path: "/v2/agents/agent%2Fone/computer-sessions" }); + }); + it("maps a missing local token to a normal authentication error", async () => { window.runtaCrew = { cloud: { request: async () => { throw new Error("Error invoking remote method 'cloud:request': Error: Runta API token is not configured"); }, subscribe: () => () => undefined }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; await expect(new RuntaCloudAgentsClient().listAgents()).rejects.toMatchObject({ code: "unauthorized", message: "Authentication is required" }); @@ -14,31 +22,33 @@ describe("RuntaCloudAgentsClient", () => { it("maps the current Cloud Agents envelope and uses the managed provider for creation", async () => { const request = vi.fn(async ({ method, path }: CloudRequest) => { - if (path.startsWith("/v1/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; - if (path === "/v1/model-providers") return { status: 200, body: { model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; - if (method === "GET" && path === "/v1/agents/agent-1/runs?limit=100") return { status: 200, body: [ + if (path.startsWith("/v2/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; + if (path === "/v2/model-providers") return { status: 200, body: { organization_id: "org-1", model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; + if (method === "GET" && path === "/v2/agents/agent-1/runs?limit=100") return { status: 200, body: [ { id: "run-2", agent_id: "agent-1", status: "failed", prompt: "Break it", result: null, error: "Tool failed", created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Build it", result: "Done", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, ] }; - if (method === "POST" && path === "/v1/agents") return { status: 201, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "pending", created_at_unix_seconds: 3, updated_at_unix_seconds: 3 } }; - if (method === "PATCH" && path === "/v1/agents/agent-1") return { status: 200, body: { id: "agent-1", runtime_id: "agent-1", name: "Atlas", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 4 } }; + if (method === "POST" && path === "/v2/agents") return { status: 201, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "pending", created_at_unix_seconds: 3, updated_at_unix_seconds: 3 } }; + if (method === "GET" && path === "/v2/agents/agent-2") return { status: 200, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "running", created_at_unix_seconds: 3, updated_at_unix_seconds: 4 } }; + if (method === "PATCH" && path === "/v2/agents/agent-1") return { status: 200, body: { id: "agent-1", runtime_id: "agent-1", name: "Atlas", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 4 } }; return { status: 404 }; }); const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; const client = new RuntaCloudAgentsClient(); expect((await client.listAgents())[0]).toEqual(expect.objectContaining({ id: "agent-1", name: "Builder", status: "idle", lastMessagePreview: "Latest agent reply", lastActiveAt: "2026-08-26T01:00:01Z" })); - expect(await client.listModelProviders()).toEqual([{ id: "provider-1", name: "Kimi", protocol: "openai_responses", defaultModel: "k3" }]); + expect(await client.listModelProviders()).toEqual({ organizationId: "org-1", providers: [{ id: "provider-1", name: "Kimi", protocol: "openai_responses", defaultModel: "k3" }] }); expect((await client.getConversation("conversation-agent-1")).messages).toEqual([ expect.objectContaining({ id: "run-1:user", role: "user", parts: [{ type: "text", text: "Build it" }] }), expect.objectContaining({ id: "run-1:agent", role: "agent", parts: [{ type: "text", text: "Done" }], streaming: false }), expect.objectContaining({ id: "run-2:user", role: "user", parts: [{ type: "text", text: "Break it" }] }), expect.objectContaining({ id: "run-2:agent", role: "system", parts: [{ type: "text", text: "Tool failed" }] }), ]); - expect(await client.createAgent({ name: "Reviewer", modelProviderId: "provider-1" })).toEqual(expect.objectContaining({ id: "agent-2", status: "working" })); + expect(await client.createAgent({ name: "Reviewer", modelProviderId: "provider-1" })).toEqual(expect.objectContaining({ id: "agent-2", status: "idle" })); expect(await client.updateAgent("agent-1", { name: "Atlas" })).toEqual(expect.objectContaining({ id: "agent-1", name: "Atlas" })); - expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v1/agents", body: expect.objectContaining({ model_provider: { type: "managed", id: "provider-1" } }) })); - expect(request).toHaveBeenCalledWith({ method: "PATCH", path: "/v1/agents/agent-1", body: { name: "Atlas" } }); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v2/agents", body: expect.objectContaining({ name: "Reviewer", system_prompt: expect.stringContaining("Be concise, practical, and honest"), initial_message: expect.stringContaining("Hi, I'm Reviewer, your Runta Crew agent."), model_provider: { type: "managed", id: "provider-1" } }) })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ path: "/v2/agents", body: expect.objectContaining({ system_prompt: expect.stringContaining("available files, terminal, browser, and computer tools") }) })); + expect(request).toHaveBeenCalledWith({ method: "PATCH", path: "/v2/agents/agent-1", body: { name: "Atlas" } }); }); it("translates the authenticated run SSE stream without exposing credentials", async () => { @@ -48,24 +58,292 @@ describe("RuntaCloudAgentsClient", () => { window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; const events: ConversationEvent[] = []; const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); - await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-1/events?after=-1", expect.any(Function))); - streamListener?.({ event: "acp.event", id: "4", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { text: "Checking." } } } } }); - streamListener?.({ event: "acp.event", id: "5", data: { params: { update: { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Read", kind: "read", status: "in_progress" } } } }); - streamListener?.({ event: "acp.event", id: "6", data: { params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Read", kind: "read", status: "completed" } } } }); - streamListener?.({ event: "acp.event", id: "7", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: "Hi" } } } } }); - streamListener?.({ event: "acp.event", id: "8", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: " there" } } } } }); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v2/agents/agent-1/runs/run-1/events?after=-1", expect.any(Function))); + streamListener?.({ event: "pi.event", id: "4", data: { type: "message_update", message: { id: "assistant-1" }, assistantMessageEvent: { type: "text_delta", delta: "Checking." } } }); + streamListener?.({ event: "pi.event", id: "5", data: { type: "tool_execution_start", toolCallId: "tool-1", toolName: "read", args: { path: "README.md" } } }); + streamListener?.({ event: "pi.event", id: "6", data: { type: "tool_execution_end", toolCallId: "tool-1", toolName: "read", args: { path: "README.md" }, result: { content: [{ type: "text", text: "README contents" }] }, isError: false } }); + streamListener?.({ event: "pi.event", id: "7", data: { type: "message_update", message: { id: "assistant-2" }, assistantMessageEvent: { type: "text_delta", delta: "Hi" } } }); + streamListener?.({ event: "pi.event", id: "8", data: { type: "message_update", message: { id: "assistant-2" }, assistantMessageEvent: { type: "text_delta", delta: " there" } } }); streamListener?.({ event: "run.status", id: "status:finished", data: { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Hello", result: "Hi there", error: null } }); + streamListener?.({ event: "stream.closed" }); expect(events).toContainEqual({ type: "message.created", message: expect.objectContaining({ id: "run-1:agent:assistant-1", parts: [{ type: "text", text: "Checking." }], streaming: true }) }); expect(events).toContainEqual({ type: "message.completed", messageId: "run-1:agent:assistant-1", notify: false }); expect(events).toContainEqual({ type: "message.created", message: expect.objectContaining({ id: "run-1:agent:assistant-2", parts: [{ type: "text", text: "Hi" }], streaming: true }) }); expect(events).toContainEqual({ type: "message.delta", messageId: "run-1:agent:assistant-2", delta: " there" }); - expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", title: "Reading file", kind: "file", status: "running" }) }); - expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", status: "completed" }) }); + expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", title: "read README.md", kind: "file", status: "running" }) }); + expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", title: "read README.md", output: "README contents", kind: "file", status: "completed" }) }); expect(events).not.toContainEqual(expect.objectContaining({ type: "message.updated", message: expect.objectContaining({ id: "run-1:agent" }) })); expect(events).toContainEqual({ type: "message.completed", messageId: "run-1:agent:assistant-2", notify: true }); subscription.unsubscribe(); }); + it("shows an upstream assistant error when a finished status precedes the Pi error event", async () => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + let finished = false; + const request = vi.fn(async () => ({ status: 200, body: [{ id: "run-error", agent_id: "agent-1", status: finished ? "finished" : "running", prompt: "Hello", result: finished ? "" : null, error: null }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce()); + + finished = true; + streamListener?.({ event: "run.status", data: { status: "finished", result: "", error: null } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: '404 {"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}' } } }); + streamListener?.({ event: "stream.closed" }); + + const failure = { id: "run-error:agent", role: "system", parts: [{ type: "text", text: 'Agent reply failed: 404 {"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}' }] }; + expect(events.filter((event) => event.type === "message.updated").at(-1)).toMatchObject({ type: "message.updated", message: failure }); + expect(events.at(-1)).toEqual({ type: "message.completed", messageId: "run-error:agent" }); + + window.dispatchEvent(new Event("focus")); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + expect(events.filter((event) => event.type === "message.updated").at(-1)).toMatchObject({ type: "message.updated", message: failure }); + subscription.unsubscribe(); + }); + + it.each([false, true])("keeps a retry working and shows its successful reply, with terminal-first replay=%s", async (terminalFirst) => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + const request = vi.fn(async () => ({ status: 200, body: [{ id: "retry", agent_id: "agent-1", status: "running", prompt: "Hello", result: null, error: null }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce()); + if (terminalFirst) streamListener?.({ event: "run.status", data: { status: "finished", result: "", error: null } }); + streamListener?.({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Failed partial" } } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 status code (no body)" } } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "aborted", errorMessage: "Request aborted" } } }); + streamListener?.({ event: "pi.event", data: { type: "agent_settled" } }); + expect(events.some((event) => event.type === "message.completed")).toBe(false); + expect(events.some((event) => event.type === "message.updated" && event.message.role === "system")).toBe(false); + streamListener?.({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Recovered" } } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Recovered" }] } } }); + if (!terminalFirst) streamListener?.({ event: "run.status", data: { status: "finished", result: "Recovered", error: null } }); + expect(events.some((event) => event.type === "message.completed")).toBe(false); + streamListener?.({ event: "stream.closed" }); + expect(events).toContainEqual({ type: "message.updated", message: expect.objectContaining({ id: "retry:agent:active", parts: [{ type: "text", text: "Recovered" }], streaming: true }) }); + expect(events).toContainEqual({ type: "message.completed", messageId: "retry:agent:active", notify: true }); + expect(events.some((event) => event.type === "message.updated" && event.message.role === "system")).toBe(false); + subscription.unsubscribe(); + }); + + it("does not finish a retry on network EOF and uses the final polling result", async () => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + let finished = false; + const request = vi.fn(async () => ({ status: 200, body: [{ id: "retry", agent_id: "agent-1", status: finished ? "finished" : "running", prompt: "Hello", result: finished ? "Recovered from polling" : null, error: null }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce()); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 temporary" } } }); + streamListener?.({ event: "error", data: "network interrupted" }); + streamListener?.({ event: "stream.closed" }); + window.dispatchEvent(new Event("focus")); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + expect(events.some((event) => event.type === "message.completed" || (event.type === "message.updated" && event.message.role === "system"))).toBe(false); + finished = true; + window.dispatchEvent(new Event("focus")); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(3)); + expect(events).toContainEqual({ type: "message.updated", message: expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "Recovered from polling" }], streaming: false }) }); + expect(events).toContainEqual({ type: "message.completed", messageId: "retry:agent" }); + expect(events.some((event) => event.type === "message.updated" && event.message.role === "system")).toBe(false); + subscription.unsubscribe(); + }); + + it("finishes from an authoritative polled reply when the SSE connection stays open", async () => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + let finished = false; + const request = vi.fn(async () => ({ status: 200, body: [{ id: "retry", agent_id: "agent-1", status: finished ? "finished" : "running", prompt: "Hello", result: finished ? "Final polled answer" : null, error: null }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce()); + streamListener?.({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Old partial" } } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 earlier attempt" } } }); + finished = true; + window.dispatchEvent(new Event("focus")); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(events).toContainEqual({ type: "message.updated", message: expect.objectContaining({ id: "retry:agent", role: "agent", parts: [{ type: "text", text: "Final polled answer" }], streaming: false }) })); + expect(events).toContainEqual({ type: "message.completed", messageId: "retry:agent" }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 stale replay" } } }); + expect(events.some((event) => event.type === "message.updated" && event.message.role === "system")).toBe(false); + subscription.unsubscribe(); + }); + + it.each(["failed", "cancelled"])("preserves authoritative live %s state over a pending attempt error", async (status) => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + const request = vi.fn(async () => ({ status: 200, body: [{ id: "terminal", agent_id: "agent-1", status: "running", prompt: "Hello" }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce()); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 earlier attempt" } } }); + streamListener?.({ event: "run.status", data: { status, result: "", error: status === "failed" ? "Final supervisor failure" : null } }); + streamListener?.({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "stop" } } }); + streamListener?.({ event: "stream.closed" }); + expect(events.filter((event) => event.type === "message.updated").at(-1)).toMatchObject({ message: { role: "system", parts: [{ type: "text", text: status === "failed" ? "Final supervisor failure" : "Run cancelled." }] } }); + subscription.unsubscribe(); + }); + + it.each(["", "Old partial plus final aggregate"])("clears a retry error in historical replay with persisted result %j", async (result) => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [{ id: "retry", agent_id: "agent-1", status: "finished", prompt: "Hello", result, error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "run.status", data: { status: "finished", result } }); + listener({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Failed partial" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 temporary" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "aborted" } } }); + listener({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Recovered reply" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "stop" } } }); + listener({ event: "stream.closed" }); + }); + return () => undefined; + }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + expect(messages).toEqual([expect.objectContaining({ role: "user" }), expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "Recovered reply" }] })]); + }); + + it.each(["error", "stream.closed"])("protects a saved successful result from an earlier replay error ending with %s", async (endEvent) => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [{ id: "retry", agent_id: "agent-1", status: "finished", prompt: "Hello", result: "Saved final answer", error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 earlier attempt" } } }); + listener({ event: endEvent }); + }); + return () => undefined; + }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + expect(messages.at(-1)).toMatchObject({ role: "agent", parts: [{ type: "text", text: "Saved final answer" }] }); + }); + + it.each(["failed", "cancelled"])("preserves historical terminal %s state after later successful assistant replay", async (status) => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [{ id: "retry", agent_id: "agent-1", status, prompt: "Hello", result: "", error: status === "failed" ? "Final supervisor failure" : null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "502 earlier attempt" } } }); + listener({ event: "pi.event", data: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Earlier successful output" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", stopReason: "stop" } } }); + listener({ event: "stream.closed" }); + }); + return () => undefined; + }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + expect(messages.at(-1)).toMatchObject({ role: "system", parts: [{ type: "text", text: status === "failed" ? "Final supervisor failure" : "Run cancelled." }] }); + }); + + it("recovers a persisted Pi assistant failure when replaying an empty finished run", async () => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [{ id: "run-error", agent_id: "agent-1", status: "finished", prompt: "Hello", result: "", error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "run.status", data: { status: "finished", result: "", error: null } }); + listener({ event: "pi.event", data: { type: "message_start", message: { role: "assistant", content: [] } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "404: The requested resource was not found" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role: "assistant", content: [], stopReason: "aborted", errorMessage: "Request aborted" } } }); + listener({ event: "pi.event", data: { type: "agent_settled" } }); + listener({ event: "stream.closed" }); + }); + return () => undefined; + }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(messages).toEqual([ + expect.objectContaining({ role: "user", parts: [{ type: "text", text: "Hello" }] }), + expect.objectContaining({ id: "run-error:agent", role: "system", parts: [{ type: "text", text: "Agent reply failed: 404: The requested resource was not found" }] }), + ]); + }); + + it("explains an empty finished reply and preserves its output attachments", async () => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [{ id: "artifact-1", run_id: "run-empty", name: "result.png", media_type: "image/png", size: 42 }] : [{ id: "run-empty", agent_id: "agent-1", status: "finished", prompt: "Render it", result: "", error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(messages[1]).toMatchObject({ role: "system", parts: [{ type: "text", text: "The Agent finished without returning a reply." }, { type: "attachment", attachment: { id: "artifact-1" } }] }); + }); + + it.each(["user", "tool"])("recovers a real reply from events without an empty-result warning or a %s error", async (role) => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [{ id: "run-recovered", agent_id: "agent-1", status: "finished", prompt: "Hello", result: "", error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "run.status", data: { status: "finished", result: "", error: null } }); + listener({ event: "pi.event", data: { type: "message_update", message: { id: "assistant-1", role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "Recovered reply" } } }); + listener({ event: "pi.event", data: { type: "message_end", message: { role, stopReason: "error", errorMessage: "Not an assistant reply failure" } } }); + listener({ event: "stream.closed" }); + }); + return () => undefined; + }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(messages).toEqual([ + expect.objectContaining({ role: "user", parts: [{ type: "text", text: "Hello" }] }), + expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "Recovered reply" }], streaming: false }), + ]); + }); + + it("shows the initial greeting without exposing its internal prompt", async () => { + const request = vi.fn(async () => ({ status: 200, body: [{ + id: "greeting-run", agent_id: "agent-1", status: "finished", + prompt: "Introduce yourself briefly using only the Runta Crew identity and name from your system instructions. Do not mention any model, provider, Pi, harness, runtime, or implementation details. Do not use tools or ask a question.", + result: "Hi, I’m ready to help.", error: null, + created_at: "2026-09-04T00:00:00Z", updated_at: "2026-09-04T00:00:01Z", + }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; + window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + + const conversation = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(conversation.messages).toEqual([ + expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "Hi, I’m ready to help." }] }), + ]); + }); + + it("does not expose workspace attachment context in replayed user messages", async () => { + const request = vi.fn(async ({ path }: CloudRequest) => path.includes("/artifacts?") + ? ({ status: 200, body: [{ id: "artifact-1", run_id: "image-run", name: "runta-crew-input-1-one.png", media_type: "image/png", size: 3 }] }) + : ({ status: 200, body: [{ id: "image-run", agent_id: "agent-1", status: "finished", prompt: "Compare these images\n\n[Runta Crew attachment context]\n- .runta-crew/attachments/one.png\n- .runta-crew/attachments/two.png", result: "They differ.", error: null }] })); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + + const conversation = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(conversation.messages[0]).toEqual(expect.objectContaining({ role: "user", parts: [{ type: "text", text: "Compare these images" }, { type: "attachment", attachment: expect.objectContaining({ id: "artifact-1", name: "one.png", source: "cloud" }) }] })); + expect(conversation.messages[1]?.parts).toEqual([{ type: "text", text: "They differ." }]); + expect(JSON.stringify(conversation.messages)).not.toContain(".runta-crew/attachments"); + }); + + it("waits for the complete initial Agent reply before focusing", async () => { + const request = vi.fn(async () => ({ status: 200, body: [{ id: "greeting-run", agent_id: "agent-1", status: "finished", result: "I am Atlas." }] })); + window.runtaCrew = { cloud: { request, subscribe: () => () => undefined }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + + const messages = await new RuntaCloudAgentsClient().waitForInitialReply("agent-1"); + + expect(request).toHaveBeenCalledWith({ method: "GET", path: "/v2/agents/agent-1/runs?limit=1" }); + expect(messages).toEqual([expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "I am Atlas." }], streaming: false })]); + }); + + it("returns the deterministic Crew greeting without another cloud request", async () => { + const request = vi.fn(); + window.runtaCrew = { cloud: { request, subscribe: () => () => undefined } } as unknown as DesktopBridge; + + const messages = await new RuntaCloudAgentsClient().waitForInitialReply("agent-1", "Atlas"); + + expect(request).not.toHaveBeenCalled(); + expect(messages).toEqual([expect.objectContaining({ role: "agent", parts: [{ type: "text", text: "Hello, I'm Atlas, and I'm all set.\nWhat can I help you tackle?" }], streaming: false })]); + expect(messages[0]?.parts[0]?.type === "text" ? messages[0].parts[0].text.split("\n") : []).toHaveLength(2); + }); + it("discovers a locally created run immediately instead of waiting for fallback polling", async () => { let created = false; const subscribe = vi.fn(() => () => undefined); @@ -77,11 +355,30 @@ describe("RuntaCloudAgentsClient", () => { const subscription = client.subscribeToConversationEvents("conversation-agent-1", () => undefined); await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); await client.sendMessage({ conversationId: "conversation-agent-1", text: "Start" }); - await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-new/events?after=-1", expect.any(Function))); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v2/agents/agent-1/runs/run-new/events?after=-1", expect.any(Function))); + await client.sendMessage({ conversationId: "conversation-agent-1", text: "Steer" }); + expect(request).toHaveBeenCalledWith({ method: "POST", path: "/v2/agents/agent-1/runs/run-new/follow-ups", body: { prompt: "Steer" } }); subscription.unsubscribe(); }); - it("replays run summaries from oldest to newest so the sidebar preview stays current", async () => { + it("sends multiple images as native prompt input without workspace upload", async () => { + const request = vi.fn(async ({ method, path, body }: CloudRequest) => { + if (method === "GET") return { status: 200, body: [] }; + if (path.endsWith("/artifacts")) { const value = body as { name: string; media_type: string; content_base64: string }; return { status: 201, body: { id: `artifact-${value.name}`, run_id: "run-image", name: value.name, media_type: value.media_type, size: atob(value.content_base64).length } }; } + return { status: 201, body: { id: "run-image", agent_id: "agent-1", status: "pending", prompt: "Describe it", result: null, error: null } }; + }); + window.runtaCrew = { cloud: { request, subscribe: () => () => undefined }, attachments: { choose: async () => [], addImage: async () => ({ id: "unused", name: "unused.png", size: 3, mediaType: "image/png" }), read: async (id: string) => id === "image-1" ? ({ name: "image.png", mediaType: "image/png", base64: "YWJj" }) : ({ name: "second.jpg", mediaType: "image/jpeg", base64: "ZGVm" }) } } as unknown as DesktopBridge; + + const message = await new RuntaCloudAgentsClient().sendMessage({ conversationId: "conversation-agent-1", text: "Compare them", attachments: [{ id: "image-1", name: "image.png", size: 3, mediaType: "image/png", source: "local-selection" }, { id: "image-2", name: "second.jpg", size: 3, mediaType: "image/jpeg", source: "local-selection" }] }); + + expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ method: "PUT" })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v2/agents/agent-1/runs", body: expect.objectContaining({ prompt: expect.stringContaining("Compare them"), images: [{ type: "image", data: "YWJj", mime_type: "image/png" }, { type: "image", data: "ZGVm", mime_type: "image/jpeg" }] }) })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v2/agents/agent-1/artifacts", body: expect.objectContaining({ run_id: "run-image", name: "runta-crew-input-1-image.png", content_base64: "YWJj" }) })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v2/agents/agent-1/artifacts", body: expect.objectContaining({ run_id: "run-image", name: "runta-crew-input-2-image.jpg", content_base64: "ZGVm" }) })); + expect(message.parts.filter((part) => part.type === "attachment").every((part) => part.attachment.source === "cloud")).toBe(true); + }); + + it("establishes an initial run baseline without duplicating loaded history", async () => { const request = vi.fn(async () => ({ status: 200, body: [ { id: "run-new", agent_id: "agent-1", status: "finished", prompt: "New prompt", result: "Newest reply", error: null, created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, { id: "run-old", agent_id: "agent-1", status: "finished", prompt: "Old prompt", result: "Old reply", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, @@ -91,12 +388,13 @@ describe("RuntaCloudAgentsClient", () => { const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => { if (event.type === "message.created" && event.message.role === "agent") replies.push(event.message.parts[0]?.type === "text" ? event.message.parts[0].text : ""); }); - await vi.waitFor(() => expect(replies).toEqual(["Old reply", "Newest reply"])); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + expect(replies).toEqual([]); subscription.unsubscribe(); }); it("replays historical assistant messages by ACP message id instead of the aggregated run result", async () => { - const request = vi.fn(async () => ({ status: 200, body: [{ + const request = vi.fn(async ({ path }: CloudRequest) => path.includes("/artifacts?") ? { status: 200, body: [{ id: "artifact-1", run_id: "run-history", name: "chart.png", media_type: "image/png", size: 42 }] } : ({ status: 200, body: [{ id: "run-history", agent_id: "agent-1", status: "finished", prompt: "Research it", result: "Checking.Browsing.Done with a giant aggregate", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z", @@ -104,10 +402,10 @@ describe("RuntaCloudAgentsClient", () => { const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => { listener({ event: "run.status", data: { status: "finished" } }); - listener({ event: "acp.event", id: "1", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { text: "Checking." } } } } }); - listener({ event: "acp.event", id: "2", data: { params: { update: { sessionUpdate: "tool_call", toolCallId: "tool-1" } } } }); - listener({ event: "acp.event", id: "3", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: "Done" } } } } }); - listener({ event: "acp.event", id: "4", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: " now." } } } } }); + listener({ event: "pi.event", id: "1", data: { type: "message_update", message: { id: "assistant-1" }, assistantMessageEvent: { type: "text_delta", delta: "Checking." } } }); + listener({ event: "pi.event", id: "2", data: { type: "tool_execution_start", toolCallId: "tool-1", toolName: "read", args: { path: "README.md" } } }); + listener({ event: "pi.event", id: "3", data: { type: "message_update", message: { id: "assistant-2" }, assistantMessageEvent: { type: "text_delta", delta: "Done" } } }); + listener({ event: "pi.event", id: "4", data: { type: "message_update", message: { id: "assistant-2" }, assistantMessageEvent: { type: "text_delta", delta: " now." } } }); listener({ event: "stream.closed" }); }); return () => undefined; @@ -118,7 +416,7 @@ describe("RuntaCloudAgentsClient", () => { expect(result.messages).toEqual([ expect.objectContaining({ id: "run-history:user", role: "user", parts: [{ type: "text", text: "Research it" }] }), - expect.objectContaining({ id: "run-history:agent:assistant-2", role: "agent", parts: [{ type: "text", text: "Done now." }], streaming: false }), + expect.objectContaining({ id: "run-history:agent:assistant-2", role: "agent", parts: [{ type: "text", text: "Done now." }, { type: "attachment", attachment: { id: "artifact-1", name: "chart.png", size: 42, mediaType: "image/png", source: "cloud", agentId: "agent-1" } }], streaming: false }), ]); expect(JSON.stringify(result.messages)).not.toContain("Checking."); expect(JSON.stringify(result.messages)).not.toContain("giant aggregate"); diff --git a/src/clients/http/RuntaCloudAgentsClient.ts b/src/clients/http/RuntaCloudAgentsClient.ts index 36395d0..bd0f820 100644 --- a/src/clients/http/RuntaCloudAgentsClient.ts +++ b/src/clients/http/RuntaCloudAgentsClient.ts @@ -1,10 +1,14 @@ import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type CloudComputer, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderOption, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; +import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type Attachment, type CloudComputer, type CloudComputerSession, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderCatalog, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; import type { CloudRequest, CloudStreamEvent } from "@/shared/desktop"; interface RuntaAgent { id: string; runtime_id: string; name: string; status: string; created_at_unix_seconds: number; updated_at_unix_seconds: number; latest_reply?: { run_id: string; text: string; created_at?: string | null; updated_at?: string | null } | null } interface RuntaRun { id: string; agent_id: string; status: string; prompt?: string | null; result?: string | null; error?: string | null; dsh_session_id?: string | null; created_at?: string | null; updated_at?: string | null } interface ModelProvider { id: string; display_name: string; protocol: string; default_model?: string | null } +interface RuntaArtifact { id: string; run_id: string; name: string; media_type: string; size: number } +interface RuntaComputerSession { channels: { vnc: { websocket_url: string; protocols: string[] } } } +const INPUT_ARTIFACT_PREFIX = "runta-crew-input-"; +const inputArtifactName = (index: number, mediaType: string) => `${INPUT_ARTIFACT_PREFIX}${index + 1}-image.${mediaType.toLowerCase() === "image/jpeg" ? "jpg" : mediaType.toLowerCase().slice("image/".length)}`; const conversationId = (agentId: string) => `conversation-${agentId}`; const agentIdFromConversation = (id: string) => id.startsWith("conversation-") ? id.slice("conversation-".length) : id; @@ -12,6 +16,35 @@ const iso = (seconds: number) => new Date(seconds * 1000).toISOString(); const status = (value: string): Agent["status"] => value === "running" ? "idle" : value === "pending" ? "working" : "offline"; const terminalRunStatuses = new Set(["finished", "failed", "cancelled"]); const RUN_FALLBACK_REFRESH_MS = 30_000; +const AGENT_READY_TIMEOUT_MS = 30_000; +const AGENT_READY_POLL_MS = 100; +const LEGACY_INITIAL_MESSAGE = "Introduce yourself briefly to the user. Do not use tools or ask a question."; +const LEGACY_IDENTITY_INITIAL_MESSAGE = "Introduce yourself briefly using only the Runta Crew identity and name from your system instructions. Do not mention any model, provider, Pi, harness, runtime, or implementation details. Do not use tools or ask a question."; +const INITIAL_MESSAGE_PREFIX = "[Runta Crew bootstrap] "; +const initialMessage = (name: string) => `${INITIAL_MESSAGE_PREFIX}Reply with exactly these two short sentences: ${JSON.stringify(`Hi, I'm ${name}, your Runta Crew agent.`)} ${JSON.stringify("Tell me what you're working on and I'll jump in.")} Do not add anything else.`; +const crewSystemPrompt = (name: string) => `You are ${JSON.stringify(name)}, the user's Runta Crew agent. Complete tasks using the available files, terminal, browser, and computer tools; verify results before reporting them. Be concise, practical, and honest. Do not use emoji unless asked. Do not proactively mention underlying models or implementation details.`; +const crewGreeting = (agentId: string, name: string) => { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(agentId)) hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + const openings = [ + `Hi, I'm ${name}, your Runta Crew agent.`, `Hello, I'm ${name}, ready to help.`, `${name} here, ready when you are.`, `Hi, I'm ${name} from your Runta Crew.`, `Hello, ${name} here and ready to go.`, + `I'm ${name}, your Crew agent.`, `Hi there, I'm ${name}.`, `${name} here, ready to get started.`, `Hello, I'm ${name}, and I'm all set.`, `Hi, I'm ${name}, here to help.`, + ]; + const invitations = [ + "Tell me what you're working on and I'll jump in.", "What should we work on first?", "Tell me what you'd like to get done.", "Point me at a task and I'll get started.", "What can I help you tackle?", + "Share the task and I'll take it from there.", "Where would you like to begin?", "Let me know what you'd like me to handle.", "What's first on the list?", "Give me a task and I'll get moving.", + ]; + const variant = hash % 100; + return `${openings[Math.floor(variant / 10)]}\n${invitations[variant % 10]}`; +}; +const isInitialMessage = (prompt: string | null | undefined) => prompt === LEGACY_INITIAL_MESSAGE || prompt === LEGACY_IDENTITY_INITIAL_MESSAGE || prompt?.startsWith(INITIAL_MESSAGE_PREFIX) === true; +const visiblePrompt = (prompt: string | null | undefined) => { + if (!prompt) return ""; + const legacyMarker = "\n\nAttached files are available in the workspace:\n"; + const marker = "\n\n[Runta Crew attachment context]\n"; + const value = prompt.split(prompt.includes(marker) ? marker : legacyMarker, 1)[0]?.trim() ?? ""; + return value === "Please review the attached file(s)." ? "" : value; +}; function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function toolActivityKind(value: string): ActivityEvent["kind"] { @@ -22,43 +55,55 @@ function toolActivityKind(value: string): ActivityEvent["kind"] { if (/agent|task|handoff/.test(normalized)) return "handoff"; return "status"; } -function toolActivityTitle(value: string): string { - const normalized = value.toLowerCase(); - if (/read|cat|view/.test(normalized)) return "Reading file"; - if (/write|edit|patch|create/.test(normalized)) return "Editing file"; - if (/search|grep|glob|find/.test(normalized)) return "Searching files"; - if (/terminal|command|shell|bash|exec|run/.test(normalized)) return "Running command"; - if (/browser|web|fetch|url/.test(normalized)) return "Browsing web"; - if (/agent|task|handoff/.test(normalized)) return "Running agent"; - return value.trim() || "Working"; -} -function activityFromToolUpdate(update: Record, conversationIdValue: string): ActivityEvent | undefined { - if (typeof update.sessionUpdate !== "string" || !/tool[_-]?call(?:[_-]?update)?/i.test(update.sessionUpdate)) return undefined; - const id = stringValue(update.toolCallId) ?? stringValue(update.tool_call_id) ?? stringValue(update.id); - if (!id) return undefined; - const rawTitle = stringValue(update.title) ?? stringValue(update.name) ?? stringValue(update.kind) ?? "Working"; - const rawStatus = stringValue(update.status)?.toLowerCase() ?? "running"; - const status: ActivityEvent["status"] = /fail|error/.test(rawStatus) ? "failed" : /complete|finish|done/.test(rawStatus) ? "completed" : "running"; - return { id: `tool:${id}`, conversationId: conversationIdValue, kind: toolActivityKind(`${stringValue(update.kind) ?? ""} ${rawTitle}`), title: toolActivityTitle(rawTitle), detail: rawTitle, status, createdAt: new Date().toISOString() }; +function toolResultText(value: unknown): string | undefined { + if (!value || typeof value !== "object") return typeof value === "string" ? value : undefined; + const content = (value as { content?: unknown }).content; + if (!Array.isArray(content)) return undefined; + const text = content.flatMap((part) => part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string" ? [(part as { text: string }).text] : []).join("\n").trim(); + return text || undefined; } - -function runMessages(run: RuntaRun, id: string): Message[] { +function runMessages(run: RuntaRun, id: string, replyError?: string): Message[] { const createdAt = run.created_at ?? new Date().toISOString(); const updatedAt = run.updated_at ?? createdAt; - const user = run.prompt ? [{ id: `${run.id}:user`, conversationId: id, role: "user" as const, parts: [{ type: "text" as const, text: run.prompt }], createdAt }] : []; - if (run.status === "failed" || run.status === "cancelled") { - const detail = run.error?.trim() || (run.status === "cancelled" ? "Run cancelled." : "Run failed."); + const prompt = visiblePrompt(run.prompt); const user = prompt && !isInitialMessage(run.prompt) ? [{ id: `${run.id}:user`, conversationId: id, role: "user" as const, parts: [{ type: "text" as const, text: prompt }], createdAt }] : []; + const terminalError = terminalRunStatuses.has(run.status) && run.status !== "cancelled" ? replyError : undefined; + if (terminalError || run.status === "failed" || run.status === "cancelled" || (run.status === "finished" && !run.result?.trim())) { + const detail = run.error?.trim() || terminalError || (run.status === "cancelled" ? "Run cancelled." : run.status === "finished" ? "The Agent finished without returning a reply." : "Run failed."); return [...user, { id: `${run.id}:agent`, conversationId: id, role: "system", parts: [{ type: "text", text: detail }], createdAt: updatedAt }]; } return [...user, { id: `${run.id}:agent`, conversationId: id, role: "agent", parts: [{ type: "text", text: run.result ?? "" }], createdAt: updatedAt, streaming: !terminalRunStatuses.has(run.status) }]; } +function assistantError(event: CloudStreamEvent): string | undefined { + if (event.event !== "pi.event" || !event.data || typeof event.data !== "object") return undefined; + const payload = event.data as { type?: string; message?: { role?: string; stopReason?: string; errorMessage?: string } }; + if (payload.type !== "message_end" || payload.message?.role !== "assistant" || payload.message.stopReason !== "error") return undefined; + const detail = stringValue(payload.message.errorMessage); + return detail ? `Agent reply failed: ${detail}` : "The Agent failed to generate a reply."; +} + +function assistantSucceeded(event: CloudStreamEvent): boolean { + if (event.event !== "pi.event" || !event.data || typeof event.data !== "object") return false; + const payload = event.data as { type?: string; message?: { role?: string; stopReason?: string } }; + return payload.type === "message_end" && payload.message?.role === "assistant" && ["stop", "length", "toolUse"].includes(payload.message.stopReason ?? ""); +} + function assistantChunk(event: CloudStreamEvent): { sourceId: string; text: string } | undefined { - if (event.event !== "acp.event" || !event.data || typeof event.data !== "object") return undefined; - const payload = event.data as { params?: { update?: { sessionUpdate?: string; messageId?: string; content?: { text?: string } } } }; - const update = payload.params?.update; - if (update?.sessionUpdate !== "agent_message_chunk" || typeof update.content?.text !== "string" || !update.content.text) return undefined; - return { sourceId: stringValue(update.messageId) ?? "legacy", text: update.content.text }; + if (event.event !== "pi.event" || !event.data || typeof event.data !== "object") return undefined; + const payload = event.data as { type?: string; message?: { id?: string }; assistantMessageEvent?: { type?: string; delta?: string } }; + if (payload.type !== "message_update" || payload.assistantMessageEvent?.type !== "text_delta" || !payload.assistantMessageEvent.delta) return undefined; + return { sourceId: stringValue(payload.message?.id) ?? "active", text: payload.assistantMessageEvent.delta }; +} +function activityFromPiEvent(value: Record, conversationIdValue: string, previous?: ActivityEvent): ActivityEvent | undefined { + if (!/^tool_execution_(?:start|update|end)$/.test(String(value.type ?? ""))) return undefined; + const id = stringValue(value.toolCallId); if (!id) return undefined; + const name = stringValue(value.toolName) ?? previous?.detail ?? "Tool"; + const args = value.args && typeof value.args === "object" ? value.args as Record : {}; + const subject = stringValue(args.path) ?? stringValue(args.file_path) ?? (name === "bash" ? stringValue(args.command) : undefined); + const rawTitle = subject ? `${name === "bash" ? "" : `${name} `}${subject}` : name; + const title = rawTitle.slice(0, 500); const timestamp = new Date().toISOString(); + const status: ActivityEvent["status"] = value.type === "tool_execution_end" ? value.isError === true ? "failed" : "completed" : "running"; + return { id: `tool:${id}`, conversationId: conversationIdValue, kind: toolActivityKind(name), title, detail: rawTitle, output: toolResultText(value.result) ?? previous?.output, status, createdAt: previous?.createdAt ?? timestamp, updatedAt: timestamp }; } async function mapWithConcurrency(values: T[], concurrency: number, mapper: (value: T) => Promise): Promise { @@ -87,6 +132,7 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { } if (response.status === 401) throw new CrewError("unauthorized", "Authentication is required"); if (response.status === 404) throw new CrewError("not_found", "Resource not found"); + if (response.status === 409) throw new CrewError("conflict", "Cloud Agent is completing a lifecycle operation", true); if (response.status < 200 || response.status >= 300) throw new CrewError("unknown", `Cloud Agents request failed (${response.status})`, response.status >= 500); return response.body as T; } @@ -95,67 +141,125 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { return { id: value.id, name: value.name, role: "Cloud coding agent", goal: value.name, status: status(value.status), avatar: value.name.slice(0, 1).toUpperCase(), lastActiveAt: value.latest_reply?.updated_at ?? iso(value.updated_at_unix_seconds), unreadCount: 0, computerId: value.runtime_id, lastMessagePreview: value.latest_reply?.text }; } - async listModelProviders(_signal?: AbortSignal): Promise { + async listModelProviders(_signal?: AbortSignal): Promise { void _signal; - const response = await this.request<{ model_providers: ModelProvider[] }>({ method: "GET", path: "/v1/model-providers" }); - return response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })); + const response = await this.request<{ organization_id: string; model_providers: ModelProvider[] }>({ method: "GET", path: "/v2/model-providers" }); + return { organizationId: response.organization_id, providers: response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })) }; } async listAgents(_signal?: AbortSignal) { void _signal; - const response = await this.request<{ agents: RuntaAgent[] }>({ method: "GET", path: "/v1/agents?limit=250&include_latest_reply=true" }); + const response = await this.request<{ agents: RuntaAgent[] }>({ method: "GET", path: "/v2/agents?limit=250&include_latest_reply=true" }); return response.agents.map((agent) => this.mapAgent(agent)); } - async getAgent(agentId: string, _signal?: AbortSignal) { void _signal; return this.mapAgent(await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}` })); } + async getAgent(agentId: string, _signal?: AbortSignal) { void _signal; return this.mapAgent(await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}` })); } async createAgent(input: CreateAgentInput, _signal?: AbortSignal) { - void _signal; if (!input.modelProviderId) throw new CrewError("contract_pending", "Select a managed model provider before creating a Crew agent"); - const created = await this.request({ method: "POST", path: "/v1/agents", body: { name: input.name, model_provider: { type: "managed", id: input.modelProviderId } } }); - return this.mapAgent(created); + const created = await this.request({ method: "POST", path: "/v2/agents", body: { name: input.name, system_prompt: crewSystemPrompt(input.name), initial_message: initialMessage(input.name), model_provider: { type: "managed", id: input.modelProviderId } } }); + const ready = await this.waitForAgentRunning(created.id, _signal); + return this.mapAgent(ready); + } + private async waitForAgentRunning(agentId: string, signal?: AbortSignal): Promise { + const deadline = Date.now() + AGENT_READY_TIMEOUT_MS; + for (;;) { + if (signal?.aborted) throw new DOMException("Agent creation was cancelled", "AbortError"); + const agent = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}` }); + if (agent.status === "running") return agent; + if (agent.status === "failed") throw new CrewError("unknown", "The new Agent failed to start", true); + if (Date.now() >= deadline) throw new CrewError("network", "The new Agent is still starting", true); + await new Promise((resolve) => window.setTimeout(resolve, AGENT_READY_POLL_MS)); + } + } + async waitForInitialReply(agentId: string, name?: string, signal?: AbortSignal): Promise { + if (name?.trim()) { + return [{ + id: `bootstrap:${agentId}:agent`, + conversationId: conversationId(agentId), + role: "agent", + parts: [{ type: "text", text: crewGreeting(agentId, name.trim()) }], + createdAt: new Date().toISOString(), + streaming: false, + }]; + } + const deadline = Date.now() + 3 * 60_000; + for (;;) { + if (signal?.aborted) throw new DOMException("Agent creation was cancelled", "AbortError"); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=1` }); + const run = runs[0]; + if (run && terminalRunStatuses.has(run.status)) { + if (run.status !== "finished") throw new CrewError("unknown", run.error?.trim() || "The Agent introduction failed", true); + if (run.result?.trim()) return runMessages(run, conversationId(agentId)).filter((message) => message.role === "agent"); + } + if (Date.now() >= deadline) throw new CrewError("network", "The Agent introduction is still pending", true); + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } } async updateAgent(agentId: string, input: UpdateAgentInput, _signal?: AbortSignal): Promise { void _signal; if (!input.name || Object.keys(input).some((key) => key !== "name")) throw new CrewError("contract_pending", "Only the Agent name can be updated"); - return this.mapAgent(await this.request({ method: "PATCH", path: `/v1/agents/${encodeURIComponent(agentId)}`, body: { name: input.name } })); + return this.mapAgent(await this.request({ method: "PATCH", path: `/v2/agents/${encodeURIComponent(agentId)}`, body: { name: input.name } })); } - async deleteAgent(agentId: string, _signal?: AbortSignal) { void _signal; await this.request({ method: "DELETE", path: `/v1/agents/${encodeURIComponent(agentId)}?delete_runtime=true` }); } + async deleteAgent(agentId: string, _signal?: AbortSignal) { void _signal; await this.request({ method: "DELETE", path: `/v2/agents/${encodeURIComponent(agentId)}?delete_runtime=true` }); } async duplicateAgent(_agentId: string, _signal?: AbortSignal): Promise { void _agentId; void _signal; throw new CrewError("contract_pending", "Agent duplication requires the Cloud Agents duplication contract"); } async setAgentUnread(agentId: string, _unread: boolean, signal?: AbortSignal) { return this.getAgent(agentId, signal); } async listConversations(agentId: string, _signal?: AbortSignal) { void _signal; - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); const latest = runs[0]; return [{ id: conversationId(agentId), agentId, title: "Agent conversation", updatedAt: latest?.updated_at ?? new Date().toISOString() }]; } + private async listRunArtifacts(agentId: string, runId: string): Promise<{ input: Attachment[]; output: Attachment[] }> { + try { + const artifacts = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/artifacts?run_id=${encodeURIComponent(runId)}` }); + const input: Attachment[] = []; const output: Attachment[] = []; + for (const artifact of artifacts.filter((value) => typeof value.id === "string" && typeof value.name === "string" && typeof value.media_type === "string" && typeof value.size === "number")) { + const inputName = artifact.name.startsWith(INPUT_ARTIFACT_PREFIX) ? artifact.name.slice(INPUT_ARTIFACT_PREFIX.length).replace(/^\d+-/, "") : undefined; + const attachment: Attachment = { id: artifact.id, name: inputName || artifact.name, size: artifact.size, mediaType: artifact.media_type, source: "cloud", agentId }; + (inputName === undefined ? output : input).push(attachment); + } + return { input, output }; + } catch { + return { input: [], output: [] }; + } + } private replayRunMessages(agentId: string, run: RuntaRun, id: string, signal?: AbortSignal): Promise { const fallback = runMessages(run, id); const bridge = window.runtaCrew?.cloud; if (!bridge?.subscribe || !terminalRunStatuses.has(run.status) || signal?.aborted) return Promise.resolve(fallback); return new Promise((resolve) => { let current: Message | undefined; + let replyError: string | undefined; let sawAssistant = false; let settled = false; let unsubscribe: () => void = () => undefined; - const finish = () => { + const finish = (incomplete = false) => { if (settled) return; settled = true; window.clearTimeout(timeout); - signal?.removeEventListener("abort", finish); + signal?.removeEventListener("abort", aborted); unsubscribe(); + if (run.status === "finished" && run.result?.trim() && (incomplete || replyError)) { resolve(fallback); return; } + if (replyError) { resolve(runMessages(run, id, replyError)); return; } if (!sawAssistant) { resolve(fallback); return; } + const recoveredReply = current?.parts.some((part) => part.type === "text" && part.text.trim()); resolve([ ...fallback.filter((message) => message.role === "user"), ...(current ? [current] : []), - ...fallback.filter((message) => message.role === "system"), + ...fallback.filter((message) => message.role === "system" && !(recoveredReply && run.status === "finished" && !run.error?.trim())), ]); }; - const timeout = window.setTimeout(finish, 10_000); - signal?.addEventListener("abort", finish, { once: true }); - unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event) => { - if (event.event === "stream.closed" || event.event === "error") { finish(); return; } - if (event.event === "acp.event" && event.data && typeof event.data === "object") { - const payload = event.data as { params?: { update?: { sessionUpdate?: string } } }; - if (payload.params?.update?.sessionUpdate === "tool_call") { current = undefined; return; } + const aborted = () => finish(true); + const timeout = window.setTimeout(() => finish(true), 10_000); + signal?.addEventListener("abort", aborted, { once: true }); + unsubscribe = bridge.subscribe(`/v2/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event) => { + if (settled) return; + if (event.event === "stream.closed" || event.event === "error") { finish(event.event === "error"); return; } + const failure = assistantError(event); + if (failure) { replyError = failure; current = undefined; } + else if (assistantSucceeded(event)) replyError = undefined; + if (event.event === "pi.event" && event.data && typeof event.data === "object") { + const payload = event.data as { type?: string }; + if (payload.type === "tool_execution_start") { current = undefined; return; } } const chunk = assistantChunk(event); if (!chunk) return; @@ -179,56 +283,112 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { } async getConversation(id: string, _signal?: AbortSignal) { const agentId = agentIdFromConversation(id); - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); - const messageGroups = await mapWithConcurrency(runs.slice().reverse(), 8, (run) => this.replayRunMessages(agentId, run, id, _signal)); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const messageGroups = await mapWithConcurrency(runs.slice().reverse(), 8, async (run) => { + const [runMessagesValue, artifacts] = await Promise.all([this.replayRunMessages(agentId, run, id, _signal), this.listRunArtifacts(agentId, run.id)]); + let userTarget = runMessagesValue.find((message) => message.role === "user"); + if (!userTarget && artifacts.input.length) { + userTarget = { id: `${run.id}:user`, conversationId: id, role: "user", parts: [], createdAt: run.created_at ?? new Date().toISOString() }; + runMessagesValue.unshift(userTarget); + } + if (userTarget) userTarget.parts.push(...artifacts.input.map((attachment) => ({ type: "attachment" as const, attachment }))); + const agentTarget = [...runMessagesValue].reverse().find((message) => message.role === "agent" || message.id === `${run.id}:agent`); + if (agentTarget) agentTarget.parts.push(...artifacts.output.map((attachment) => ({ type: "attachment" as const, attachment }))); + return runMessagesValue; + }); const messages = messageGroups.flat(); return { conversation: { id, agentId, title: "Agent conversation", updatedAt: runs[0]?.updated_at ?? new Date().toISOString() }, messages }; } async sendMessage(input: SendMessageInput): Promise { - if (input.attachments?.length) throw new CrewError("contract_pending", "Cloud attachment upload is not available yet"); const agentId = agentIdFromConversation(input.conversationId); - const run = await this.request({ method: "POST", path: `/v1/agents/${encodeURIComponent(agentId)}/runs`, body: { prompt: input.text } }); + const preparedImages = await Promise.all((input.attachments ?? []).map(async (attachment) => { + if (attachment.source !== "local-selection") throw new CrewError("contract_pending", "Only local attachments can be sent"); + const content = await window.runtaCrew?.attachments.read(attachment.id); if (!content) throw new CrewError("unknown", "Attachment is no longer available"); + if (!/^image\/(?:gif|jpeg|png|webp)$/i.test(content.mediaType)) throw new CrewError("contract_pending", "This attachment type is not supported as native Agent input"); + return { attachment, content, promptImage: { type: "image" as const, data: content.base64, mime_type: content.mediaType } }; + })); + const prompt = input.text.trim(); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=1` }); + const latest = runs[0]; + const run = await this.request({ method: "POST", path: latest ? `/v2/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(latest.id)}/follow-ups` : `/v2/agents/${encodeURIComponent(agentId)}/runs`, body: { prompt, ...(preparedImages.length ? { images: preparedImages.map((image) => image.promptImage) } : {}) } }); + const persistedImages = await Promise.all(preparedImages.map(async ({ attachment, content }, index) => { + const artifact = await this.request({ method: "POST", path: `/v2/agents/${encodeURIComponent(agentId)}/artifacts`, body: { run_id: run.id, name: inputArtifactName(index, content.mediaType), media_type: content.mediaType, content_base64: content.base64 } }); + return { id: artifact.id, name: attachment.name, size: artifact.size, mediaType: artifact.media_type, source: "cloud" as const, agentId }; + })); for (const refresh of this.conversationRefreshListeners.get(input.conversationId) ?? []) refresh(); - return { id: `${run.id}:user`, conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date().toISOString() }; + return { id: `${run.id}:user:${crypto.randomUUID()}`, conversationId: input.conversationId, role: "user", parts: [...(input.text ? [{ type: "text" as const, text: input.text }] : []), ...persistedImages.map((attachment) => ({ type: "attachment" as const, attachment }))], createdAt: new Date().toISOString() }; } subscribeToConversationEvents(id: string, listener: (event: ConversationEvent) => void): Subscription { const agentId = agentIdFromConversation(id); const observed = new Map(); const streams = new Map void>(); - const assistantStreams = new Map }>(); + const assistantStreams = new Map; retrying: boolean }>(); + const replyErrors = new Map(); + const streamRunUpdates = new Map void>(); + let baselineReady = false; const bridge = window.runtaCrew?.cloud; const subscribeToRun = (run: RuntaRun) => { if (!bridge?.subscribe || streams.has(run.id) || terminalRunStatuses.has(run.status)) return; - let terminal = false; - const assistant = { seen: new Set() } as { current?: string; seen: Set }; + let latestRun = run; + let streamClosed = false; + let incompleteStream = false; + let finalized = false; + let replyError: string | undefined; + const assistant = { seen: new Set(), retrying: false } as { current?: string; seen: Set; retrying: boolean }; + const toolActivities = new Map(); assistantStreams.set(run.id, assistant); const completeCurrentAssistant = (notify = false) => { if (!assistant.current) return; listener({ type: "message.completed", messageId: assistant.current, notify }); assistant.current = undefined; }; - const unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event: CloudStreamEvent) => { + const finish = (fromPoll = false) => { + if (finalized || !terminalRunStatuses.has(latestRun.status)) return; + const polledReply = fromPoll && latestRun.status === "finished" && Boolean(latestRun.result?.trim()); + if (latestRun.status === "finished" && !streamClosed && !polledReply) return; + finalized = true; + const authoritativeFailure = latestRun.status === "failed" || latestRun.status === "cancelled"; + const error = latestRun.status === "finished" && latestRun.result?.trim() ? undefined : replyError; + if (error) replyErrors.set(run.id, error); else replyErrors.delete(run.id); + if (authoritativeFailure || polledReply || incompleteStream || replyError || assistant.seen.size === 0) { + completeCurrentAssistant(); + const message = runMessages(latestRun, id, error).find((candidate) => candidate.id === `${run.id}:agent`); + if (message) listener({ type: "message.updated", message }); + listener({ type: "message.completed", messageId: `${run.id}:agent` }); + } else completeCurrentAssistant(true); + }; + streamRunUpdates.set(run.id, (next) => { latestRun = next; finish(true); }); + const unsubscribe = bridge.subscribe(`/v2/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event: CloudStreamEvent) => { + if (finalized) return; + if (event.event === "error") { incompleteStream = true; return; } + if (event.event === "stream.closed") { incompleteStream ||= !terminalRunStatuses.has(latestRun.status); streamClosed = true; finish(); return; } + const failure = assistantError(event); + if (failure) { + replyError = failure; + assistant.retrying = true; + return; + } + if (assistantSucceeded(event)) { replyError = undefined; replyErrors.delete(run.id); } if (event.event === "run.status" && event.data && typeof event.data === "object") { const data = event.data as Partial & { session_id?: string | null }; if (typeof data.status !== "string") return; - const next: RuntaRun = { ...run, ...data, prompt: data.prompt ?? run.prompt, dsh_session_id: data.session_id ?? data.dsh_session_id ?? run.dsh_session_id }; - terminal = terminalRunStatuses.has(next.status); - if (terminal) completeCurrentAssistant(true); - if (assistant.seen.size === 0) { - const message = runMessages(next, id).find((candidate) => candidate.id === `${run.id}:agent`); + latestRun = { ...latestRun, ...data, prompt: data.prompt ?? latestRun.prompt, dsh_session_id: data.session_id ?? data.dsh_session_id ?? latestRun.dsh_session_id }; + if (terminalRunStatuses.has(latestRun.status)) finish(); + else if (assistant.seen.size === 0) { + const message = runMessages(latestRun, id).find((candidate) => candidate.id === `${run.id}:agent`); if (message) listener({ type: "message.updated", message }); - if (terminal) listener({ type: "message.completed", messageId: `${run.id}:agent` }); } return; } - if (event.event !== "acp.event" || terminal || !event.data || typeof event.data !== "object") return; - const payload = event.data as { params?: { update?: Record & { sessionUpdate?: string; messageId?: string; content?: { text?: string } } } }; - const update = payload.params?.update; + if (event.event !== "pi.event" || !event.data || typeof event.data !== "object") return; + const update = event.data as Record; const chunk = assistantChunk(event); if (chunk) { const sourceId = chunk.sourceId; const messageId = `${run.id}:agent:${sourceId}`; - if (!assistant.seen.has(messageId)) { + if (assistant.retrying && assistant.current === messageId) { + listener({ type: "message.updated", message: { id: messageId, conversationId: id, role: "agent", parts: [{ type: "text", text: chunk.text }], createdAt: new Date().toISOString(), streaming: true } }); + } else if (!assistant.seen.has(messageId)) { completeCurrentAssistant(); assistant.current = messageId; assistant.seen.add(messageId); @@ -237,10 +397,12 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { assistant.current = messageId; listener({ type: "message.delta", messageId, delta: chunk.text }); } + assistant.retrying = false; } - if (update?.sessionUpdate === "tool_call") completeCurrentAssistant(); - const activity = update ? activityFromToolUpdate(update, id) : undefined; - if (activity) listener({ type: "activity.updated", activity }); + if (update.type === "tool_execution_start") completeCurrentAssistant(); + const activityId = stringValue(update.toolCallId); + const activity = activityFromPiEvent(update, id, activityId ? toolActivities.get(`tool:${activityId}`) : undefined); + if (activity) { toolActivities.set(activity.id, activity); listener({ type: "activity.updated", activity }); } }); streams.set(run.id, unsubscribe); }; @@ -249,19 +411,23 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { if (polling) { pollAgain = true; return; } polling = true; try { - const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const runs = await this.request({ method: "GET", path: `/v2/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const establishingBaseline = !baselineReady; for (const run of runs.slice().reverse()) { const signature = `${run.status}\u0000${run.result ?? ""}\u0000${run.error ?? ""}`; const previous = observed.get(run.id); observed.set(run.id, signature); subscribeToRun(run); - if (previous === undefined) { - for (const message of runMessages(run, id)) listener({ type: "message.created", message }); - } else if (previous !== signature && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) { - const agentMessage = runMessages(run, id).find((message) => message.id === `${run.id}:agent`); + const updateStreamRun = streamRunUpdates.get(run.id); + if (previous !== signature) updateStreamRun?.(run); + if (previous === undefined && !establishingBaseline) { + for (const message of runMessages(run, id, replyErrors.get(run.id))) listener({ type: "message.created", message }); + } else if (!updateStreamRun && !establishingBaseline && previous !== signature && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) { + const agentMessage = runMessages(run, id, replyErrors.get(run.id)).find((message) => message.id === `${run.id}:agent`); if (agentMessage) listener({ type: "message.updated", message: agentMessage }); } - if (previous !== signature && terminalRunStatuses.has(run.status) && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) listener({ type: "message.completed", messageId: `${run.id}:agent` }); + if (!updateStreamRun && !establishingBaseline && previous !== signature && terminalRunStatuses.has(run.status) && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) listener({ type: "message.completed", messageId: `${run.id}:agent` }); } + baselineReady = true; listener({ type: "connection.changed", state: "connected" }); } catch { listener({ type: "connection.changed", state: "error" }); } finally { polling = false; if (pollAgain) { pollAgain = false; void poll(); } } @@ -271,12 +437,16 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { const refreshListeners = this.conversationRefreshListeners.get(id) ?? new Set<() => void>(); refreshListeners.add(refreshWhenActive); this.conversationRefreshListeners.set(id, refreshListeners); void poll(); const timer = window.setInterval(refreshWhenActive, RUN_FALLBACK_REFRESH_MS); window.addEventListener("focus", refreshWhenActive); window.addEventListener("online", refreshWhenActive); document.addEventListener("visibilitychange", onVisibilityChange); - return { unsubscribe: () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); refreshListeners.delete(refreshWhenActive); if (refreshListeners.size === 0) this.conversationRefreshListeners.delete(id); for (const unsubscribe of streams.values()) unsubscribe(); streams.clear(); assistantStreams.clear(); } }; + return { unsubscribe: () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); refreshListeners.delete(refreshWhenActive); if (refreshListeners.size === 0) this.conversationRefreshListeners.delete(id); for (const unsubscribe of streams.values()) unsubscribe(); streams.clear(); assistantStreams.clear(); streamRunUpdates.clear(); } }; } async listApprovalRequests(_agentId?: string, _signal?: AbortSignal): Promise { void _agentId; void _signal; return []; } async respondToApproval(_input: RespondApprovalInput, _signal?: AbortSignal): Promise { void _input; void _signal; throw new CrewError("contract_pending", "ACP approvals require the Cloud Agents approval contract"); } async getComputer(agentId: string, signal?: AbortSignal): Promise { const agent = await this.getAgent(agentId, signal); return { id: agent.computerId, agentId, runtimeName: agent.computerId, status: agent.status === "offline" ? "offline" : "online", capabilities: ["open", "takeover"] }; } - async openComputer(_agentId: string, _signal?: AbortSignal): Promise<{ url: string; mode: "remote" }> { void _agentId; void _signal; throw new CrewError("contract_pending", "Computer sessions are outside the current Crew scope"); } + async openComputer(agentId: string, _signal?: AbortSignal): Promise { + void _signal; + const session = await this.request({ method: "POST", path: `/v2/agents/${encodeURIComponent(agentId)}/computer-sessions` }); + return { url: session.channels.vnc.websocket_url, protocols: session.channels.vnc.protocols, mode: "remote" }; + } async takeOverComputer(agentId: string, signal?: AbortSignal) { return this.openComputer(agentId, signal); } async reconnect(signal?: AbortSignal) { await this.listAgents(signal); } getActivities(_conversationId: string) { void _conversationId; return []; } diff --git a/src/domain/CloudAgentsClient.ts b/src/domain/CloudAgentsClient.ts index b5474ab..1b691a3 100644 --- a/src/domain/CloudAgentsClient.ts +++ b/src/domain/CloudAgentsClient.ts @@ -1,7 +1,7 @@ -import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ModelProviderOption, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; +import type { Agent, ApprovalRequest, CloudComputer, CloudComputerSession, Conversation, ConversationEvent, CreateAgentInput, Message, ModelProviderCatalog, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; export interface CloudAgentsClient { - listModelProviders(signal?: AbortSignal): Promise; + listModelProviders(signal?: AbortSignal): Promise; listAgents(signal?: AbortSignal): Promise; getAgent(agentId: string, signal?: AbortSignal): Promise; createAgent(input: CreateAgentInput, signal?: AbortSignal): Promise; @@ -16,7 +16,7 @@ export interface CloudAgentsClient { listApprovalRequests(agentId?: string, signal?: AbortSignal): Promise; respondToApproval(input: RespondApprovalInput, signal?: AbortSignal): Promise; getComputer(agentId: string, signal?: AbortSignal): Promise; - openComputer(agentId: string, signal?: AbortSignal): Promise<{ url: string; mode: "remote" }>; - takeOverComputer(agentId: string, signal?: AbortSignal): Promise<{ url: string; mode: "remote" }>; + openComputer(agentId: string, signal?: AbortSignal): Promise; + takeOverComputer(agentId: string, signal?: AbortSignal): Promise; reconnect(signal?: AbortSignal): Promise; } diff --git a/src/domain/types.ts b/src/domain/types.ts index 0079783..88aa384 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -8,16 +8,18 @@ export interface Agent { } export interface TextPart { type: "text"; text: string } export interface ActivityPart { type: "activity"; activityId: string } -export interface Attachment { id: string; name: string; size: number; mediaType: string; source: "local-selection" | "cloud" } +export interface Attachment { id: string; name: string; size: number; mediaType: string; source: "local-selection" | "cloud"; agentId?: string } export interface AttachmentPart { type: "attachment"; attachment: Attachment } export type MessagePart = TextPart | ActivityPart | AttachmentPart; -export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean } +export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean; interrupted?: boolean } export interface Conversation { id: string; agentId: string; title: string; updatedAt: string } export type ActivityStatus = "running" | "completed" | "failed"; -export interface ActivityEvent { id: string; conversationId: string; kind: "browser" | "terminal" | "file" | "handoff" | "status"; title: string; detail: string; status: ActivityStatus; createdAt: string } +export interface ActivityEvent { id: string; conversationId: string; kind: "browser" | "terminal" | "file" | "handoff" | "status"; title: string; detail: string; output?: string; status: ActivityStatus; createdAt: string; updatedAt?: string } export interface ApprovalRequest { id: string; agentId: string; conversationId: string; title: string; description: string; scope: string[]; status: "pending" | "allowed" | "denied"; createdAt: string; responseNote?: string } export interface CloudComputer { id: string; agentId: string; runtimeName: string; status: "online" | "starting" | "offline"; activeApp?: string; previewUrl?: string; capabilities: Array<"open" | "takeover"> } +export interface CloudComputerSession { url: string; protocols: string[]; mode: "remote" } export interface ModelProviderOption { id: string; name: string; protocol: string; defaultModel?: string } +export interface ModelProviderCatalog { organizationId: string; providers: ModelProviderOption[] } export interface CreateAgentInput { name: string; modelProviderId: string } export interface UpdateAgentInput { name?: string; role?: string; goal?: string; pinned?: boolean } export interface SendMessageInput { conversationId: string; text: string; attachments?: Attachment[]; signal?: AbortSignal } @@ -33,5 +35,5 @@ export type ConversationEvent = export interface Subscription { unsubscribe(): void } export class CrewError extends Error { - constructor(public readonly code: "network" | "unauthorized" | "not_found" | "contract_pending" | "unknown", message: string, public readonly retryable = false) { super(message); this.name = "CrewError"; } + constructor(public readonly code: "network" | "unauthorized" | "not_found" | "conflict" | "contract_pending" | "unknown", message: string, public readonly retryable = false) { super(message); this.name = "CrewError"; } } diff --git a/src/novnc.d.ts b/src/novnc.d.ts new file mode 100644 index 0000000..6f8ec48 --- /dev/null +++ b/src/novnc.d.ts @@ -0,0 +1,9 @@ +declare module "@novnc/novnc/lib/rfb.js" { + export default class RFB extends EventTarget { + constructor(target: HTMLElement, url: string, options?: { shared?: boolean; wsProtocols?: string[] }); + scaleViewport: boolean; + resizeSession: boolean; + viewOnly: boolean; + disconnect(): void; + } +} diff --git a/src/shared/cloudStreamPath.test.ts b/src/shared/cloudStreamPath.test.ts new file mode 100644 index 0000000..d21f3d2 --- /dev/null +++ b/src/shared/cloudStreamPath.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { cloudRunEventsPath } from "./cloudStreamPath"; + +describe("cloudRunEventsPath", () => { + it("accepts only v2 Cloud Agent run event streams", () => { + expect(cloudRunEventsPath("/v2/agents/agent-1/runs/run-1/events?after=-1")).toBe("/v2/agents/agent-1/runs/run-1/events?after=-1"); + expect(cloudRunEventsPath("/v1/agents/agent-1/runs/run-1/events?after=-1")).toBeUndefined(); + expect(cloudRunEventsPath("/v2/agents/agent-1/runs/run-1/events?after=next")).toBeUndefined(); + expect(cloudRunEventsPath("https://example.com/v2/agents/agent-1/runs/run-1/events")).toBeUndefined(); + }); +}); diff --git a/src/shared/cloudStreamPath.ts b/src/shared/cloudStreamPath.ts new file mode 100644 index 0000000..8d69fcb --- /dev/null +++ b/src/shared/cloudStreamPath.ts @@ -0,0 +1,5 @@ +const CLOUD_RUN_EVENTS_PATH = /^\/v2\/agents\/[A-Za-z0-9._-]{1,160}\/runs\/[A-Za-z0-9._-]{1,160}\/events(?:\?after=-?\d+)?$/; + +export function cloudRunEventsPath(value: unknown): string | undefined { + return typeof value === "string" && CLOUD_RUN_EVENTS_PATH.test(value) ? value : undefined; +} diff --git a/src/shared/desktop.ts b/src/shared/desktop.ts index 400d837..0b100da 100644 --- a/src/shared/desktop.ts +++ b/src/shared/desktop.ts @@ -1,6 +1,7 @@ export type ThemePreference = "light" | "dark" | "system"; export type AppSettings = { endpoint: string; dashboardUrl?: string; theme: ThemePreference; notifications: boolean; modelProviderId?: string }; export interface SelectedAttachment { id: string; name: string; size: number; mediaType: string } +export interface AttachmentContent { name: string; mediaType: string; base64: string } export interface DesktopNotification { title: string; body: string } export interface CloudRequest { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; body?: unknown } export interface CloudResponse { status: number; body?: unknown } @@ -15,7 +16,7 @@ export interface DesktopBridge { credentials: { has(): Promise; set(token: string | null): Promise }; auth?: { start(): Promise; status(): Promise; logout(): Promise }; cloud?: { request(request: CloudRequest): Promise; subscribe(path: string, listener: (event: CloudStreamEvent) => void): () => void }; - attachments: { choose(): Promise }; + attachments: { choose(): Promise; addImage(image: AttachmentContent): Promise; read(id: string): Promise }; notifications: { show(notification: DesktopNotification): Promise; setBadge(count: number): Promise }; deepLinks: { onOpenAgent(listener: (agentId: string) => void): () => void }; } diff --git a/src/shared/runtaEndpoints.test.ts b/src/shared/runtaEndpoints.test.ts index 6911d3e..d3c46a9 100644 --- a/src/shared/runtaEndpoints.test.ts +++ b/src/shared/runtaEndpoints.test.ts @@ -10,21 +10,34 @@ import { normalizeRuntaDashboardUrl, } from "./runtaEndpoints"; -describe("Runta production endpoints", () => { +describe("Runta public endpoints", () => { it("uses the public Runta API and dashboard hosts", () => { - expect(DEFAULT_RUNTA_API_URL).toBe("https://api.runta.com"); - expect(DEFAULT_RUNTA_DASHBOARD_URL).toBe("https://dashboard.runta.com"); - expect(deviceAuthorizationUrl(DEFAULT_RUNTA_DASHBOARD_URL)).toBe("https://dashboard.runta.com/api/device/authorization"); - expect(deviceTokenUrl(DEFAULT_RUNTA_DASHBOARD_URL)).toBe("https://dashboard.runta.com/api/device/token"); - expect(deviceAuthorizationRequest("Runta Crew on darwin")).toEqual({ clientId: "runta_cli", deviceName: "Runta Crew on darwin" }); - expect(deviceTokenRequest("device-code")).toEqual({ deviceCode: "device-code" }); + expect(DEFAULT_RUNTA_API_URL).toBe("https://api.runta.me"); + expect(DEFAULT_RUNTA_DASHBOARD_URL).toBe("https://dashboard.runta.me"); + expect(deviceAuthorizationUrl(DEFAULT_RUNTA_API_URL)).toBe("https://api.runta.me/v2/auth/device/authorization"); + expect(deviceTokenUrl(DEFAULT_RUNTA_API_URL)).toBe("https://api.runta.me/v2/auth/device/token"); + expect(deviceAuthorizationRequest("Runta Crew on darwin", `${DEFAULT_RUNTA_DASHBOARD_URL}/`)).toEqual({ client_id: "runta_crew", device_name: "Runta Crew on darwin", app_url: "https://dashboard.runta.me" }); + expect(deviceTokenRequest("device-code")).toEqual({ device_code: "device-code" }); }); - it("migrates the retired Forge defaults without overriding custom development hosts", () => { - expect(normalizeRuntaApiUrl("https://api.forge")).toBe(DEFAULT_RUNTA_API_URL); - expect(normalizeRuntaApiUrl("https://app.forge/api/")).toBe(DEFAULT_RUNTA_API_URL); - expect(normalizeRuntaDashboardUrl("https://app.forge/")).toBe(DEFAULT_RUNTA_DASHBOARD_URL); + it("preserves explicit development hosts and normalizes the legacy API alias", () => { + expect(normalizeRuntaApiUrl("https://api.forge")).toBe("https://api.forge"); + expect(normalizeRuntaApiUrl("https://app.forge/api/")).toBe("https://api.forge"); + expect(normalizeRuntaDashboardUrl("https://app.forge/")).toBe("https://app.forge"); + expect(normalizeRuntaApiUrl(" https://api.runta.com/ ")).toBe("https://api.runta.com"); expect(normalizeRuntaApiUrl("http://127.0.0.1:8080/")).toBe("http://127.0.0.1:8080"); expect(normalizeRuntaDashboardUrl("http://127.0.0.1:5173/")).toBe("http://127.0.0.1:5173"); }); + + it("uses the public defaults for missing or empty configuration", () => { + for (const value of [undefined, "", " "]) { + expect(normalizeRuntaApiUrl(value)).toBe(DEFAULT_RUNTA_API_URL); + expect(normalizeRuntaDashboardUrl(value)).toBe(DEFAULT_RUNTA_DASHBOARD_URL); + } + }); + + it("retains a configured API path prefix for both device requests", () => { + expect(deviceAuthorizationUrl("https://example.test/api/")).toBe("https://example.test/api/v2/auth/device/authorization"); + expect(deviceTokenUrl("https://example.test/api/")).toBe("https://example.test/api/v2/auth/device/token"); + }); }); diff --git a/src/shared/runtaEndpoints.ts b/src/shared/runtaEndpoints.ts index d56fbbb..37b674e 100644 --- a/src/shared/runtaEndpoints.ts +++ b/src/shared/runtaEndpoints.ts @@ -1,37 +1,29 @@ -export const DEFAULT_RUNTA_API_URL = "https://api.runta.com"; -export const DEFAULT_RUNTA_DASHBOARD_URL = "https://dashboard.runta.com"; - -const LEGACY_RUNTA_ENDPOINTS = new Set([ - "https://api.forge", - "https://app.forge/api", -]); - -const LEGACY_RUNTA_DASHBOARDS = new Set([ - "https://app.forge", -]); +export const DEFAULT_RUNTA_API_URL = "https://api.runta.me"; +export const DEFAULT_RUNTA_DASHBOARD_URL = "https://dashboard.runta.me"; export function normalizeRuntaApiUrl(value: string | undefined): string { const configured = value?.trim().replace(/\/+$/, "") ?? ""; - return !configured || LEGACY_RUNTA_ENDPOINTS.has(configured) ? DEFAULT_RUNTA_API_URL : configured; + if (configured === "https://app.forge/api") return "https://api.forge"; + return configured || DEFAULT_RUNTA_API_URL; } export function normalizeRuntaDashboardUrl(value: string | undefined): string { const configured = value?.trim().replace(/\/+$/, "") ?? ""; - return !configured || LEGACY_RUNTA_DASHBOARDS.has(configured) ? DEFAULT_RUNTA_DASHBOARD_URL : configured; + return configured || DEFAULT_RUNTA_DASHBOARD_URL; } -export function deviceAuthorizationUrl(dashboardUrl: string): string { - return new URL("api/device/authorization", `${dashboardUrl.replace(/\/+$/, "")}/`).toString(); +export function deviceAuthorizationUrl(apiUrl: string): string { + return new URL("v2/auth/device/authorization", `${apiUrl.replace(/\/+$/, "")}/`).toString(); } -export function deviceTokenUrl(dashboardUrl: string): string { - return new URL("api/device/token", `${dashboardUrl.replace(/\/+$/, "")}/`).toString(); +export function deviceTokenUrl(apiUrl: string): string { + return new URL("v2/auth/device/token", `${apiUrl.replace(/\/+$/, "")}/`).toString(); } -export function deviceAuthorizationRequest(deviceName: string) { - return { clientId: "runta_cli" as const, deviceName }; +export function deviceAuthorizationRequest(deviceName: string, dashboardUrl: string) { + return { client_id: "runta_crew" as const, device_name: deviceName, app_url: dashboardUrl.replace(/\/+$/, "") }; } export function deviceTokenRequest(deviceCode: string) { - return { deviceCode }; + return { device_code: deviceCode }; } diff --git a/src/state/useCrewController.test.tsx b/src/state/useCrewController.test.tsx index 31f8b42..722c3da 100644 --- a/src/state/useCrewController.test.tsx +++ b/src/state/useCrewController.test.tsx @@ -1,12 +1,24 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import type { Agent, ConversationEvent, Message } from "@/domain/types"; +import type { Agent, Attachment, ConversationEvent, Message } from "@/domain/types"; import { useCrewController } from "./useCrewController"; const agent = (id: string, name: string): Agent => ({ id, name, role: "Cloud coding agent", goal: name, status: "idle", avatar: name[0]!, lastActiveAt: new Date(0).toISOString(), unreadCount: 0, computerId: id }); describe("useCrewController", () => { + it("refreshes the model-provider catalog on demand", async () => { + let providers = [] as Awaited>["providers"]; + const listModelProviders = vi.fn(async () => ({ organizationId: "org-test", providers })); + const client = { listModelProviders, listAgents: async () => [] } as unknown as CloudAgentsClient; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + providers = [{ id: "provider-1", name: "OpenAI", protocol: "openai_responses" }]; + await act(async () => { await result.current.refreshModelProviders(); }); + expect(result.current.modelProviders).toEqual(providers); + expect(listModelProviders).toHaveBeenCalledTimes(2); + }); + it("does not start Cloud Agents requests until authentication is enabled", async () => { const listAgents = vi.fn(); const listModelProviders = vi.fn(); const client = { listAgents, listModelProviders } as unknown as CloudAgentsClient; @@ -22,14 +34,14 @@ describe("useCrewController", () => { const atlasConversation = new Promise[0]>((resolve) => { resolveAtlas = resolve; }); const listeners = new Map void>(); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => id === "conversation-atlas" ? atlasConversation : { conversation: { id, agentId: "scout", title: "Scout", updatedAt: new Date(0).toISOString() }, messages: [] }, sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, - getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); @@ -46,10 +58,10 @@ describe("useCrewController", () => { it("restores a fresh per-agent snapshot without fetching the conversation again", async () => { const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: id.endsWith("atlas") ? "atlas" : "scout", title: id, updatedAt: new Date(0).toISOString() }, messages: [{ id: `${id}-message`, conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: id.endsWith("atlas") ? "Atlas cached" : "Scout cached" }], createdAt: new Date(0).toISOString() }] })); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation, - sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Atlas cached" })); @@ -66,69 +78,121 @@ describe("useCrewController", () => { const sendMessage = vi.fn(async () => pendingSend); const listeners = new Map void>(); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: "Old server reply" }], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: "Old server reply" }], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [] }), - sendMessage, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + sendMessage, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); await waitFor(() => expect(listeners.has("conversation-atlas")).toBe(true)); + const attachment: Attachment = { id: "image-1", name: "screen.png", size: 3, mediaType: "image/png", source: "local-selection" }; let send!: Promise; - act(() => { send = result.current.sendMessage("hello"); }); + act(() => { send = result.current.sendMessage("hello", [attachment]); }); expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toMatchObject({ role: "user", parts: [{ type: "text", text: "hello" }] }); + expect(result.current.messages[0]).toMatchObject({ role: "user", parts: [{ type: "text", text: "hello" }, { type: "attachment", attachment }] }); expect(result.current.messages[1]).toMatchObject({ role: "agent", streaming: true }); const visualIds = result.current.messages.map((message) => message.id); - await act(async () => { await result.current.sendMessage("hello"); }); + const workingStartedAt = result.current.messages[1]?.createdAt; expect(sendMessage).toHaveBeenCalledTimes(1); act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:user", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }], createdAt: new Date(0).toISOString() } })); expect(result.current.messages).toHaveLength(2); expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + expect(result.current.messages[0]?.parts).toEqual([{ type: "text", text: "hello" }, { type: "attachment", attachment }]); - await act(async () => { resolveSend({ id: "run-1:user", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }], createdAt: new Date(0).toISOString() }); await send; }); + const cloudAttachment: Attachment = { ...attachment, id: "artifact-1", source: "cloud", agentId: "atlas" }; + await act(async () => { resolveSend({ id: "run-1:user:response", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }, { type: "attachment", attachment: cloudAttachment }], createdAt: new Date(0).toISOString() }); await send; }); + expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:user", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }], createdAt: new Date(0).toISOString() } })); expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + expect(result.current.messages[0]?.parts).toEqual([{ type: "text", text: "hello" }, { type: "attachment", attachment: cloudAttachment }]); act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Hi" }], createdAt: new Date(0).toISOString(), streaming: true } })); expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); expect(result.current.messages[1]?.parts).toEqual([{ type: "text", text: "Hi" }]); + expect(result.current.messages[1]?.createdAt).toBe(workingStartedAt); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent:first", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Checking" }], createdAt: new Date(0).toISOString(), streaming: true } })); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1]).toMatchObject({ id: visualIds[1], parts: [{ type: "text", text: "Checking" }], streaming: true }); + expect(result.current.messages[1]?.createdAt).toBe(workingStartedAt); await act(async () => { await result.current.reconnect(); }); expect(result.current.agents[0]?.lastMessagePreview).toBe("Old server reply"); - act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: false })); - expect(result.current.messages).toHaveLength(1); - act(() => listeners.get("conversation-atlas")?.({ type: "message.delta", messageId: "run-1:agent", delta: "Final answer after the tool" })); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent:first", notify: false })); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1]).toMatchObject({ id: visualIds[1], parts: [{ type: "text", text: "" }], streaming: true }); + act(() => listeners.get("conversation-atlas")?.({ type: "message.delta", messageId: "run-1:agent:second", delta: "Final answer after the tool" })); expect(result.current.messages[1]).toMatchObject({ id: visualIds[1], parts: [{ type: "text", text: "Final answer after the tool" }], streaming: true }); expect(result.current.agents[0]?.lastMessagePreview).toBe("Old server reply"); - act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: true })); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "stale-working", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date(0).toISOString(), streaming: true } })); + expect(result.current.messages.filter((message) => message.role === "agent" && message.streaming)).toHaveLength(2); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent:second", notify: true })); expect(result.current.messages[1]).toMatchObject({ streaming: false }); + expect(result.current.messages.filter((message) => message.role === "agent" && message.streaming)).toEqual([]); expect(result.current.agents[0]?.lastMessagePreview).toBe("Final answer after the tool"); - act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent:second", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Final answer" }], createdAt: new Date(1).toISOString(), streaming: true } })); - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[2]).toMatchObject({ id: "run-1:agent:second", parts: [{ type: "text", text: "Final answer" }], streaming: true }); + act(() => { + listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-2:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "I'm" }], createdAt: new Date(0).toISOString(), streaming: true } }); + listeners.get("conversation-atlas")?.({ type: "message.delta", messageId: "run-2:agent", delta: " Atlas, ready to help." }); + listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-2:agent", notify: true }); + }); + expect(result.current.agents[0]?.lastMessagePreview).toBe("I'm Atlas, ready to help."); + }); + + it("keeps a steering message visible when delivery acknowledgement fails", async () => { + const client: CloudAgentsClient = { + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [{ id: "working", conversationId: id, role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date(0).toISOString(), streaming: true }] }), + sendMessage: async () => { throw new Error("Steer acknowledgement failed"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: [] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: [] }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + await waitFor(() => expect(result.current.messages[0]?.id).toBe("working")); + let failure: unknown; + await act(async () => { try { await result.current.sendMessage("steer now"); } catch (reason) { failure = reason; } }); + expect(failure).toEqual(expect.objectContaining({ message: "Steer acknowledgement failed" })); + expect(result.current.messages).toEqual(expect.arrayContaining([expect.objectContaining({ role: "user", parts: [{ type: "text", text: "steer now" }] })])); }); - it("treats a newly created agent as a known empty conversation", async () => { + it("focuses a newly created agent with its greeting already hydrated", async () => { let created = false; const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => created ? [agent("new-agent", "Atlas")] : [], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => created ? [agent("new-agent", "Atlas")] : [], getAgent: async (id) => agent(id, id), createAgent: async (input) => { created = true; return agent("new-agent", input.name); }, updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), - listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.createAgent({ name: "Atlas", modelProviderId: "provider" }); }); - expect(result.current.selectedAgentId).toBe("new-agent"); + expect(result.current.selectedAgentId).toBe(""); expect(result.current.conversationLoading).toBe(false); expect(result.current.messages).toEqual([]); + const greeting: Message = { id: "greeting", conversationId: "conversation-new-agent", role: "agent", parts: [{ type: "text", text: "I am Atlas." }], createdAt: new Date().toISOString() }; + act(() => result.current.focusAgentWithMessages("new-agent", [greeting])); + expect(result.current.selectedAgentId).toBe("new-agent"); + expect(result.current.conversationLoading).toBe(false); + expect(result.current.messages).toEqual([greeting]); + expect(result.current.agents.find((item) => item.id === "new-agent")?.lastMessagePreview).toBe("I am Atlas."); + }); + + it("keeps checking computer readiness until VNC becomes available", async () => { + const getComputer = vi.fn(async (id: string) => ({ id, agentId: id, runtimeName: id, status: getComputer.mock.calls.length > 1 ? "online" as const : "offline" as const, capabilities: ["open" as const] })); + const client: CloudAgentsClient = { + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer, openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.computer?.status).toBe("online")); + expect(getComputer.mock.calls.length).toBeGreaterThanOrEqual(2); }); it("removes a deleted agent immediately and rolls back when deletion fails", async () => { let rejectDelete!: (reason: Error) => void; const pendingDelete = new Promise((_resolve, reject) => { rejectDelete = reject; }); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => pendingDelete, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), - listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); @@ -146,9 +210,9 @@ describe("useCrewController", () => { it("keeps an accepted deletion tombstoned until the server list confirms removal", async () => { let serverAgents = [agent("atlas", "Atlas"), agent("scout", "Scout")]; const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => serverAgents, + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => serverAgents, getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), - listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); @@ -165,9 +229,9 @@ describe("useCrewController", () => { const server = { latestReply: undefined as string | undefined }; const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date().toISOString() }, messages: server.latestReply ? [{ id: "reply", conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: server.latestReply }], createdAt: new Date().toISOString() }] : [] })); const client: CloudAgentsClient = { - listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: server.latestReply }], + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: server.latestReply }], getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), - listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date().toISOString() }], getConversation, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date().toISOString() }], getConversation, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, }; const { result } = renderHook(() => useCrewController(client)); await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index a5018d0..5a88af8 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -15,6 +15,13 @@ function messageText(message: Message): string { return message.parts.filter((part) => part.type === "text").map((part) => part.text).join(""); } +function mergeVisualMessage(canonical: Message, visual?: Message): Message { + if (!visual) return canonical; + const canonicalAttachmentIds = new Set(canonical.parts.filter((part) => part.type === "attachment").map((part) => part.attachment.id)); + const retainedAttachments = visual.parts.filter((part) => part.type === "attachment" && !canonicalAttachmentIds.has(part.attachment.id)); + return { ...canonical, id: visual.id, createdAt: visual.createdAt, parts: [...canonical.parts, ...retainedAttachments] }; +} + interface AgentSnapshot { messages: Message[]; approvals: ApprovalRequest[]; computer?: CloudComputer; cachedAt: number } const SNAPSHOT_TTL_MS = 60_000; const AGENT_FALLBACK_REFRESH_MS = 30_000; @@ -27,16 +34,17 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const [messages, setMessages] = useState([]); const [activities, setActivities] = useState([]); const [approvals, setApprovals] = useState([]); const [computer, setComputer] = useState(); const [connection, setConnection] = useState("connecting"); const [modelProviders, setModelProviders] = useState([]); + const [organizationId, setOrganizationId] = useState(""); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(); const [loadedAgentIds, setLoadedAgentIds] = useState>(() => new Set()); const [liveAgentId, setLiveAgentId] = useState(""); const [revalidateVersion, setRevalidateVersion] = useState(0); - const snapshots = useRef(new Map()); const selectedAgentIdRef = useRef(selectedAgentId); const deletingAgentIds = useRef(new Set()); const sendingAgentIds = useRef(new Set()); const visualMessageIds = useRef(new Map()); + const snapshots = useRef(new Map()); const selectedAgentIdRef = useRef(selectedAgentId); const deletingAgentIds = useRef(new Set()); const sendingAgentRequests = useRef(new Map()); const visualMessageIds = useRef(new Map()); const selectedAgent = useMemo(() => agents.find((agent) => agent.id === selectedAgentId), [agents, selectedAgentId]); const conversationId = selectedAgentId ? `conversation-${selectedAgentId}` : ""; const conversationLoading = Boolean(selectedAgentId && !loadedAgentIds.has(selectedAgentId)); useEffect(() => { selectedAgentIdRef.current = selectedAgentId; }, [selectedAgentId]); - const refreshAgents = useCallback(async () => { + const refreshAgents = useCallback(async (preserveSelection = false) => { const fetched = await client.listAgents(); for (const agentId of deletingAgentIds.current) if (!fetched.some((agent) => agent.id === agentId)) deletingAgentIds.current.delete(agentId); const next = fetched.filter((agent) => !deletingAgentIds.current.has(agent.id)); @@ -52,14 +60,19 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const preserveLocalPreview = Boolean(localPreview) || (agent.id === selectedId && selectedIsStreaming); return { ...agent, lastMessagePreview: preserveLocalPreview ? localPreview || existing?.lastMessagePreview : agent.lastMessagePreview ?? existing?.lastMessagePreview }; })); - setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : next[0]?.id || ""); return next; + setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : preserveSelection ? current : next[0]?.id || ""); return next; + }, [client]); + const refreshModelProviders = useCallback(async () => { + const catalog = await client.listModelProviders(); + setModelProviders(catalog.providers); setOrganizationId(catalog.organizationId); + return catalog.providers; }, [client]); useEffect(() => { - if (!enabled) { setAgents([]); setModelProviders([]); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } + if (!enabled) { setAgents([]); setModelProviders([]); setOrganizationId(""); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } let alive = true; setLoading(true); - void Promise.all([refreshAgents(), client.listModelProviders()]).then(([, providers]) => { if (alive) { setModelProviders(providers); setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); + void Promise.all([refreshAgents(), refreshModelProviders()]).then(() => { if (alive) { setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); return () => { alive = false; }; - }, [client, enabled, refreshAgents]); + }, [enabled, refreshAgents, refreshModelProviders]); useEffect(() => { if (!enabled) return; const refreshWhenActive = () => { @@ -89,7 +102,15 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { Promise.all([client.listConversations(selectedAgentId, controller.signal), client.listApprovalRequests(selectedAgentId, controller.signal), client.getComputer(selectedAgentId, controller.signal)]).then(async ([conversations, nextApprovals, nextComputer]) => { const conversation = conversations[0]; const data = conversation ? await client.getConversation(conversation.id, controller.signal) : undefined; if (alive && !deletingAgentIds.current.has(selectedAgentId)) { - const nextMessages = (data?.messages ?? []).map((message) => { const visualId = visualMessageIds.current.get(message.id); return visualId ? { ...message, id: visualId } : message; }); + const snapshotMessages = snapshots.current.get(selectedAgentId)?.messages ?? []; + const hydratedMessages = (data?.messages ?? []).map((message) => { + const visualId = visualMessageIds.current.get(message.id); + if (!visualId) return message; + const visualMessage = snapshotMessages.find((current) => current.id === visualId); + return mergeVisualMessage({ ...message, id: visualId }, visualMessage); + }); + const optimisticMessages = snapshotMessages.filter((message) => message.id.startsWith(OPTIMISTIC_USER_PREFIX) || message.id.startsWith(OPTIMISTIC_AGENT_PREFIX)); + const nextMessages = [...hydratedMessages, ...optimisticMessages.filter((message) => !hydratedMessages.some((item) => item.id === message.id))]; const preview = latestCompletedAgentPreview(nextMessages); snapshots.current.set(selectedAgentId, { messages: nextMessages, approvals: nextApprovals, computer: nextComputer, cachedAt: Date.now() }); setLoadedAgentIds((current) => new Set(current).add(selectedAgentId)); @@ -100,6 +121,22 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { }).catch((reason: unknown) => { if (alive && !deletingAgentIds.current.has(selectedAgentId) && !(reason instanceof DOMException && reason.name === "AbortError")) { snapshots.current.set(selectedAgentId, { messages: [], approvals: [], cachedAt: Date.now() }); setLoadedAgentIds((current) => new Set(current).add(selectedAgentId)); setMessages([]); setError(reason instanceof Error ? reason.message : "Could not load agent"); } }); return () => { alive = false; controller.abort(); }; }, [client, enabled, revalidateVersion, selectedAgentId]); + useEffect(() => { + if (!enabled || !selectedAgentId || computer?.status === "online") return; + let active = true; + const refreshComputer = async () => { + try { + const nextComputer = await client.getComputer(selectedAgentId); + if (!active || selectedAgentIdRef.current !== selectedAgentId) return; + setComputer(nextComputer); + const snapshot = snapshots.current.get(selectedAgentId); + snapshots.current.set(selectedAgentId, { messages: snapshot?.messages ?? [], approvals: snapshot?.approvals ?? [], computer: nextComputer, cachedAt: Date.now() }); + } catch { /* Availability errors remain represented by the normal connection state. */ } + }; + const timer = window.setInterval(() => { void refreshComputer(); }, 2_000); + void refreshComputer(); + return () => { active = false; window.clearInterval(timer); }; + }, [client, computer?.status, enabled, selectedAgentId]); useEffect(() => { if (!enabled || !conversationId || liveAgentId !== selectedAgentId) return; let active = true; const updateMessages = (updater: (current: Message[]) => Message[]) => setMessages((current) => { @@ -111,10 +148,19 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { if (!active) return; if (event.type === "message.created") { updateMessages((current) => { - let nextMessage = event.message; const knownVisualId = visualMessageIds.current.get(event.message.id); + let nextMessage = event.message; let knownVisualId = visualMessageIds.current.get(event.message.id); + if (!knownVisualId && event.message.role === "agent") { + const canonicalRunMessageId = event.message.id.replace(/:agent:.+$/, ":agent"); + const canonicalVisualId = canonicalRunMessageId === event.message.id ? undefined : visualMessageIds.current.get(canonicalRunMessageId); + if (canonicalVisualId) { + knownVisualId = canonicalVisualId; + visualMessageIds.current.set(event.message.id, canonicalVisualId); + } + } if (knownVisualId) nextMessage = { ...event.message, id: knownVisualId }; - if (event.message.role === "user" && sendingAgentIds.current.has(selectedAgentId)) { - const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_USER_PREFIX) && messageText(message) === messageText(event.message)); + if (event.message.role === "user") { + const claimedVisualIds = new Set(visualMessageIds.current.values()); + const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_USER_PREFIX) && !claimedVisualIds.has(message.id) && messageText(message) === messageText(event.message)); if (optimistic) { visualMessageIds.current.set(event.message.id, optimistic.id); nextMessage = { ...event.message, id: optimistic.id }; } } if (event.message.role === "agent" && !knownVisualId) { @@ -122,12 +168,21 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualMessageIds.current.set(event.message.id, optimistic.id); nextMessage = { ...event.message, id: optimistic.id }; } } - return current.some((message) => message.id === nextMessage.id) ? current.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...current, nextMessage]; + const visualMessage = current.find((message) => message.id === nextMessage.id); + if (visualMessage) nextMessage = mergeVisualMessage(nextMessage, visualMessage); + return visualMessage ? current.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...current, nextMessage]; }); } if (event.type === "message.delta") { updateMessages((current) => { let visualId = visualMessageIds.current.get(event.messageId); + if (!visualId) { + const canonicalRunMessageId = event.messageId.replace(/:agent:.+$/, ":agent"); + if (canonicalRunMessageId !== event.messageId) { + visualId = visualMessageIds.current.get(canonicalRunMessageId); + if (visualId) visualMessageIds.current.set(event.messageId, visualId); + } + } if (!visualId) { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.messageId, visualId); } } visualId ??= event.messageId; if (!current.some((message) => message.id === visualId)) return [...current, { id: visualId, conversationId, role: "agent", parts: [{ type: "text", text: event.delta }], createdAt: new Date().toISOString(), streaming: true }]; @@ -137,17 +192,23 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { if (event.type === "message.completed") { const visualId = visualMessageIds.current.get(event.messageId) ?? event.messageId; if (event.notify === false) { - updateMessages((current) => current.filter((message) => message.id !== visualId)); + updateMessages((current) => current.flatMap((message) => { + if (message.id !== visualId) return [message]; + if (!message.id.startsWith(OPTIMISTIC_AGENT_PREFIX)) return []; + return [{ ...message, parts: message.parts.map((part) => part.type === "text" ? { ...part, text: "" } : part), streaming: true }]; + })); } else { - updateMessages((current) => current.map((message) => message.id === visualId ? { ...message, streaming: false } : message)); - const completedMessages = (snapshots.current.get(selectedAgentId)?.messages ?? []).map((message) => message.id === visualId ? { ...message, streaming: false } : message); - const completedPreview = latestCompletedAgentPreview(completedMessages); - if (completedPreview) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: completedPreview } : agent)); + updateMessages((current) => { + const completed = current.filter((message) => message.id === visualId || message.role !== "agent" || !message.streaming).map((message) => message.id === visualId ? { ...message, streaming: false } : message); + const completedPreview = latestCompletedAgentPreview(completed); + if (completedPreview) setAgents((agents) => agents.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: completedPreview } : agent)); + return completed; + }); void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} finished`, body: "New work is ready to review in Runta Crew." }); } } if (event.type === "message.updated") { - updateMessages((current) => { let visualId = visualMessageIds.current.get(event.message.id); if (!visualId && event.message.role === "agent") { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.message.id, visualId); } } const nextMessage = visualId ? { ...event.message, id: visualId } : event.message; return current.some((message) => message.id === nextMessage.id) ? current.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...current, nextMessage]; }); + updateMessages((current) => { let visualId = visualMessageIds.current.get(event.message.id); if (!visualId && event.message.role === "agent") { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.message.id, visualId); } } let nextMessage = visualId ? { ...event.message, id: visualId } : event.message; const visualMessage = current.find((message) => message.id === nextMessage.id); if (visualMessage) nextMessage = mergeVisualMessage(nextMessage, visualMessage); const settled = event.message.role === "agent" && !event.message.streaming ? current.filter((message) => message.id === nextMessage.id || message.role !== "agent" || !message.streaming) : current; return visualMessage ? settled.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...settled, nextMessage]; }); if (event.message.role === "agent" && !event.message.streaming) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: agentMessagePreview(event.message) || undefined } : agent)); } if (event.type === "approval.updated") { setApprovals((current) => { const next = current.map((approval) => approval.id === event.approval.id ? event.approval : approval); const snapshot = snapshots.current.get(selectedAgentId); snapshots.current.set(selectedAgentId, { messages: snapshot?.messages ?? [], approvals: next, computer: snapshot?.computer, cachedAt: Date.now() }); return next; }); if (event.approval.status === "pending") void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} needs approval`, body: event.approval.title }); } @@ -157,9 +218,10 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { }, [client, conversationId, enabled, liveAgentId, selectedAgent?.name, selectedAgentId]); return { - agents, modelProviders, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, + agents, modelProviders, organizationId, refreshModelProviders, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, + focusAgentWithMessages: (agentId: string, initialMessages: Message[]) => { const preview = latestCompletedAgentPreview(initialMessages); snapshots.current.set(agentId, { messages: initialMessages, approvals: [], cachedAt: Date.now() }); setLoadedAgentIds((current) => new Set(current).add(agentId)); if (preview) setAgents((current) => current.map((agent) => agent.id === agentId ? { ...agent, lastMessagePreview: preview } : agent)); setSelectedAgentId(agentId); }, dismissError: () => setError(undefined), - createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); snapshots.current.set(agent.id, { messages: [], approvals: [], cachedAt: 0 }); setLoadedAgentIds((current) => new Set(current).add(agent.id)); await refreshAgents(); setSelectedAgentId(agent.id); return agent; }, + createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); await refreshAgents(true); return agent; }, updateAgent: async (agentId: string, input: UpdateAgentInput) => { await client.updateAgent(agentId, input); await refreshAgents(); }, deleteAgent: async (agentId: string) => { const removedAgent = agents.find((agent) => agent.id === agentId); const previousSelectedAgentId = selectedAgentId; @@ -186,19 +248,25 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { sendMessage: async (text: string, attachments: Attachment[] = []) => { if (!conversationId || !selectedAgentId) return; const targetAgentId = selectedAgentId; const targetConversationId = conversationId; const nonce = `${Date.now()}:${Math.random().toString(36).slice(2)}`; - if (sendingAgentIds.current.has(targetAgentId)) return; - sendingAgentIds.current.add(targetAgentId); + sendingAgentRequests.current.set(targetAgentId, (sendingAgentRequests.current.get(targetAgentId) ?? 0) + 1); const optimisticUserId = `${OPTIMISTIC_USER_PREFIX}${nonce}`; const optimisticAgentId = `${OPTIMISTIC_AGENT_PREFIX}${nonce}`; const createdAt = new Date().toISOString(); const optimisticUser: Message = { id: optimisticUserId, conversationId: targetConversationId, role: "user", parts: [...(text ? [{ type: "text" as const, text }] : []), ...attachments.map((attachment) => ({ type: "attachment" as const, attachment }))], createdAt }; const optimisticAgent: Message = { id: optimisticAgentId, conversationId: targetConversationId, role: "agent", parts: [{ type: "text", text: "" }], createdAt, streaming: true }; const snapshot = snapshots.current.get(targetAgentId) ?? { messages: [], approvals: [], cachedAt: Date.now() }; - const optimisticMessages = [...snapshot.messages, optimisticUser, optimisticAgent]; + const existingWorking = [...snapshot.messages].reverse().find((message) => message.role === "agent" && message.streaming); + const interruptedMessages = existingWorking ? snapshot.messages.map((message) => message.id === existingWorking.id ? { ...message, streaming: false, interrupted: true } : message) : snapshot.messages; + const optimisticMessages = [...interruptedMessages, optimisticUser, optimisticAgent]; + if (selectedAgentIdRef.current === targetAgentId) setActivities([]); snapshots.current.set(targetAgentId, { ...snapshot, messages: optimisticMessages, cachedAt: Date.now() }); if (selectedAgentIdRef.current === targetAgentId) { setMessages(optimisticMessages); setLiveAgentId(targetAgentId); } try { const message = await client.sendMessage({ conversationId: targetConversationId, text, attachments }); visualMessageIds.current.set(message.id, optimisticUserId); - if (message.id.endsWith(":user")) visualMessageIds.current.set(`${message.id.slice(0, -":user".length)}:agent`, optimisticAgentId); + const runId = message.id.match(/^(.+):user(?:$|:)/)?.[1]; + if (runId) { + visualMessageIds.current.set(`${runId}:user`, optimisticUserId); + visualMessageIds.current.set(`${runId}:agent`, existingWorking?.id ?? optimisticAgentId); + } const latest = snapshots.current.get(targetAgentId) ?? snapshot; const visualMessage = { ...message, id: optimisticUserId }; const reconciled = latest.messages.map((item) => item.id === optimisticUserId ? visualMessage : item).filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index); @@ -206,13 +274,16 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { if (selectedAgentIdRef.current === targetAgentId) setMessages(reconciled); } catch (reason) { const latest = snapshots.current.get(targetAgentId) ?? snapshot; - const rolledBack = latest.messages.filter((message) => message.id !== optimisticUserId && message.id !== optimisticAgentId); - snapshots.current.set(targetAgentId, { ...latest, messages: rolledBack, cachedAt: Date.now() }); - if (selectedAgentIdRef.current === targetAgentId) setMessages(rolledBack); - setError(reason instanceof Error ? reason.message : "Could not send message"); + const withoutEmptyAgent = latest.messages.filter((message) => message.id !== optimisticAgentId); + const retained = withoutEmptyAgent.some((message) => message.id === optimisticUserId) ? withoutEmptyAgent : [...withoutEmptyAgent, optimisticUser]; + snapshots.current.set(targetAgentId, { ...latest, messages: retained, cachedAt: Date.now() }); + if (selectedAgentIdRef.current === targetAgentId) setMessages(retained); + if (!existingWorking) setError(reason instanceof Error ? reason.message : "Could not send message"); throw reason; } finally { - sendingAgentIds.current.delete(targetAgentId); + const pending = (sendingAgentRequests.current.get(targetAgentId) ?? 1) - 1; + if (pending > 0) sendingAgentRequests.current.set(targetAgentId, pending); + else sendingAgentRequests.current.delete(targetAgentId); } }, respondToApproval: async (requestId: string, decision: "allow" | "deny", note?: string) => { const targetAgentId = selectedAgentId; const next = await client.respondToApproval({ requestId, decision, note }); const snapshot = snapshots.current.get(targetAgentId); const nextApprovals = (snapshot?.approvals ?? []).map((item) => item.id === next.id ? next : item); if (snapshot) snapshots.current.set(targetAgentId, { ...snapshot, approvals: nextApprovals, cachedAt: Date.now() }); if (selectedAgentIdRef.current === targetAgentId) setApprovals(nextApprovals); }, diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx index 990edee..98304de 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -6,9 +6,34 @@ import { accountDisplayName } from "./accountDisplayName"; import { nextAgentName } from "@/domain/agentName"; import { AgentList } from "./components/AgentList"; import { LoginPage } from "./components/LoginPage"; -import type { DesktopBridge } from "@/shared/desktop"; +import { SettingsDialog } from "./components/Dialogs"; +import type { AppSettings, DesktopBridge } from "@/shared/desktop"; describe("Runta Crew authentication surfaces", () => { + it("opens the dashboard add-provider page when no providers exist", async () => { + const openExternal = vi.fn(async () => undefined); const user = userEvent.setup(); + window.runtaCrew = { + openExternal, + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings: AppSettings) => settings }, + } as unknown as DesktopBridge; + render( undefined} />); + await user.click(screen.getByRole("button", { name: "Add model provider" })); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(openExternal).toHaveBeenCalledWith("https://app.forge/org/org-a%2Fb/secrets/providers/new"); + delete window.runtaCrew; + }); + + it("notifies when the model-provider picker opens", async () => { + const onModelProviderOpen = vi.fn(); const user = userEvent.setup(); + window.runtaCrew = { + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings: AppSettings) => settings }, + } as unknown as DesktopBridge; + render( undefined} />); + await user.click(screen.getByRole("button", { name: "Add model provider" })); + expect(onModelProviderOpen).toHaveBeenCalledOnce(); + delete window.runtaCrew; + }); + it("adapts the landing copy to whether agents exist", () => { const { rerender } = render(); expect(screen.getByText("Create your first agent to get started.")).toBeInTheDocument(); @@ -34,7 +59,7 @@ describe("Runta Crew authentication surfaces", () => { const bridge: DesktopBridge = { getVersion: async () => "0.1.0", openExternal: async (url) => { opened.push(url); }, settings: { get: async () => ({ endpoint: "", theme: "light", notifications: true }), set: async (settings) => settings }, - credentials: { has: async () => false, set: async () => true }, attachments: { choose: async () => [] }, + credentials: { has: async () => false, set: async () => true }, attachments: { choose: async () => [], addImage: async (image) => ({ id: "image", name: image.name, size: 0, mediaType: image.mediaType }), read: async () => ({ name: "test.txt", mediaType: "text/plain", base64: "" }) }, notifications: { show: async () => true, setBadge: async () => undefined }, deepLinks: { onOpenAgent: () => () => undefined }, }; @@ -52,9 +77,11 @@ describe("Runta Crew authentication surfaces", () => { }); it("shows immediate feedback while an agent is being created", () => { - render( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); - expect(screen.getByRole("status")).toHaveTextContent("AtlasCreating…"); + const { rerender } = render( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); + expect(screen.getByRole("status", { name: "Atlas is being created" })).toHaveTextContent("AtlasCreating…"); expect(screen.getByRole("button", { name: "New agent" })).toBeDisabled(); + rerender( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); + expect(screen.getByRole("status", { name: "Atlas is typing" })).toBeInTheDocument(); }); it("does not duplicate a creating row when polling sees the new server agent first", () => { @@ -93,7 +120,7 @@ describe("Runta Crew authentication surfaces", () => { const bridge: DesktopBridge = { getVersion: async () => "0.1.0", openExternal: async () => undefined, settings: { get: async () => ({ endpoint: "https://api.runta.com", dashboardUrl: "https://dashboard.runta.com", theme: "light", notifications: true }), set: async (settings) => settings }, - credentials: { has: async () => false, set: async () => false }, attachments: { choose: async () => [] }, + credentials: { has: async () => false, set: async () => false }, attachments: { choose: async () => [], addImage: async (image) => ({ id: "image", name: image.name, size: 0, mediaType: image.mediaType }), read: async () => ({ name: "test.txt", mediaType: "text/plain", base64: "" }) }, cloud: { request: cloudRequest, subscribe: () => () => undefined }, notifications: { show: async () => true, setBadge: async () => undefined }, deepLinks: { onOpenAgent: () => () => undefined }, @@ -113,7 +140,7 @@ describe("Runta Crew authentication surfaces", () => { settings: { get: async () => ({ endpoint: "https://api.runta.com", dashboardUrl: "https://dashboard.runta.com", theme: "light", notifications: true }), set: async (settings) => settings }, credentials: { has: async () => false, set: async () => false }, auth: { start: async () => { throw new Error("Error invoking remote method 'auth:start': Error: Device authorization failed (401)"); }, status: async () => "error", logout: async () => true }, - attachments: { choose: async () => [] }, notifications: { show: async () => true, setBadge: async () => undefined }, deepLinks: { onOpenAgent: () => () => undefined }, + attachments: { choose: async () => [], addImage: async (image) => ({ id: "image", name: image.name, size: 0, mediaType: image.mediaType }), read: async () => ({ name: "test.txt", mediaType: "text/plain", base64: "" }) }, notifications: { show: async () => true, setBadge: async () => undefined }, deepLinks: { onOpenAgent: () => () => undefined }, }; window.runtaCrew = bridge; const user = userEvent.setup(); render(); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 26edd33..85a2b6d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; import { AlertCircle } from "lucide-react"; import { RuntaCloudAgentsClient } from "@/clients/http/RuntaCloudAgentsClient"; import { useCrewController } from "@/state/useCrewController"; @@ -15,6 +15,22 @@ import type { Agent } from "@/domain/types"; const LANDING_AGENTS = ["Atlas", "Scout", "Mira", "Nova"] as const; +function ErrorToast({ message, onDismiss }: { message?: string; onDismiss(): void }) { + const [renderedMessage, setRenderedMessage] = useState(message); const [visible, setVisible] = useState(false); + useEffect(() => { + if (message) { + setRenderedMessage(message); + const frame = window.requestAnimationFrame(() => setVisible(true)); + return () => window.cancelAnimationFrame(frame); + } + setVisible(false); + const timer = window.setTimeout(() => setRenderedMessage(undefined), 180); + return () => window.clearTimeout(timer); + }, [message]); + if (!renderedMessage) return null; + return
{renderedMessage}
; +} + export function AgentsLanding({ hasAgents }: { hasAgents: boolean }) { return