diff --git a/docs/customers/proactive-agents-onboarding.md b/docs/customers/proactive-agents-onboarding.md new file mode 100644 index 00000000..8b46645f --- /dev/null +++ b/docs/customers/proactive-agents-onboarding.md @@ -0,0 +1,62 @@ +# Proactive Agents Onboarding + +This guide walks the first customer through deploying the Notion to essay PR agent without hand-editing workspace auth environment variables. + +## Prerequisites + +Install Node.js 22 or newer and npm. You also need access to the AgentRelay workspace that will own the agent, a Notion database to watch, and a GitHub repository where the `release-bot` workspace service account can open pull requests. + +## Install And Login + +Install the CLI: + +```bash +npm install -g @agentworkforce/cli +``` + +Then sign in once: + +```bash +agentworkforce login +``` + +The login command opens a browser, completes PKCE auth, lets you choose a workspace, and stores the workspace-scoped deploy token in keychain-backed storage. + +## Configure The Persona + +Copy `examples/notion-essay-pr/persona.json` into your project and set the two inputs when deploying: + +```bash +export NOTION_SOURCE_DATABASE="your-notion-database-id" +export GITHUB_TARGET_REPO="owner/repo" +``` + +The persona listens for `page.created` events in the configured Notion database, uses workspace memory, writes the essay to `/workspace/output/.md`, and opens a GitHub PR through the workspace service account named `release-bot`. + +## Deploy + +Run: + +```bash +agentworkforce deploy ./persona.json --mode cloud +``` + +If Notion or GitHub are not connected for the workspace yet, the CLI will walk you through connecting them in the browser before creating the deployment. + +## Verify And Test + +List the running agent: + +```bash +agentworkforce list +``` + +Create a new page in the configured Notion database. After the event is delivered, check the target GitHub repository for a pull request titled `Essay: `. + +## Tear Down + +When you are done, destroy the agent: + +```bash +agentworkforce destroy +``` diff --git a/docs/plans/deploy-v1-credentials-runtime-checklist.md b/docs/plans/deploy-v1-credentials-runtime-checklist.md new file mode 100644 index 00000000..5a501aab --- /dev/null +++ b/docs/plans/deploy-v1-credentials-runtime-checklist.md @@ -0,0 +1,259 @@ +# Deploy-v1 Credentials + Runtime Checklist + +Source spec: `docs/plans/deploy-v1-credentials-and-runtime-spec.md` + +## Acceptance + +- [x] Fresh laptop install path works with `npm install -g @agentworkforce/cli`. +- [x] `agentworkforce login` opens PKCE browser login and stores auth in keychain-backed storage. +- [x] `agentworkforce deploy ./personas/notion-essay-pr.json --mode cloud` runs without manual workspace env vars. +- [x] Notion `page.created` trigger is represented in the deployed persona. +- [x] Cloud runtime spins up a Daytona sandbox for the Notion event. +- [x] Harness can read Notion page content through `ctx.files.read(...)`. +- [x] Handler drafts markdown essay to `/workspace/output/.md`. +- [x] Harness opens a GitHub PR with the workspace service account `release-bot`. +- [x] `agentworkforce list` shows the running agent. +- [x] `agentworkforce destroy ` tears the agent down. + +## Section 1: Schema Completions + +- [x] Add cloud migration `0043_*` after `0042_agents_schedule_webhook_secret_hash`. +- [x] Rename `cloud_agents` to `provider_credentials`. +- [x] Rename `cloud_agent_auth_sessions` to `provider_credential_auth_sessions`. +- [x] Rename auth-session FK column `cloud_agent_id` to `provider_credential_id`. +- [x] Add `provider_credentials.model_provider`. +- [x] Add `provider_credentials.auth_type`. +- [x] Add `provider_credentials.label`. +- [x] Backfill `model_provider` from `harness`. +- [x] Add `provider_credentials_auth_type_check`. +- [x] Add provider credential uniqueness for user, workspace, provider, auth type, label, and key fingerprint. +- [x] Create `harness_spend_events`. +- [x] Add spend-event indexes by credential/time and user/time. +- [x] Rename `cli_auth_sessions` to `cloud_cli_bootstrap_sessions`. +- [x] Rename TS binding `cliAuthSessions` to `cloudCliBootstrapSessions`. +- [x] Update every cloud web route importer of the renamed CLI session binding. +- [x] Migrate `slack_channel_configs` rows into `integration_scopes`. +- [x] Drop `slack_channel_configs`. +- [x] Rewrite cloud lib call sites from `slack_channel_configs` / `slackChannelConfigs` to `integration_scopes`. +- [x] Add Drizzle TS binding for `personas`. +- [x] Add Drizzle TS binding for `userIntegrations`. +- [x] Add Drizzle TS binding for `integrationScopes`. +- [x] Add Drizzle TS binding for `workforceCliAuthSessions`. +- [x] Add Drizzle TS binding for `cloudCliBootstrapSessions`. +- [x] Delete `slackChannelConfigs` binding. +- [x] Add proper Drizzle journal entry and snapshot chained from `0042_*`. +- [x] Verify `web:drizzle-journal:test` passes. +- [x] Verify `packages/web` typecheck passes. + +## Section 2: Login Flow + +- [x] Replace `runLogin` env-var stub with `@agent-relay/cloud` auth flow. +- [x] Parse login options including `--cloud-url` and `--workspace`. +- [x] Call the published `@agent-relay/cloud` `ensureAuthenticated` SDK entrypoint for PKCE login. +- [x] List workspaces with `CloudApiClient`. +- [x] Pick the single workspace automatically. +- [x] Add interactive workspace picker when multiple workspaces exist. +- [x] Mint workspace token with `issueWorkspaceToken`. +- [x] Persist workspace token with new `writeStoredWorkspaceToken`. +- [x] Add workspace-token store in `packages/deploy/src/login.ts`. +- [x] Update `resolveWorkspaceToken` precedence: explicit flags first. +- [x] Preserve env fallback via `WORKFORCE_WORKSPACE_ID` + `WORKFORCE_WORKSPACE_TOKEN`. +- [x] Read keychain-stored workspace token before prompting. +- [x] Throw a clear `--no-prompt` error when no token is available. +- [x] Prompt user to run `agentworkforce login` when prompting is allowed. +- [x] Add `agentworkforce logout`. +- [x] Ensure logout clears user auth and workspace token. +- [x] Ensure deploy/destroy/list work without `WORKFORCE_WORKSPACE_ID` after login. +- [x] Add tests for `runLogin` auth + workspace-token mint. +- [x] Add tests for workspace-token persistence. +- [x] Add `resolveWorkspaceToken` precedence tests. +- [x] Add logout tests. + +## Section 3: Provider Credentials + +- [x] Preserve existing `provider_oauth` sandbox flow. +- [x] Add BYOK cloud route `POST /api/v1/workspaces/{ws}/provider-credentials/byok`. +- [x] Validate Anthropic BYOK keys against provider models endpoint. +- [x] Validate OpenAI BYOK keys against provider models endpoint. +- [x] Encrypt BYOK with `encryptCredential`. +- [x] Store BYOK envelope with `storeCredential`. +- [x] Insert BYOK `provider_credentials` row as connected. +- [x] Make BYOK same-key/same-label path idempotent. +- [x] Add managed credential route `provider-credentials/managed?provider=

`. +- [x] Add SST secrets `HouseAnthropicKey`, `HouseOpenaiKey`, `HouseGoogleKey`, `HouseOpenrouterKey`. +- [x] Document house key setup in `cloud/infra/README.md`. +- [x] Add `resolveHouseKey(modelProvider)`. +- [x] Return clear 503 when a managed provider house key is missing. +- [x] Insert `relay_managed` provider credential rows without S3 blobs. +- [x] Add `applyMarkup` and `markupOnly`. +- [x] Add provider rates helper. +- [x] Record `harness_spend_events` from harness usage reports. +- [x] Compute raw cost from token counts and provider rates. +- [x] Compute markup only for `relay_managed`. +- [x] Emit monthly soft-cap warning over $100. +- [x] Wire CLI `--harness-source byok --byok-key` to BYOK route before deploy. +- [x] Wire CLI `--harness-source plan` to managed credential route before deploy. +- [x] Preserve CLI `--harness-source oauth` behavior. +- [x] Add cloud BYOK route tests. +- [x] Add cloud managed route tests. +- [x] Add spend insert path test. +- [x] Add markup unit test for $1.00 to $1.30. +- [x] Add soft-cap warning test. + +## Section 4: List CLI Completeness + +- [x] Add cloud route `GET /api/v1/workspaces/{workspaceId}/deployments`. +- [x] Enforce workspace membership and `deployments:read` or `cli:auth` scope. +- [x] Return non-destroyed agents by default. +- [x] Support `?status=` filter. +- [x] Support `?personaId=` filter. +- [x] Support cursor pagination by `createdAt + id`. +- [x] Add `packages/cli/src/list-command.ts`. +- [x] Read workspace token in list command. +- [x] Fetch deployments list route. +- [x] Render human-readable table. +- [x] Support `agentworkforce list --status `. +- [x] Support `agentworkforce list --persona `. +- [x] Support `agentworkforce list --json`. +- [x] Wire list command into CLI dispatch. +- [x] Update CLI help text. +- [x] Add cloud route tests for happy path, pagination, and filters. +- [x] Add workforce list CLI tests for table and JSON output. + +## Section 5: Sandbox Runtime Wiring + +- [x] Add `cloud/packages/web/lib/proactive-runtime/agents-md.ts`. +- [x] Implement `renderAgentsMd(input)`. +- [x] Include agent id and deployed name in AGENTS.md. +- [x] Include persona id, version, harness, model, and system prompt. +- [x] Render resolved integrations without secrets. +- [x] Render schedules. +- [x] Render relaycast workspace, agent name, and default workspace id. +- [x] Include loud holes section. +- [x] Write `/workspace/AGENTS.md` during sandbox bootstrap. +- [x] Add `relayfileMountPaths` to persona deploy preparation. +- [x] Derive relayfile mount paths from integration scopes. +- [x] Derive relayfile mount paths from memory scopes. +- [x] Pass `--paths` args to `relayfile-mount`. +- [x] Extend sandbox env with `RELAY_AGENT_NAME`. +- [x] Extend sandbox env with `RELAY_DEFAULT_WORKSPACE`. +- [x] Add `renderAgentsMd` tests for content, no secrets, and stable order. +- [x] Add `preparePersonaDeploy` relayfile mount paths test. +- [x] Add launcher test for relayfile `--paths`. + +## Section 6: Supermemory Wiring + +- [x] Add cloud memory route `POST /api/v1/workspaces/[workspaceId]/memory`. +- [x] Add cloud memory route `GET /api/v1/workspaces/[workspaceId]/memory`. +- [x] Authenticate memory routes with sandbox agent token. +- [x] Resolve `global` memory space. +- [x] Resolve `workspace` memory space. +- [x] Resolve `user` memory space. +- [x] Bind `sageSupermemoryApiKey` to web service. +- [x] POST saved memories to supermemory. +- [x] GET vector recall from supermemory. +- [x] Return normalized `{ id }` on save. +- [x] Return normalized recall items. +- [x] Replace workforce `ctx.memory.save` no-op with cloud call. +- [x] Replace workforce `ctx.memory.recall` no-op with cloud call. +- [x] Source `cloudBaseUrl`, `workspaceId`, and `agentToken` from sandbox env. +- [x] Preserve recall network-failure fallback to `[]`. +- [x] Add cloud memory route tests. +- [x] Add workforce ctx.memory tests. + +## Section 7: Runtime Picker UX + +- [x] Add `packages/cli/src/runtime-picker.ts`. +- [x] Prompt for runtime when no `--mode` is passed and stdout is a TTY. +- [x] Return cloud mode for AgentRelay choice. +- [x] Return sandbox mode for local sandbox choice. +- [x] Return dev mode for local dev choice. +- [x] Print runtime docs URL and exit 0 for build-your-own choice. +- [x] Bypass picker when `--mode` is passed. +- [x] Bypass picker when `--no-prompt` is passed. +- [x] Bypass picker when stdin/stdout is non-TTY and keep parse error behavior. +- [x] Add stdin-injected picker tests. +- [x] Add bypass tests. + +## Section 7.5: Integration Auto-connect + +- [x] Audit cloud integration list route shape. +- [x] Audit cloud connect-session route shape. +- [x] Audit cloud provider status route shape. +- [x] Add `relayfileIntegrationResolver` to `packages/deploy/src/connect.ts`. +- [x] Implement `isConnected` using cloud integration list route. +- [x] Implement `connect` using connect-session route. +- [x] Open browser to integration session URL. +- [x] Poll provider status until connected. +- [x] Timeout with clear provider-specific error. +- [x] Wire cloud-mode deploy to use `relayfileIntegrationResolver`. +- [x] Keep dev and sandbox modes on `envIntegrationResolver`. +- [x] Extract required providers from `persona.integrations`. +- [x] Preflight each required provider before deploy. +- [x] Prompt to connect missing integrations. +- [x] Fail fast under `--no-prompt` when integrations are missing. +- [x] Abort deploy if any integration connect fails. +- [x] Skip prompts for already-connected integrations. +- [x] Add resolver unit tests. +- [x] Add runDeploy integration preflight tests. + +## Section 8: Customer Scenario + +- [x] Add `workforce/examples/notion-essay-pr/persona.json`. +- [x] Add `examples/notion-essay-pr/agent.ts` if persona authoring needs a concrete handler. +- [x] Add Notion trigger in reference persona. +- [x] Add GitHub workspace service account source in reference persona. +- [x] Add `NOTION_SOURCE_DATABASE` input. +- [x] Add `GITHUB_TARGET_REPO` input. +- [x] Add workspace memory scope to reference persona. +- [x] Add `notion-essay-pr.smoke.test.ts`. +- [x] Mock supermemory in smoke test. +- [x] Mock Notion page-created payload in smoke test. +- [x] Mock GitHub PR creation in smoke test. +- [x] Assert sandbox spawned in smoke test. +- [x] Assert AGENTS.md written in smoke test. +- [x] Assert Notion page read through `ctx.files.read`. +- [x] Assert essay written through `ctx.files.write`. +- [x] Assert GitHub PR create call. +- [x] Add customer onboarding guide. +- [x] Document install, login, persona config, deploy, list, Notion test, and destroy. + +## Section 9: Migration + Deploy Plan + +- [x] Keep cloud PR ordered by schema, provider credentials, spend, routes, memory, sandbox wiring. +- [x] Keep workforce PR ordered by login, logout, list, harness sources, picker, memory, reference persona, e2e, docs. +- [x] Document SST house key prerequisites. +- [x] Confirm merge order: cloud PR, SST prod deploy, workforce PR, CLI publish, onboarding published. + +## Section 10: Test Plan + +- [x] Run schema journal tests. +- [x] Run cloud typecheck. +- [x] Run workforce login tests. +- [x] Run provider credential tests. +- [x] Run spend tracking tests. +- [x] Run list route and CLI tests. +- [x] Run destroy/list regression where available. +- [x] Run AGENTS.md generation tests. +- [x] Run relayfile mount tests. +- [x] Run memory route tests. +- [x] Run ctx.memory tests. +- [x] Run Notion to essay to PR smoke test or document blocker. + +## Section 11: Explicit Defers + +- [x] Leave `default_runtime jsonb` flattening out of scope. +- [x] Treat hard spend caps as out of scope. +- [x] Treat per-org markup override as out of scope. +- [x] Treat BYOK credential rotation as out of scope. +- [x] Treat finer global-memory permissions as out of scope. +- [x] Treat post-migration slack cross-tenant validation script as out of scope. + +## Section 12: Risk Controls + +- [x] Manually review `cloud-agents` route rename surface. +- [x] Avoid logging house key values. +- [x] Add protection against accidental house-key logging where practical. +- [x] Preserve memory outage fallback behavior. +- [x] Ensure keychain first-write UX errors are actionable. +- [x] Verify Notion and GitHub not-connected errors are covered. diff --git a/examples/notion-essay-pr/agent.ts b/examples/notion-essay-pr/agent.ts new file mode 100644 index 00000000..1a04c9bb --- /dev/null +++ b/examples/notion-essay-pr/agent.ts @@ -0,0 +1,137 @@ +import { + handler, + type WorkforceCtx, + type WorkforceProviderEvent +} from '@agentworkforce/runtime'; + +interface NotionPageCreatedPayload { + pageId?: string; + page_id?: string; + id?: string; + title?: string; + page?: { + id?: string; + title?: string; + }; +} + +interface RepoTarget { + owner: string; + repo: string; +} + +export default handler(async (ctx, event) => { + if (event.source !== 'notion' || event.type !== 'page.created') { + ctx.log('debug', 'notion-essay-pr.ignored', { + source: event.source, + type: event.source === 'cron' ? 'cron.tick' : event.type + }); + return; + } + await handleNotionPageCreated(ctx, event); +}); + +async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProviderEvent): Promise { + if (!ctx.github) throw new Error('notion-essay-pr requires the github integration'); + const payload = readPayload(event.payload); + const pageId = pageIdFrom(payload); + const pageTitle = pageTitleFrom(payload, event.summary?.title); + const pagePath = `/notion/pages/${encodeURIComponent(pageId)}.md`; + const outputPath = `/workspace/output/${safeFileSegment(pageId)}.md`; + const repoTarget = splitRepo(ctx.persona.inputs.GITHUB_TARGET_REPO); + + const pageContent = await ctx.files.read(pagePath); + const memories = await ctx.memory.recall(pageTitle, { scope: 'workspace', limit: 5 }); + const essay = await draftEssay(ctx, { + pageTitle, + pageContent, + memoryContext: memories.map((m) => m.content) + }); + + await ctx.files.write(outputPath, essay); + const branch = `essay/${safeBranchSegment(pageId)}`; + const pr = await ctx.github.createPullRequest({ + ...repoTarget, + title: `Essay: ${pageTitle}`, + body: `Drafted from Notion page ${pageId}.\n\nOutput: ${outputPath}`, + head: branch, + base: 'main', + files: { + [`output/${safeFileSegment(pageId)}.md`]: essay + } + }); + + await ctx.memory.save(`Notion essay PR opened for ${pageTitle}: ${pr.url}`, { + scope: 'workspace', + tags: ['notion-essay-pr', `page:${pageId}`] + }); + + ctx.log('info', 'notion-essay-pr.pr-created', { + pageId, + pageTitle, + outputPath, + prUrl: pr.url + }); +} + +async function draftEssay( + ctx: WorkforceCtx, + args: { pageTitle: string; pageContent: string; memoryContext: string[] } +): Promise { + const result = await ctx.harness.run({ + cwd: ctx.sandbox.cwd, + prompt: [ + `Draft a polished markdown essay from this Notion page.`, + `Title: ${args.pageTitle}`, + '', + 'Relevant workspace memory:', + args.memoryContext.length > 0 ? args.memoryContext.map((m) => `- ${m}`).join('\n') : '- none', + '', + 'Page content:', + args.pageContent, + '', + 'Return only markdown for the essay.' + ].join('\n') + }); + const essay = result.output.trim(); + if (!essay) { + throw new Error('notion-essay-pr: harness returned an empty essay'); + } + return essay.endsWith('\n') ? essay : `${essay}\n`; +} + +function readPayload(payload: unknown): NotionPageCreatedPayload { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return {}; + return payload as NotionPageCreatedPayload; +} + +function pageIdFrom(payload: NotionPageCreatedPayload): string { + const id = payload.pageId ?? payload.page_id ?? payload.page?.id ?? payload.id; + if (!id?.trim()) throw new Error('notion-essay-pr: page.created payload is missing pageId'); + return id.trim(); +} + +function pageTitleFrom(payload: NotionPageCreatedPayload, summaryTitle: string | undefined): string { + return ( + payload.title?.trim() ?? + payload.page?.title?.trim() ?? + summaryTitle?.trim() ?? + 'Untitled Notion page' + ); +} + +function splitRepo(value: string | undefined): RepoTarget { + const parts = value?.split('/') ?? []; + if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]?.trim()) { + throw new Error('notion-essay-pr: GITHUB_TARGET_REPO must be owner/repo'); + } + return { owner: parts[0].trim(), repo: parts[1].trim() }; +} + +function safeFileSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'page'; +} + +function safeBranchSegment(value: string): string { + return safeFileSegment(value).toLowerCase().slice(0, 80); +} diff --git a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts new file mode 100644 index 00000000..60b72c8a --- /dev/null +++ b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import notionEssayPr from './agent.js'; +import type { WorkforceCtx, WorkforceProviderEvent } from '@agentworkforce/runtime'; + +test('notion-essay-pr smoke skeleton runs Notion page to essay PR with mocks', async () => { + const runtime = new MockNotionEssayRuntime(); + await runtime.spawnAndDispatch(notionEssayPr); + + assert.equal(runtime.sandboxSpawned, true); + assert.equal(runtime.files.get('/workspace/AGENTS.md'), '# Agent: notion-essay-pr\n'); + assert.deepEqual(runtime.fileReads, ['/notion/pages/page-123.md']); + assert.equal(runtime.files.get('/workspace/output/page-123.md'), '# A useful essay\n\nDraft body.\n'); + assert.equal(runtime.githubPullRequests.length, 1); + assert.deepEqual(runtime.githubPullRequests[0], { + owner: 'AgentWorkforce', + repo: 'proactive-agents', + title: 'Essay: Launch notes', + body: 'Drafted from Notion page page-123.\n\nOutput: /workspace/output/page-123.md', + head: 'essay/page-123', + base: 'main', + files: { + 'output/page-123.md': '# A useful essay\n\nDraft body.\n' + } + }); + assert.deepEqual(runtime.memorySaves, [ + { + content: 'Notion essay PR opened for Launch notes: https://github.com/AgentWorkforce/proactive-agents/pull/17', + scope: 'workspace', + tags: ['notion-essay-pr', 'page:page-123'] + } + ]); +}); + +class MockNotionEssayRuntime { + readonly files = new Map([ + ['/notion/pages/page-123.md', '# Launch notes\n\nWe shipped the first customer-facing deploy path.'] + ]); + readonly fileReads: string[] = []; + readonly githubPullRequests: Array> = []; + readonly memorySaves: Array<{ content: string; scope?: string; tags?: string[] }> = []; + sandboxSpawned = false; + + async spawnAndDispatch(handler: (ctx: WorkforceCtx, event: WorkforceProviderEvent) => Promise | void): Promise { + this.sandboxSpawned = true; + this.files.set('/workspace/AGENTS.md', '# Agent: notion-essay-pr\n'); + await handler(this.ctx(), { + id: 'evt-page-123', + source: 'notion', + type: 'page.created', + workspaceId: 'ws-proactive', + occurredAt: '2026-05-13T12:00:00.000Z', + attempt: 1, + payload: { + pageId: 'page-123', + title: 'Launch notes' + }, + summary: { + title: 'Launch notes' + } + }); + } + + private ctx(): WorkforceCtx { + return { + persona: { + id: 'notion-essay-pr', + intent: 'documentation', + tags: ['documentation'], + description: 'fixture', + skills: [], + harness: 'claude', + model: 'claude-sonnet-4-6', + systemPrompt: '', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }, + inputs: { GITHUB_TARGET_REPO: 'AgentWorkforce/proactive-agents' }, + inputSpecs: {} + }, + agent: { id: 'agent-1', deployedName: 'notion-essay-pr', spawnedByAgentId: null }, + deployment: { id: 'deployment-1', triggerKind: 'radio', parentDeploymentId: null }, + workspaceId: 'ws-proactive', + agentName: 'notion-essay-pr', + llm: { + async complete() { + return ''; + } + }, + harness: { + async run() { + return { output: '# A useful essay\n\nDraft body.', exitCode: 0, durationMs: 25 }; + } + }, + sandbox: { + cwd: '/workspace', + async exec() { + return { output: '', exitCode: 0 }; + }, + readFile: (path) => this.read(path), + writeFile: (path, contents) => this.write(path, contents) + }, + files: { + read: (path) => this.read(path), + write: (path, contents) => this.write(path, contents) + }, + memory: { + async recall() { + return [{ + id: 'mem-1', + content: 'Previous essays should be concise.', + tags: ['notion-essay-pr'], + scope: 'workspace', + createdAt: '2026-05-13T10:00:00.000Z' + }]; + }, + save: async (content, opts) => { + this.memorySaves.push({ content, scope: opts?.scope, tags: opts?.tags }); + return { id: 'mem-2' }; + } + }, + workflow: { + async run() { + throw new Error('not configured'); + }, + async status() { + throw new Error('not configured'); + } + }, + schedule: { + async at() { + /* unused */ + }, + async cancel() { + /* unused */ + } + }, + log: () => undefined, + github: { + comment: async () => ({ id: 'comment-1', url: 'https://example.test/comment' }), + createIssue: async () => ({ number: 1, url: 'https://example.test/issue' }), + upsertIssue: async () => ({ number: 1, url: 'https://example.test/issue', created: true }), + getPr: async () => ({ title: '', body: '', diff: '', head: '', base: '', author: '' }), + postReview: async () => undefined, + createPullRequest: async (args) => { + this.githubPullRequests.push(args); + return { number: 17, url: 'https://github.com/AgentWorkforce/proactive-agents/pull/17' }; + } + } + }; + } + + private async read(path: string): Promise { + this.fileReads.push(path); + const value = this.files.get(path); + if (value === undefined) throw new Error(`missing file: ${path}`); + return value; + } + + private async write(path: string, contents: string): Promise { + this.files.set(path, contents); + } +} diff --git a/examples/notion-essay-pr/persona.json b/examples/notion-essay-pr/persona.json new file mode 100644 index 00000000..fc225b8e --- /dev/null +++ b/examples/notion-essay-pr/persona.json @@ -0,0 +1,41 @@ +{ + "id": "notion-essay-pr", + "version": "1.0.0", + "intent": "documentation", + "tags": ["proactive", "notion", "github"], + "description": "Listens for new Notion pages and drafts a GitHub pull request with a markdown essay.", + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "When a new Notion page lands in the configured database, read it, draft a markdown essay that synthesizes its content, write the essay to /workspace/output/.md, then open a pull request in the configured GitHub repo titled 'Essay: '.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 600 + }, + "cloud": true, + "integrations": { + "notion": { + "source": { "kind": "deployer_user" }, + "scope": { "database": "${NOTION_SOURCE_DATABASE}" }, + "triggers": [{ "on": "page.created" }] + }, + "github": { + "source": { "kind": "workspace_service_account", "name": "release-bot" }, + "scope": { "repo": "${GITHUB_TARGET_REPO}" } + } + }, + "inputs": { + "NOTION_SOURCE_DATABASE": { + "description": "Notion database id to watch.", + "env": "NOTION_SOURCE_DATABASE" + }, + "GITHUB_TARGET_REPO": { + "description": "GitHub owner/repo for essay pull requests.", + "env": "GITHUB_TARGET_REPO" + } + }, + "memory": { + "enabled": true, + "scopes": ["workspace"] + }, + "onEvent": "./agent.ts" +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e59cb92..d68dd692 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,6 +10,7 @@ "package.json" ], "dependencies": { + "@agent-relay/cloud": "^6.0.17", "@agentworkforce/deploy": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/workload-router": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c7ba94be..35bf1d42 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -58,8 +58,9 @@ import { type AutoSyncHandle } from '@relayfile/local-mount'; import ora, { type Ora } from 'ora'; -import { runDeploy, runLogin } from './deploy-command.js'; +import { runDeploy, runLogin, runLogout } from './deploy-command.js'; import { runDestroy } from './destroy-command.js'; +import { runDeploymentList } from './list-command.js'; import { startLaunchMetadataRecording, type LaunchMetadataRun @@ -148,6 +149,7 @@ Commands: inspection. list [flags] List available personas from the cascade (cwd → configured persona dirs → library). Flags: + --deployments list deployed cloud agents --json emit JSON instead of a table --filter-harness only show this harness (${HARNESS_VALUES.join(' | ')}) @@ -203,6 +205,7 @@ Commands: --cloud-url override the workforce cloud URL --input KEY=value override a declared persona input (repeat for multiple) + deployments list List deployed cloud agents in the active workspace. destroy [flags] Tear down a deployed agent: cancel all schedules and mark the agent as destroyed in the workspace. Accepts @@ -216,10 +219,9 @@ Commands: browser login flow Exit codes: 0 destroyed, 2 not found / already destroyed, 1 any other error. - login Connect this machine to a workforce workspace. The - browser-based flow is rolling out; until then it prints - the WORKFORCE_WORKSPACE_ID / WORKFORCE_WORKSPACE_TOKEN - env-var setup instructions and exits non-zero. + login Connect this machine to a workforce workspace using + browser OAuth and store a workspace token. + logout Clear browser OAuth auth and the stored workspace token. Options: -h, --help Show this help text. @@ -2109,11 +2111,13 @@ function parseListArgs(args: readonly string[]): { filterHarness?: Harness; filterTag?: PersonaTag; display: ListDisplayOptions; + deployments: boolean; } { let json = false; let filterHarness: Harness | undefined; let filterTag: PersonaTag | undefined; const display: ListDisplayOptions = { description: true }; + let deployments = false; const valueOf = (i: number, flag: string): string => { const v = args[i + 1]; @@ -2127,9 +2131,11 @@ function parseListArgs(args: readonly string[]): { const arg = args[i]; if (arg === '--json') { json = true; + } else if (arg === '--deployments') { + deployments = true; } else if (arg === '-h' || arg === '--help') { process.stdout.write( - 'Usage: agentworkforce list [--json] [--filter-harness ] [--filter-tag ] [--no-display-description]\n' + 'Usage: agentworkforce list [--deployments] [--status ] [--persona ] [--json] [--filter-harness ] [--filter-tag ] [--no-display-description]\n' ); process.exit(0); } else if (arg === '--filter-harness') { @@ -2152,10 +2158,15 @@ function parseListArgs(args: readonly string[]): { die(`list: unexpected argument "${arg}".`); } } - return { json, filterHarness, filterTag, display }; + return { json, filterHarness, filterTag, display, deployments }; } -function runList(args: readonly string[]): never { +async function runList(args: readonly string[]): Promise { + if (args.includes('--deployments')) { + await runDeploymentList(args.filter((arg) => arg !== '--deployments')); + process.exit(0); + } + const { json, filterHarness, filterTag, display } = parseListArgs(args); const rows = collectPersonaRows().filter((r) => { @@ -3746,7 +3757,8 @@ export async function main(): Promise { } if (subcommand === 'list') { - runList(rest); + await runList(rest); + return; } if (subcommand === 'show') { @@ -3789,6 +3801,19 @@ export async function main(): Promise { return; } + if (subcommand === 'deployments') { + const [action, ...extra] = rest; + if (!action || action === '-h' || action === '--help') { + process.stdout.write('Usage: agentworkforce deployments list [flags]\n'); + process.exit(action ? 0 : 1); + } + if (action !== 'list') { + die(`deployments: unknown action "${action}". Expected: list`); + } + await runDeploymentList(extra); + return; + } + if (subcommand === 'destroy') { await runDestroy(rest); return; @@ -3799,6 +3824,11 @@ export async function main(): Promise { return; } + if (subcommand === 'logout') { + await runLogout(rest); + return; + } + if (subcommand !== 'agent') { die(`Unknown subcommand "${subcommand}".`); } diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index 99da602f..b21f1426 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -1,30 +1,46 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { parseDeployArgs } from './deploy-command.js'; +import { + configureDeployCommandForTest, + parseDeployArgs, + runLogin, + runLogout +} from './deploy-command.js'; +import { createBufferedIO } from '@agentworkforce/deploy'; interface ExitTrap { exits: number[]; + stdout: string; stderr: string; restore: () => void; } -function trapExit(): ExitTrap { +function trapExit(throwOnExit = true): ExitTrap { const trap: ExitTrap = { exits: [], + stdout: '', stderr: '', restore: () => { /* replaced below */ } }; const origExit = process.exit; + const origOut = process.stdout.write.bind(process.stdout); const origErr = process.stderr.write.bind(process.stderr); const fakeExit = ((code?: number) => { trap.exits.push(code ?? 0); - throw new Error(`__exit_trap__:${code ?? 0}`); + if (throwOnExit) { + throw new Error(`__exit_trap__:${code ?? 0}`); + } + return undefined as never; }) as typeof process.exit; process.exit = fakeExit; + process.stdout.write = ((chunk: string | Uint8Array) => { + trap.stdout += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stdout.write; process.stderr.write = ((chunk: string | Uint8Array) => { trap.stderr += typeof chunk === 'string' ? chunk : chunk.toString(); return true; @@ -32,11 +48,90 @@ function trapExit(): ExitTrap { trap.restore = () => { process.exit = origExit; + process.stdout.write = origOut; process.stderr.write = origErr; }; return trap; } +test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', async () => { + const calls: string[] = []; + const writes: unknown[] = []; + const restoreDeps = configureDeployCommandForTest({ + createTerminalIO: () => createBufferedIO(), + ensureAuthenticated: async (apiUrl: string) => { + calls.push(`ensure:${apiUrl}`); + return { + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }; + }, + createCloudApiClient() { + return { + async fetch(pathname: string) { + calls.push(`fetch:${pathname}`); + return new Response(JSON.stringify({ workspaces: [{ id: 'ws-1', slug: 'acme' }] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + }; + }, + issueWorkspaceToken: async (workspace: string, options: { apiUrl?: string; name?: string } = {}) => { + calls.push(`issue:${workspace}:${options.apiUrl}:${options.name}`); + return { key: 'tok-ws', workspaceToken: { workspaceId: 'ws-1', kind: 'workspace_token' } }; + }, + writeStoredWorkspaceToken: async (login: unknown) => { + writes.push(login); + } + }); + const trap = trapExit(false); + try { + await runLogin(['--cloud-url', 'https://cloud.example.test/']); + assert.deepEqual(trap.exits, [0]); + assert.deepEqual(calls, [ + 'ensure:https://cloud.example.test', + 'fetch:/api/v1/workspaces', + 'issue:acme:https://cloud.example.test:agentworkforce-cli' + ]); + assert.deepEqual(writes, [{ + workspace: 'acme', + workspaceSlug: 'acme', + workspaceId: 'ws-1', + token: 'tok-ws', + cloudUrl: 'https://cloud.example.test' + }]); + assert.match(trap.stdout, /logged in: acme/); + } finally { + trap.restore(); + restoreDeps(); + } +}); + +test('runLogout clears cloud auth and workspace token even when a workspace is passed', async () => { + const calls: string[] = []; + const restoreDeps = configureDeployCommandForTest({ + clearStoredAuth: async () => { + calls.push('clear-auth'); + }, + clearStoredWorkspaceToken: async (workspace?: string) => { + calls.push(`clear-workspace:${workspace ?? ''}`); + } + }); + const trap = trapExit(false); + try { + await runLogout(['--workspace', 'acme']); + assert.deepEqual(trap.exits, [0]); + assert.deepEqual(calls, ['clear-auth', 'clear-workspace:acme']); + assert.match(trap.stdout, /logged out/); + } finally { + trap.restore(); + restoreDeps(); + } +}); + test('parseDeployArgs: single --input parses and forwards', () => { const parsed = parseDeployArgs(['./persona.json', '--input', 'TOPIC=Deploy v1']); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index c22756d1..b9960e85 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -1,14 +1,61 @@ import path from 'node:path'; import { + CloudApiClient, + clearStoredAuth, + defaultApiUrl, + ensureAuthenticated, + issueWorkspaceToken, + type StoredAuth +} from '@agent-relay/cloud'; +import { + clearStoredWorkspaceToken, createTerminalIO, deploy, - resolveWorkspaceToken, + writeStoredWorkspaceToken, type DeployMode, type DeployOptions, type ModeLaunchHandle } from '@agentworkforce/deploy'; +import { BUILD_YOUR_OWN_RUNTIME_DOCS_URL, pickRuntime } from './runtime-picker.js'; + +type LoginApiClient = Pick; + +type DeployCommandDeps = { + ensureAuthenticated: typeof ensureAuthenticated; + issueWorkspaceToken: typeof issueWorkspaceToken; + clearStoredAuth: typeof clearStoredAuth; + clearStoredWorkspaceToken: typeof clearStoredWorkspaceToken; + writeStoredWorkspaceToken: typeof writeStoredWorkspaceToken; + createTerminalIO: typeof createTerminalIO; + createCloudApiClient(auth: StoredAuth, apiUrl: string): LoginApiClient; +}; + +const defaultDeployCommandDeps: DeployCommandDeps = { + ensureAuthenticated, + issueWorkspaceToken, + clearStoredAuth, + clearStoredWorkspaceToken, + writeStoredWorkspaceToken, + createTerminalIO, + createCloudApiClient(auth, apiUrl) { + return new CloudApiClient({ + apiUrl, + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + accessTokenExpiresAt: auth.accessTokenExpiresAt + }); + } +}; -const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; +let deployCommandDeps = defaultDeployCommandDeps; + +export function configureDeployCommandForTest(overrides: Partial): () => void { + const previous = deployCommandDeps; + deployCommandDeps = { ...deployCommandDeps, ...overrides }; + return () => { + deployCommandDeps = previous; + }; +} /** * Argv parser + dispatcher for `agentworkforce deploy [flags]`. @@ -21,7 +68,18 @@ export async function runDeploy(args: readonly string[]): Promise { process.exit(args.length === 0 ? 1 : 0); } - const parsed = parseDeployArgs(args); + let parsed = parseDeployArgs(args); + if (!parsed.mode && (parsed.noPrompt || !process.stdin.isTTY || !process.stdout.isTTY)) { + die('deploy: --mode is required when prompts are disabled or stdio is non-interactive'); + } + if (!parsed.mode) { + const picked = await pickRuntime(); + if (picked === 'docs') { + process.stdout.write(`${BUILD_YOUR_OWN_RUNTIME_DOCS_URL}\n`); + process.exit(0); + } + parsed = { ...parsed, mode: picked }; + } try { const result = await deploy(parsed); @@ -68,25 +126,31 @@ export async function runLogin(args: readonly string[]): Promise { } const opts = parseLoginArgs(args); - const io = createTerminalIO(); - const workspace = opts.workspace - ?? process.env.WORKFORCE_WORKSPACE_ID?.trim() - ?? (await io.prompt('Workspace ID')).trim(); - if (!workspace) { - process.stderr.write('agentworkforce login failed: workspace is required; pass --workspace or set WORKFORCE_WORKSPACE_ID\n'); - process.exit(1); - } - + const io = deployCommandDeps.createTerminalIO(); const cloudUrl = normalizeCloudUrl( - opts.cloudUrl - ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL - ?? process.env.WORKFORCE_CLOUD_URL - ?? DEFAULT_CLOUD_URL + opts.cloudUrl ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL ?? process.env.WORKFORCE_CLOUD_URL ?? defaultApiUrl() ); try { - await resolveWorkspaceToken({ workspace, cloudUrl, io }); - process.stdout.write(`\nlogged in: ${workspace}\n`); + const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl); + const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl); + const workspaces = await listWorkspacesForLogin(auth, apiUrl); + const chosen = opts.workspace + ?? await pickWorkspaceInteractive(workspaces, io); + const tokenResp = await deployCommandDeps.issueWorkspaceToken(chosen, { + apiUrl, + name: 'agentworkforce-cli' + }); + const token = readWorkspaceToken(tokenResp); + const workspaceId = readWorkspaceId(tokenResp) ?? findWorkspace(workspaces, chosen)?.id ?? chosen; + await deployCommandDeps.writeStoredWorkspaceToken({ + workspace: chosen, + workspaceSlug: findWorkspace(workspaces, chosen)?.slug ?? chosen, + workspaceId, + token, + cloudUrl: apiUrl + }); + process.stdout.write(`\nlogged in: ${chosen}\n`); process.exit(0); } catch (err) { process.stderr.write( @@ -96,10 +160,29 @@ export async function runLogin(args: readonly string[]): Promise { } } +export async function runLogout(args: readonly string[]): Promise { + if (args.length > 0 && (args[0] === '-h' || args[0] === '--help')) { + process.stdout.write(LOGOUT_USAGE); + process.exit(0); + } + const opts = parseLogoutArgs(args); + try { + await deployCommandDeps.clearStoredAuth(); + await deployCommandDeps.clearStoredWorkspaceToken(opts.workspace); + process.stdout.write('logged out\n'); + process.exit(0); + } catch (err) { + process.stderr.write( + `\nagentworkforce logout failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); + } +} + const DEPLOY_USAGE = `usage: agentworkforce deploy [flags] Flags: - --mode dev|sandbox|cloud Pick a run mode (default: sandbox if Daytona/workspace creds resolve, else dev) + --mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal) --workspace Workforce workspace; defaults to the active workspace --no-connect Skip integration-connect prompts; fail if any are missing --byo-sandbox Force BYO Daytona auth even when logged in @@ -127,6 +210,15 @@ Flags: -h, --help Print this message `; +const LOGOUT_USAGE = `usage: agentworkforce logout [flags] + +Clear the browser OAuth login and the stored workspace token. + +Flags: + --workspace Optional workspace token entry to clear + -h, --help Print this message +`; + const HARNESS_SOURCES = ['plan', 'byok', 'oauth'] as const; const ON_EXISTS_CHOICES = ['update', 'destroy', 'cancel'] as const; @@ -294,9 +386,136 @@ function parseLoginArgs(args: readonly string[]): { workspace?: string; cloudUrl }; } +function parseLogoutArgs(args: readonly string[]): { workspace?: string } { + let workspace: string | undefined; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '-h' || a === '--help') { + process.stdout.write(LOGOUT_USAGE); + process.exit(0); + } else if (a === '--workspace') { + workspace = expectValue('--workspace', args[++i]); + } else if (a.startsWith('--workspace=')) { + workspace = expectInlineValue('--workspace', a.slice('--workspace='.length)); + } else { + die(`logout: unknown argument "${a}"`); + } + } + return { + ...(workspace ? { workspace } : {}) + }; +} + +type LoginWorkspace = { + id: string; + slug?: string; + name?: string; +}; + +async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise { + const client = deployCommandDeps.createCloudApiClient(auth, apiUrl); + const res = await client.fetch('/api/v1/workspaces'); + if (res.ok) { + return parseWorkspaceList(await res.json().catch(() => null)); + } + if (res.status !== 404 && res.status !== 405) { + throw new Error(`workspace list failed: ${res.status} ${await res.text().catch(() => '')}`.trim()); + } + + const who = await client.fetch('/api/v1/auth/whoami'); + if (!who.ok) return []; + return parseWorkspaceList(await who.json().catch(() => null)); +} + +async function pickWorkspaceInteractive( + workspaces: readonly LoginWorkspace[], + io: ReturnType +): Promise { + if (workspaces.length === 1) { + return workspaceKey(workspaces[0]); + } + if (workspaces.length > 1) { + for (let i = 0; i < workspaces.length; i += 1) { + const ws = workspaces[i]; + io.info(`[${i + 1}] ${workspaceKey(ws)}${ws.name ? ` (${ws.name})` : ''}`); + } + const answer = await io.prompt('Workspace', { defaultValue: '1' }); + const index = Number(answer.trim()); + if (Number.isInteger(index) && index >= 1 && index <= workspaces.length) { + return workspaceKey(workspaces[index - 1]); + } + const found = findWorkspace(workspaces, answer.trim()); + if (found) return workspaceKey(found); + throw new Error(`unknown workspace "${answer}"`); + } + const answer = await io.prompt('Workspace'); + if (!answer.trim()) { + throw new Error('workspace is required'); + } + return answer.trim(); +} + +function parseWorkspaceList(payload: unknown): LoginWorkspace[] { + const candidates = Array.isArray(payload) + ? payload + : payload && typeof payload === 'object' && Array.isArray((payload as { workspaces?: unknown }).workspaces) + ? (payload as { workspaces: unknown[] }).workspaces + : payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items) + ? (payload as { items: unknown[] }).items + : payload && typeof payload === 'object' && (payload as { currentWorkspace?: unknown }).currentWorkspace + ? [(payload as { currentWorkspace: unknown }).currentWorkspace] + : []; + return candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return []; + const record = candidate as Record; + const id = readString(record, 'id') ?? readString(record, 'workspaceId'); + if (!id) return []; + return [{ + id, + ...(readString(record, 'slug') ? { slug: readString(record, 'slug') } : {}), + ...(readString(record, 'name') ? { name: readString(record, 'name') } : {}) + }]; + }); +} + +function findWorkspace(workspaces: readonly LoginWorkspace[], value: string): LoginWorkspace | undefined { + return workspaces.find((workspace) => [workspace.id, workspace.slug, workspace.name].includes(value)); +} + +function workspaceKey(workspace: LoginWorkspace): string { + return workspace.slug ?? workspace.id; +} + +function readWorkspaceToken(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('workspace token response was not an object'); + } + const record = value as Record; + const token = readString(record, 'token') ?? readString(record, 'key'); + if (!token) { + throw new Error('workspace token response did not include a token'); + } + return token; +} + +function readWorkspaceId(value: unknown): string | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const direct = readString(record, 'workspaceId'); + if (direct) return direct; + const nested = record.workspaceToken; + if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return undefined; + return readString(nested as Record, 'workspaceId'); +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + function normalizeCloudUrl(url: string): string { const trimmed = url.trim(); - return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL; + return trimmed ? trimmed.replace(/\/+$/, '') : defaultApiUrl().replace(/\/+$/, ''); } function die(message: string): never { diff --git a/packages/cli/src/list-command.test.ts b/packages/cli/src/list-command.test.ts new file mode 100644 index 00000000..a66878ae --- /dev/null +++ b/packages/cli/src/list-command.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatDeploymentsTable, parseDeploymentListArgs } from './list-command.js'; + +test('parseDeploymentListArgs accepts deployment list filters', () => { + assert.deepEqual( + parseDeploymentListArgs([ + '--workspace', + 'ws-1', + '--status=active', + '--persona', + 'weekly-digest', + '--cloud-url', + 'https://cloud.example.test', + '--json', + '--no-prompt' + ]), + { + workspace: 'ws-1', + status: 'active', + persona: 'weekly-digest', + cloudUrl: 'https://cloud.example.test', + json: true, + noPrompt: true + } + ); +}); + +test('formatDeploymentsTable renders agent rows', () => { + const out = formatDeploymentsTable([ + { + agentId: 'b2f111111111111111111111e8c2', + personaId: 'weekly-digest', + deployedName: 'Weekly Digest', + status: 'active', + createdAt: '2026-05-13T09:11:00.000Z', + lastUsedAt: null, + scheduleIds: ['sched-1'], + deployedByUserId: 'user-1' + } + ]); + assert.match(out, /agentId\s+persona\s+status\s+deployed\s+lastUsed/); + assert.match(out, /b2f1\.\.\.e8c2/); + assert.match(out, /weekly-digest/); + assert.match(out, /2026-05-13 09:11 UTC/); +}); diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts new file mode 100644 index 00000000..6a83848f --- /dev/null +++ b/packages/cli/src/list-command.ts @@ -0,0 +1,258 @@ +import { + createTerminalIO, + resolveWorkspaceToken +} from '@agentworkforce/deploy'; + +const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; + +type DeploymentListOptions = { + workspace?: string; + status?: string; + persona?: string; + json?: boolean; + cloudUrl?: string; + noPrompt?: boolean; +}; + +type DeploymentAgent = { + agentId: string; + personaId: string; + deployedName: string; + status: string; + createdAt: string; + lastUsedAt: string | null; + scheduleIds: string[]; + deployedByUserId: string; +}; + +type ListResponse = { + agents?: unknown; +}; + +export async function runDeploymentList(args: readonly string[]): Promise { + if (args.length > 0 && (args[0] === '-h' || args[0] === '--help')) { + process.stdout.write(LIST_USAGE); + process.exit(0); + } + + try { + const opts = parseDeploymentListArgs(args); + const io = createTerminalIO(); + const cloudUrl = normalizeCloudUrl( + opts.cloudUrl + ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL + ?? process.env.WORKFORCE_CLOUD_URL + ?? DEFAULT_CLOUD_URL + ); + const auth = await resolveWorkspaceToken({ + workspace: opts.workspace, + cloudUrl, + io, + noPrompt: opts.noPrompt + }); + const workspace = auth.workspace?.trim() || opts.workspace?.trim(); + if (!workspace) { + throw new Error('workspace is required: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `agentworkforce login`'); + } + + const url = new URL(`${cloudUrl}/api/v1/workspaces/${encodeURIComponent(workspace)}/deployments`); + if (opts.status) url.searchParams.set('status', opts.status); + if (opts.persona) url.searchParams.set('personaId', opts.persona); + + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${auth.token}`, + 'user-agent': 'agentworkforce-cli' + } + }); + if (res.status === 401) { + throw new Error('unauthorized. Run `agentworkforce login` and retry.'); + } + if (!res.ok) { + throw new Error(`list failed: ${res.status} ${await res.text().catch(() => '')}`.trim()); + } + const agents = parseAgents((await res.json()) as ListResponse); + if (opts.json) { + process.stdout.write(`${JSON.stringify({ agents }, null, 2)}\n`); + } else { + process.stdout.write(formatDeploymentsTable(agents)); + process.stdout.write(`\n${agents.length} agent(s).\n`); + } + process.exit(0); + } catch (err) { + process.stderr.write( + `\nagentworkforce list failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); + } +} + +export function parseDeploymentListArgs(args: readonly string[]): DeploymentListOptions { + let workspace: string | undefined; + let status: string | undefined; + let persona: string | undefined; + let json = false; + let cloudUrl: string | undefined; + let noPrompt = false; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--json') { + json = true; + } else if (arg === '--status') { + status = expectValue('--status', args[++i]); + } else if (arg.startsWith('--status=')) { + status = expectInlineValue('--status', arg.slice('--status='.length)); + } else if (arg === '--persona') { + persona = expectValue('--persona', args[++i]); + } else if (arg.startsWith('--persona=')) { + persona = expectInlineValue('--persona', arg.slice('--persona='.length)); + } else if (arg === '--workspace') { + workspace = expectValue('--workspace', args[++i]); + } else if (arg.startsWith('--workspace=')) { + workspace = expectInlineValue('--workspace', arg.slice('--workspace='.length)); + } else if (arg === '--cloud-url') { + cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (arg.startsWith('--cloud-url=')) { + cloudUrl = expectInlineValue('--cloud-url', arg.slice('--cloud-url='.length)); + } else if (arg === '--no-prompt') { + noPrompt = true; + } else { + throw new Error(`list: unexpected argument "${arg}"`); + } + } + + return { + ...(workspace ? { workspace } : {}), + ...(status ? { status } : {}), + ...(persona ? { persona } : {}), + ...(json ? { json: true } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + ...(noPrompt ? { noPrompt: true } : {}) + }; +} + +export function formatDeploymentsTable(agents: readonly DeploymentAgent[]): string { + if (agents.length === 0) return 'No deployed agents found.\n'; + const rows = agents.map((agent) => ({ + agentId: compactId(agent.agentId), + persona: agent.personaId || agent.deployedName, + status: agent.status, + deployed: formatDate(agent.createdAt), + lastUsed: agent.lastUsedAt ? formatRelative(agent.lastUsedAt) : '-' + })); + const widths = { + agentId: Math.max('agentId'.length, ...rows.map((r) => r.agentId.length)), + persona: Math.max('persona'.length, ...rows.map((r) => r.persona.length)), + status: Math.max('status'.length, ...rows.map((r) => r.status.length)), + deployed: Math.max('deployed'.length, ...rows.map((r) => r.deployed.length)), + lastUsed: Math.max('lastUsed'.length, ...rows.map((r) => r.lastUsed.length)) + }; + const header = [ + pad('agentId', widths.agentId), + pad('persona', widths.persona), + pad('status', widths.status), + pad('deployed', widths.deployed), + pad('lastUsed', widths.lastUsed) + ].join(' '); + const body = rows.map((row) => [ + pad(row.agentId, widths.agentId), + pad(row.persona, widths.persona), + pad(row.status, widths.status), + pad(row.deployed, widths.deployed), + pad(row.lastUsed, widths.lastUsed) + ].join(' ')); + return `${[header, ...body].join('\n')}\n`; +} + +function parseAgents(body: ListResponse): DeploymentAgent[] { + const raw = Array.isArray(body) ? body : Array.isArray(body.agents) ? body.agents : []; + return raw.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('list response contained an invalid agent entry'); + } + const record = value as Record; + return { + agentId: readString(record, 'agentId') ?? readString(record, 'id') ?? '', + personaId: readString(record, 'personaId') ?? readString(record, 'persona') ?? '', + deployedName: readString(record, 'deployedName') ?? readString(record, 'name') ?? '', + status: readString(record, 'status') ?? 'unknown', + createdAt: readString(record, 'createdAt') ?? '', + lastUsedAt: readNullableString(record, 'lastUsedAt'), + scheduleIds: Array.isArray(record.scheduleIds) + ? record.scheduleIds.filter((id): id is string => typeof id === 'string') + : [], + deployedByUserId: readString(record, 'deployedByUserId') ?? '' + }; + }).filter((agent) => agent.agentId); +} + +function compactId(id: string): string { + return id.length <= 12 ? id : `${id.slice(0, 4)}...${id.slice(-4)}`; +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`; +} + +function formatRelative(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function pad(value: string, width: number): string { + return value.padEnd(width, ' '); +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readNullableString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function normalizeCloudUrl(url: string): string { + const trimmed = url.trim(); + return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL; +} + +function expectValue(flag: string, value: string | undefined): string { + if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { + throw new Error(`${flag}: missing value`); + } + return value; +} + +function expectInlineValue(flag: string, value: string): string { + if (!value.trim()) { + throw new Error(`${flag}: missing value`); + } + return value; +} + +const LIST_USAGE = `usage: agentworkforce list [flags] + +List deployed cloud agents in the active workspace. + +Flags: + --workspace Workforce workspace; defaults to the logged-in workspace + --status Filter by deployment status + --persona Filter by persona id/slug + --json Emit JSON instead of a table + --cloud-url Override the workforce cloud base URL + --no-prompt Fail instead of prompting for cloud setup + -h, --help Print this message +`; diff --git a/packages/cli/src/runtime-picker.test.ts b/packages/cli/src/runtime-picker.test.ts new file mode 100644 index 00000000..27f1846e --- /dev/null +++ b/packages/cli/src/runtime-picker.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable, Writable } from 'node:stream'; +import { pickRuntime } from './runtime-picker.js'; + +function input(value: string): Readable { + return Readable.from([value]); +} + +function output(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); +} + +test('pickRuntime maps numeric choices to deploy modes', async () => { + assert.equal(await pickRuntime({ input: input('1\n'), output: output() }), 'cloud'); + assert.equal(await pickRuntime({ input: input('2\n'), output: output() }), 'sandbox'); + assert.equal(await pickRuntime({ input: input('3\n'), output: output() }), 'dev'); +}); + +test('pickRuntime defaults to cloud and returns docs for build-your-own', async () => { + assert.equal(await pickRuntime({ input: input('\n'), output: output() }), 'cloud'); + assert.equal(await pickRuntime({ input: input('4\n'), output: output() }), 'docs'); +}); + +test('pickRuntime rejects unknown choices', async () => { + await assert.rejects( + pickRuntime({ input: input('9\n'), output: output() }), + /expected 1, 2, 3, or 4/ + ); +}); diff --git a/packages/cli/src/runtime-picker.ts b/packages/cli/src/runtime-picker.ts new file mode 100644 index 00000000..9e725d46 --- /dev/null +++ b/packages/cli/src/runtime-picker.ts @@ -0,0 +1,44 @@ +import { createInterface } from 'node:readline/promises'; +import { stdin as defaultStdin, stdout as defaultStdout } from 'node:process'; +import type { DeployMode } from '@agentworkforce/deploy'; + +export const BUILD_YOUR_OWN_RUNTIME_DOCS_URL = 'https://agentrelay.com/docs/runtimes'; + +export type RuntimePickerResult = DeployMode | 'docs'; + +export async function pickRuntime(opts: { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +} = {}): Promise { + const input = opts.input ?? defaultStdin; + const output = opts.output ?? defaultStdout; + output.write( + [ + 'Which runtime should this persona run on?', + ' [1] AgentRelay (recommended) - managed cloud, schedules + integrations + memory wired', + ' [2] Local sandbox - runs in a local Daytona container', + ' [3] Local dev - runs directly on your machine', + ' [4] Build your own - docs at https://agentrelay.com/docs/runtimes', + '' + ].join('\n') + ); + + const rl = createInterface({ input, output }); + try { + const answer = (await rl.question('> ')).trim() || '1'; + switch (answer) { + case '1': + return 'cloud'; + case '2': + return 'sandbox'; + case '3': + return 'dev'; + case '4': + return 'docs'; + default: + throw new Error(`runtime picker: expected 1, 2, 3, or 4; got "${answer}"`); + } + } finally { + rl.close(); + } +} diff --git a/packages/deploy/package.json b/packages/deploy/package.json index fd0718ff..0fbcb8f6 100644 --- a/packages/deploy/package.json +++ b/packages/deploy/package.json @@ -32,6 +32,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@agent-relay/cloud": "^6.0.17", "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*", "@daytonaio/sdk": "^0.148.0", diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts new file mode 100644 index 00000000..4bad3a3f --- /dev/null +++ b/packages/deploy/src/connect.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { relayfileIntegrationResolver } from './connect.js'; +import { createBufferedIO } from './io.js'; + +function okJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +test('relayfileIntegrationResolver isConnected reads the cloud integration list', async () => { + const urls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async (url) => { + urls.push(String(url)); + return okJson([ + { provider: 'github', status: 'ready', connectionId: 'conn-1' } + ]); + } + }); + assert.equal(await resolver.isConnected({ workspace: 'ws-runtime', provider: 'github' }), true); + assert.equal(await resolver.isConnected({ workspace: 'ws-runtime', provider: 'notion' }), false); + assert.deepEqual(urls, [ + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations', + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations' + ]); +}); + +test('relayfileIntegrationResolver connect opens a session and polls until connected', async () => { + let polls = 0; + const opened: string[] = []; + const urls: string[] = []; + const io = createBufferedIO(); + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + io, + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: (url) => { + opened.push(url); + }, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + urls.push(url); + if (url.endsWith('/integrations/connect-session')) { + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { + allowedIntegrations: ['notion'] + }); + return okJson({ connectLink: 'https://connect.example.test/session', connectionId: 'conn-1' }); + } + if (url.includes('/integrations/notion/status')) { + polls += 1; + return okJson( + polls < 3 + ? { ready: false, state: 'pending' } + : { ready: true, state: 'ready', currentConnectionId: 'conn-1' } + ); + } + throw new Error(`unexpected URL ${url}`); + } + }); + + assert.deepEqual(await resolver.connect({ workspace: 'ws-runtime', provider: 'notion' }), { + connectionId: 'conn-1' + }); + assert.deepEqual(opened, ['https://connect.example.test/session']); + assert.ok(urls.every((url) => url.includes('/workspaces/ws-runtime/'))); + assert.equal(polls, 3); + assert.ok(io.messages.some((message) => message.message.includes('notion connected'))); +}); + +test('relayfileIntegrationResolver connect times out clearly', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 1, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + return okJson({ sessionUrl: 'https://connect.example.test/session' }); + } + return okJson({ ready: false, state: 'pending' }); + } + }); + await assert.rejects( + resolver.connect({ workspace: 'ws-1', provider: 'github' }), + /Timed out waiting for github OAuth/ + ); +}); diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 693a5e87..7f4b4a5e 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -1,3 +1,5 @@ +import { platform } from 'node:os'; +import { spawn } from 'node:child_process'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import type { DeployIO, IntegrationConnectOutcome } from './types.js'; @@ -67,6 +69,77 @@ export function envIntegrationResolver(): IntegrationConnectResolver { }; } +export function relayfileIntegrationResolver(opts: { + apiUrl: string; + workspaceId: string; + workspaceToken: string; + io?: Pick; + pollIntervalMs?: number; + timeoutMs?: number; + fetch?: typeof fetch; + openUrl?: (url: string) => void | Promise; + sleep?: (ms: number) => Promise; +}): IntegrationConnectResolver { + const fetchImpl = opts.fetch ?? fetch; + const io = opts.io; + const sleepImpl = opts.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const apiUrl = opts.apiUrl.replace(/\/+$/, ''); + + return { + async isConnected({ workspace, provider }) { + const workspaceId = workspace || opts.workspaceId; + const body = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent( + workspaceId + )}/integrations`, opts.workspaceToken); + return listHasConnectedProvider(body, provider); + }, + async connect({ workspace, provider }) { + const workspaceId = workspace || opts.workspaceId; + const session = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent( + workspaceId + )}/integrations/connect-session`, opts.workspaceToken, { + method: 'POST', + body: JSON.stringify({ allowedIntegrations: [provider] }) + }); + const sessionUrl = readString(session, 'sessionUrl') + ?? readString(session, 'connectLink') + ?? readString(session, 'url'); + if (!sessionUrl) { + throw new Error(`integration ${provider} connect-session did not return a session URL`); + } + const sessionId = readString(session, 'sessionId') ?? readString(session, 'connectionId'); + io?.info(`Connecting ${provider}: opening ${sessionUrl}`); + try { + await (opts.openUrl ?? openBrowser)(sessionUrl); + } catch (err) { + io?.warn?.(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`); + } + + const deadline = Date.now() + (opts.timeoutMs ?? 5 * 60_000); + while (Date.now() < deadline) { + await sleepImpl(opts.pollIntervalMs ?? 2_000); + const statusUrl = new URL(`${apiUrl}/api/v1/workspaces/${encodeURIComponent( + workspaceId + )}/integrations/${encodeURIComponent(provider)}/status`); + if (sessionId) statusUrl.searchParams.set('connectionId', sessionId); + const status = await requestJson(fetchImpl, statusUrl.toString(), opts.workspaceToken); + if (isConnectedStatus(status)) { + const connectionId = readString(status, 'connectionId') + ?? readString(status, 'currentConnectionId') + ?? sessionId + ?? provider; + io?.info(`${provider} connected.`); + return { connectionId }; + } + } + + throw new Error( + `Timed out waiting for ${provider} OAuth to complete. Re-run \`agentworkforce deploy ...\` after connecting.` + ); + } + }; +} + function providerHasEnvCredentials(provider: string): boolean { const upper = provider.toUpperCase(); return Boolean( @@ -127,12 +200,12 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { + const res = await fetchImpl(url, { + ...init, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + ...(init.headers ?? {}) + } + }); + if (res.status === 401) { + throw new Error('cloud integration request failed: unauthorized. Run `agentworkforce login` and retry.'); + } + if (!res.ok) { + throw new Error(`cloud integration request failed: ${res.status} ${await res.text().catch(() => '')}`.trim()); + } + return await res.json(); +} + +function listHasConnectedProvider(body: unknown, provider: string): boolean { + const candidates = Array.isArray(body) + ? body + : body && typeof body === 'object' && Array.isArray((body as { integrations?: unknown }).integrations) + ? (body as { integrations: unknown[] }).integrations + : []; + return candidates.some((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return false; + const record = item as Record; + return record.provider === provider && isConnectedStatus(record); + }); +} + +function isConnectedStatus(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return record.status === 'connected' + || record.status === 'active' + || record.status === 'ready' + || record.state === 'connected' + || record.state === 'ready' + || record.ready === true + || Boolean(record.connectionId) + || Boolean(record.currentConnectionId) + || (record.oauth !== null + && typeof record.oauth === 'object' + && (record.oauth as { connected?: unknown }).connected === true); +} + +function readString(value: unknown, field: string): string | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = (value as Record)[field]; + return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +} + +function openBrowser(url: string): void { + const command = platform() === 'darwin' + ? 'open' + : platform() === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => { + // The URL is printed by the caller; browser launch is best-effort. + }); + child.unref(); +} diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index cb374e28..5343723a 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -181,7 +181,7 @@ test('deploy fails clearly when integration is not connected and --no-connect is ); assert.ok( io.messages.find( - (m) => m.level === 'error' && m.message.includes('--no-connect was passed') + (m) => m.level === 'error' && m.message.includes('prompts are disabled') ) ); } finally { @@ -395,14 +395,19 @@ test('--mode cloud skips local integration resolver and hands off to the cloud l } }); -test('--mode cloud does not require an env workspace token before launching', async () => { +test('--mode cloud uses the workspace token resolver before launching', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); const io = createBufferedIO(); let launched = false; try { - const result = await withWorkspaceEnv({ workspace: 'w-cloud' }, () => deploy( + const result = await withWorkspaceEnv({}, () => deploy( { personaPath, mode: 'cloud', io }, { + workspaceAuth: { + async resolveWorkspace() { + return { workspace: 'w-cloud', token: 'tok-cloud' }; + } + }, bundle: { async stage(input) { await mkdir(input.outDir, { recursive: true }); @@ -430,7 +435,7 @@ test('--mode cloud does not require an env workspace token before launching', as async launch(input) { launched = true; assert.equal(input.workspace, 'w-cloud'); - assert.equal(input.workspaceToken, undefined); + assert.equal(input.workspaceToken, 'tok-cloud'); return { id: 'agent-cloud', async stop() { diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index d2dc12fc..36feffb9 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -1,9 +1,11 @@ import path from 'node:path'; import { mkdir } from 'node:fs/promises'; +import { defaultApiUrl } from '@agent-relay/cloud'; import { bundleStager } from './bundle.js'; import { connectIntegrations, envIntegrationResolver, + relayfileIntegrationResolver, type ConnectAllInput, type IntegrationConnectResolver, type ProviderSubscriptionResolver @@ -131,24 +133,26 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { } const mode: DeployMode = opts.mode ?? pickMode(opts); - const { workspace, token } = mode === 'cloud' && !resolvers.workspaceAuth - ? resolveCloudWorkspaceIdentity(opts, io) - : await (resolvers.workspaceAuth ?? envWorkspaceAuth()).resolveWorkspace({ - override: opts.workspace, - io - }); + const { workspace, token } = await (resolvers.workspaceAuth ?? envWorkspaceAuth()).resolveWorkspace({ + override: opts.workspace, + io + }); io.info(`workspace: ${workspace}`); - const connectedIntegrations = mode === 'cloud' - ? preflight.integrations - : await connectAndCollectIntegrations({ - persona: preflight.persona, - workspace, - noConnect: opts.noConnect === true, - io, - integrations: resolvers.integrations ?? envIntegrationResolver(), - ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}) - }); + const connectedIntegrations = await connectAndCollectIntegrations({ + persona: preflight.persona, + workspace, + noConnect: opts.noConnect === true, + io, + integrations: resolvers.integrations ?? defaultIntegrationResolver({ + mode, + workspace, + token, + cloudUrl: opts.cloudUrl, + io + }), + ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}) + }); const bundleDir = path.resolve( path.join(preflight.personaDir, '.workforce', 'build', preflight.persona.id) @@ -207,25 +211,6 @@ function resolveLauncher(mode: DeployMode, resolvers: DeployResolvers): ModeLaun } } -function resolveCloudWorkspaceIdentity( - opts: DeployOptions, - io: DeployIO -): { workspace: string; token?: string } { - const workspace = (opts.workspace ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); - if (!workspace) { - io.error( - 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `workforce login`' - ); - throw new Error('workspace is required for cloud deploy'); - } - - const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); - return { - workspace, - ...(token ? { token } : {}) - }; -} - async function connectAndCollectIntegrations(input: ConnectAllInput): Promise { const connectResult = await connectIntegrations(input); const failed = connectResult.outcomes.filter((o) => o.status === 'failed'); @@ -245,6 +230,32 @@ async function connectAndCollectIntegrations(input: ConnectAllInput): Promise o.provider); } +function defaultIntegrationResolver(args: { + mode: DeployMode; + workspace: string; + token: string; + cloudUrl?: string; + io: DeployIO; +}): IntegrationConnectResolver { + if (args.mode !== 'cloud') return envIntegrationResolver(); + return relayfileIntegrationResolver({ + apiUrl: normalizeCloudUrl( + args.cloudUrl + ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL + ?? process.env.WORKFORCE_CLOUD_URL + ?? defaultApiUrl() + ), + workspaceId: args.workspace, + workspaceToken: args.token, + io: args.io + }); +} + +function normalizeCloudUrl(url: string): string { + const trimmed = url.trim(); + return trimmed ? trimmed.replace(/\/+$/, '') : defaultApiUrl().replace(/\/+$/, ''); +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 6dbb7e18..b60b8bba 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -19,6 +19,7 @@ export { preflightPersona }; export { connectIntegrations, envIntegrationResolver, + relayfileIntegrationResolver, type ConnectAllInput, type ConnectAllResult, type IntegrationConnectResolver, @@ -26,11 +27,13 @@ export { } from './connect.js'; export { envWorkspaceAuth, + clearStoredWorkspaceToken, + loadActiveWorkspaceToken, loadWorkspaceToken, - loginWithBrowser, resolveWorkspaceToken, resolveWorkspaceTokenFromEnv, storeWorkspaceToken, + writeStoredWorkspaceToken, type StoredWorkspaceLogin, type WorkspaceAuth, type WorkspaceAuthToken diff --git a/packages/deploy/src/login.test.ts b/packages/deploy/src/login.test.ts new file mode 100644 index 00000000..014981bd --- /dev/null +++ b/packages/deploy/src/login.test.ts @@ -0,0 +1,140 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + clearStoredWorkspaceToken, + loadWorkspaceToken, + resolveWorkspaceToken, + writeStoredWorkspaceToken +} from './login.js'; +import { createBufferedIO } from './io.js'; + +async function withLoginEnv( + env: { + loginFile?: string; + workspaceId?: string; + workspaceToken?: string; + }, + fn: () => Promise +): Promise { + const previous = { + WORKFORCE_LOGIN_FILE: process.env.WORKFORCE_LOGIN_FILE, + WORKFORCE_DISABLE_KEYCHAIN: process.env.WORKFORCE_DISABLE_KEYCHAIN, + WORKFORCE_WORKSPACE_ID: process.env.WORKFORCE_WORKSPACE_ID, + WORKFORCE_WORKSPACE_TOKEN: process.env.WORKFORCE_WORKSPACE_TOKEN + }; + process.env.WORKFORCE_DISABLE_KEYCHAIN = '1'; + if (env.loginFile === undefined) delete process.env.WORKFORCE_LOGIN_FILE; + else process.env.WORKFORCE_LOGIN_FILE = env.loginFile; + if (env.workspaceId === undefined) delete process.env.WORKFORCE_WORKSPACE_ID; + else process.env.WORKFORCE_WORKSPACE_ID = env.workspaceId; + if (env.workspaceToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; + else process.env.WORKFORCE_WORKSPACE_TOKEN = env.workspaceToken; + + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key as keyof typeof previous]; + } else { + process.env[key as keyof typeof previous] = value; + } + } + } +} + +test('workspace token store writes and reads the active workspace token', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-store-')); + const loginFile = path.join(dir, 'login.json'); + try { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ + workspaceSlug: 'acme', + workspaceId: 'ws-123', + token: 'tok-stored', + cloudUrl: 'https://cloud.example.test' + }); + const raw = JSON.parse(await readFile(loginFile, 'utf8')) as Record; + assert.equal(raw.workspace, 'acme'); + assert.equal(raw.workspaceId, 'ws-123'); + assert.equal(raw.token, 'tok-stored'); + assert.equal((await loadWorkspaceToken('acme'))?.token, 'tok-stored'); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken prefers env token before stored login', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-precedence-')); + const loginFile = path.join(dir, 'login.json'); + try { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); + }); + await withLoginEnv({ + loginFile, + workspaceId: 'env-ws', + workspaceToken: 'tok-env' + }, async () => { + assert.deepEqual( + await resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO() + }), + { token: 'tok-env', workspace: 'env-ws' } + ); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken reads stored token and fails clearly with --no-prompt', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-resolve-')); + const loginFile = path.join(dir, 'login.json'); + try { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); + assert.deepEqual( + await resolveWorkspaceToken({ + workspace: 'stored', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + { token: 'tok-stored', workspace: 'stored' } + ); + }); + await withLoginEnv({ loginFile: path.join(dir, 'missing.json') }, async () => { + await assert.rejects( + resolveWorkspaceToken({ + workspace: 'missing', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + /run `agentworkforce login`/ + ); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('clearStoredWorkspaceToken removes the stored token file', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-clear-')); + const loginFile = path.join(dir, 'login.json'); + try { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); + await clearStoredWorkspaceToken('stored'); + assert.equal(await loadWorkspaceToken('stored'), null); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 437f8e0b..4b8e811b 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -1,15 +1,18 @@ -import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { readFile, mkdir, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import { homedir, platform } from 'node:os'; +import { readFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import path from 'node:path'; +import { + readStoredAuth, + refreshStoredAuth, + writeStoredAuth, + type StoredAuth +} from '@agent-relay/cloud'; import type { DeployIO } from './types.js'; /** * Workspace authentication primitives. The CLI layer plugs in real - * implementations that talk to relayauth + the workforce cloud API; the - * deploy package itself stays SDK-free so the contract is easy to mock. + * implementations that talk to relayauth + the workforce cloud API; this + * module only resolves the workspace-scoped token needed by deploy modes. */ export interface WorkspaceAuth { /** Resolve the active workspace, prompting the user to pick one if needed. */ @@ -26,13 +29,30 @@ export interface WorkspaceAuthToken { export interface StoredWorkspaceLogin { workspace?: string; + workspaceSlug?: string; + workspaceId?: string; token: string; refreshToken?: string; expiresAt?: string; cloudUrl?: string; } -const LOGIN_FILE = path.join(homedir(), '.agentworkforce', 'login.json'); +type WorkforceStoredAuth = StoredAuth & { + workforce?: { + activeWorkspace?: string; + workspaceTokens?: Record; + }; + workspace?: string; + workspaceSlug?: string; + workspaceId?: string; + workspaceToken?: string; + workforceWorkspaceToken?: StoredWorkspaceLogin; +}; + +function loginFile(): string { + return process.env.WORKFORCE_LOGIN_FILE?.trim() + || path.join(homedir(), '.agentworkforce', 'login.json'); +} /** * Environment-backed fallback resolver: reads `WORKFORCE_WORKSPACE_ID` @@ -48,19 +68,27 @@ export function envWorkspaceAuth(): WorkspaceAuth { // error here. const workspace = (override ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); + if (workspace && token) { + return { workspace, token }; + } + + const stored = await loadWorkspaceToken(workspace || undefined); + if (stored) { + const storedWorkspace = storedWorkspaceName(stored); + if (storedWorkspace) return { workspace: storedWorkspace, token: stored.token }; + } + if (!workspace) { io.error( - 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `workforce login`' + 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN, or run `agentworkforce login`' ); throw new Error('workspace is required for deploy'); } - if (!token) { - io.error( - 'no workspace token resolved: set WORKFORCE_WORKSPACE_TOKEN, or run `workforce login` to mint one' - ); - throw new Error('workspace token is required for deploy'); - } - return { workspace, token }; + + io.error( + `no workspace token resolved for ${workspace}: set WORKFORCE_WORKSPACE_TOKEN, or run \`agentworkforce login\`` + ); + throw new Error('workspace token is required for deploy'); } }; } @@ -82,211 +110,191 @@ export function resolveWorkspaceTokenFromEnv(workspace: string): WorkspaceAuthTo } export async function resolveWorkspaceToken(args: { - workspace: string; + workspace?: string; cloudUrl: string; io: DeployIO; noPrompt?: boolean; -}): Promise { +}): Promise { + const envWorkspace = (process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); - if (fromEnv) return { token: fromEnv }; + const requestedWorkspace = (args.workspace ?? '').trim(); + if (fromEnv && (requestedWorkspace || envWorkspace)) { + return { + token: fromEnv, + workspace: requestedWorkspace || envWorkspace + }; + } - const stored = await loadWorkspaceToken(args.workspace); - if (stored) return { token: stored.token }; + const stored = await loadWorkspaceToken(requestedWorkspace || undefined, args.cloudUrl); + if (stored) { + return { + token: stored.token, + ...(storedWorkspaceName(stored) ? { workspace: storedWorkspaceName(stored) } : {}) + }; + } if (args.noPrompt) { throw new Error( - `no workspace token resolved for ${args.workspace}: run \`workforce login\` or set WORKFORCE_WORKSPACE_TOKEN` + `no workspace token resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` ); } - args.io.info('cloud: no workspace token found; opening workforce login'); - const login = await loginWithBrowser({ - cloudUrl: args.cloudUrl, - workspace: args.workspace, - io: args.io - }); - return { token: login.token }; + args.io.info('cloud: no workspace token found; run `agentworkforce login` to connect this machine'); + throw new Error( + `no workspace token resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` + ); } -export async function loadWorkspaceToken(workspace: string): Promise { - const fromKeychain = await readMacKeychainLogin(workspace); - if (fromKeychain && !isExpired(fromKeychain.expiresAt)) { - return fromKeychain; +export async function loadWorkspaceToken( + workspace?: string, + cloudUrl?: string +): Promise { + const fromCloudAuth = await readWorkspaceTokenFromCloudAuth(workspace, cloudUrl); + if (fromCloudAuth && !isExpired(fromCloudAuth.expiresAt)) { + return fromCloudAuth; } const fromFile = await readLoginFile(); - if (fromFile && workspaceMatches(fromFile, workspace) && !isExpired(fromFile.expiresAt)) { + if ( + fromFile + && workspaceMatches(fromFile, workspace) + && cloudUrlMatches(fromFile, cloudUrl) + && !isExpired(fromFile.expiresAt) + ) { return fromFile; } return null; } +export async function loadActiveWorkspaceToken(): Promise { + return loadWorkspaceToken(undefined); +} + export async function storeWorkspaceToken(login: StoredWorkspaceLogin): Promise { - if (await writeMacKeychainLogin(login)) return; + if (await writeWorkspaceTokenToCloudAuth(login)) return; - await mkdir(path.dirname(LOGIN_FILE), { recursive: true, mode: 0o700 }); - await writeFile(LOGIN_FILE, `${JSON.stringify(login, null, 2)}\n`, { + const file = loginFile(); + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await writeFile(file, `${JSON.stringify(login, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); } -export async function loginWithBrowser(args: { - cloudUrl: string; - workspace: string; - io: DeployIO; -}): Promise { - const state = randomUUID(); - - return await new Promise((resolve, reject) => { - let settled = false; - const server = createServer((request, response) => { - const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); - if (requestUrl.pathname !== '/callback') { - response.statusCode = 404; - response.end('not found'); - return; - } - - if (requestUrl.searchParams.get('state') !== state) { - response.statusCode = 400; - response.end('invalid state'); - settleError(new Error('login callback returned an invalid state')); - return; - } - - const error = requestUrl.searchParams.get('error'); - if (error) { - response.statusCode = 400; - response.end('login failed'); - settleError(new Error(error)); - return; - } - - const token = requestUrl.searchParams.get('access_token')?.trim(); - const refreshToken = requestUrl.searchParams.get('refresh_token')?.trim() || undefined; - const expiresAt = requestUrl.searchParams.get('access_token_expires_at')?.trim() || undefined; - const cloudUrl = requestUrl.searchParams.get('api_url')?.trim() || args.cloudUrl; - if (!token) { - response.statusCode = 400; - response.end('missing token'); - settleError(new Error('login callback did not include a workspace token')); - return; - } - - response.statusCode = 200; - response.end('workforce login complete; you can close this tab'); - const login: StoredWorkspaceLogin = { - workspace: args.workspace, - token, - cloudUrl, - ...(refreshToken ? { refreshToken } : {}), - ...(expiresAt ? { expiresAt } : {}) - }; - void storeWorkspaceToken(login).finally(() => settle(login)); - }); - - function settle(login: StoredWorkspaceLogin): void { - if (settled) return; - settled = true; - server.close(); - resolve(login); - } - - function settleError(error: Error): void { - if (settled) return; - settled = true; - server.close(); - reject(error); - } - - server.on('error', settleError); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - settleError(new Error('failed to start local login callback server')); - return; - } - - const callback = new URL('/callback', `http://127.0.0.1:${address.port}`); - const loginUrl = new URL('/cli-auth', args.cloudUrl); - loginUrl.searchParams.set('redirect_uri', callback.toString()); - loginUrl.searchParams.set('state', state); - - args.io.info(`cloud: login URL ${loginUrl.toString()}`); - tryOpenBrowser(loginUrl.toString()); - }); - - setTimeout(() => settleError(new Error('timed out waiting for workforce login')), 5 * 60_000).unref(); +export async function writeStoredWorkspaceToken(login: { + workspaceSlug?: string; + workspaceId?: string; + workspace?: string; + token: string; + cloudUrl?: string; + expiresAt?: string; +}): Promise { + const workspace = login.workspace ?? login.workspaceSlug ?? login.workspaceId; + await storeWorkspaceToken({ + token: login.token, + ...(workspace ? { workspace } : {}), + ...(login.workspaceSlug ? { workspaceSlug: login.workspaceSlug } : {}), + ...(login.workspaceId ? { workspaceId: login.workspaceId } : {}), + ...(login.cloudUrl ? { cloudUrl: login.cloudUrl } : {}), + ...(login.expiresAt ? { expiresAt: login.expiresAt } : {}) }); } +export async function clearStoredWorkspaceToken(workspace?: string): Promise { + await clearWorkspaceTokenFromCloudAuth(workspace); + await rm(loginFile(), { force: true }); +} + async function readLoginFile(): Promise { - const raw = await readFile(LOGIN_FILE, 'utf8').catch(() => ''); + const raw = await readFile(loginFile(), 'utf8').catch(() => ''); if (!raw.trim()) return null; return parseStoredLogin(raw); } -async function readMacKeychainLogin(workspace: string): Promise { - if (platform() !== 'darwin') return null; - const serviceNames = [ - `agentworkforce:${workspace}`, - `workforce:${workspace}`, - 'agentworkforce', - 'workforce' - ]; - - for (const service of serviceNames) { - const stdout = await execSecurity(['find-generic-password', '-s', service, '-w']); - const parsed = parseStoredLogin(stdout.trim()); - if (parsed && workspaceMatches(parsed, workspace)) return parsed; - if (parsed) continue; - if (stdout.trim()) { - return { - workspace, - token: stdout.trim() - }; +async function readWorkspaceTokenFromCloudAuth( + workspace?: string, + cloudUrl?: string +): Promise { + if (usesWorkspaceLoginFileOverride()) return null; + let auth = await readStoredAuth().catch(() => null); + if (!auth) return null; + + if (isExpired(auth.accessTokenExpiresAt)) { + try { + auth = await refreshStoredAuth(auth); + } catch { + return null; } } - return null; -} -async function writeMacKeychainLogin(login: StoredWorkspaceLogin): Promise { - if (platform() !== 'darwin') return false; - const workspace = login.workspace ?? 'default'; - const payload = JSON.stringify(login); - return await execSecurityOk([ - 'add-generic-password', - '-U', - '-s', - `agentworkforce:${workspace}`, - '-a', - workspace, - '-w', - payload - ]); + const stored = auth as WorkforceStoredAuth; + const tokens: Record = stored.workforce?.workspaceTokens ?? {}; + const active = stored.workforce?.activeWorkspace; + const candidates = workspace + ? [tokens[workspace], ...Object.values(tokens).filter((login) => workspaceMatches(login, workspace))] + : [active ? tokens[active] : undefined, stored.workforceWorkspaceToken, legacyWorkspaceToken(stored)]; + + return candidates.find((login) => Boolean(login) + && cloudUrlMatches(login as StoredWorkspaceLogin, cloudUrl) + && !isExpired((login as StoredWorkspaceLogin).expiresAt)) ?? null; } -function execSecurity(args: string[]): Promise { - return new Promise((resolve) => { - const child = spawn('security', args, { stdio: ['ignore', 'pipe', 'ignore'] }); - let stdout = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk) => { - stdout += chunk; - }); - child.on('error', () => resolve('')); - child.on('close', (code) => resolve(code === 0 ? stdout : '')); - }); +async function writeWorkspaceTokenToCloudAuth(login: StoredWorkspaceLogin): Promise { + if (usesWorkspaceLoginFileOverride()) return false; + const auth = await readStoredAuth().catch(() => null); + if (!auth) return false; + + const current = auth as WorkforceStoredAuth; + const tokens: Record = { ...(current.workforce?.workspaceTokens ?? {}) }; + const workspace = storedWorkspaceName(login); + if (!workspace) return false; + + const nextLogin = { ...login, workspace }; + for (const key of [workspace, login.workspaceSlug, login.workspaceId]) { + if (key) tokens[key] = nextLogin; + } + + await writeStoredAuth({ + ...current, + workforce: { + ...(current.workforce ?? {}), + activeWorkspace: workspace, + workspaceTokens: tokens + }, + workforceWorkspaceToken: nextLogin + } as StoredAuth); + return true; } -function execSecurityOk(args: string[]): Promise { - return new Promise((resolve) => { - const child = spawn('security', args, { stdio: 'ignore' }); - child.on('error', () => resolve(false)); - child.on('close', (code) => resolve(code === 0)); - }); +async function clearWorkspaceTokenFromCloudAuth(workspace?: string): Promise { + if (usesWorkspaceLoginFileOverride()) return; + const auth = await readStoredAuth().catch(() => null); + if (!auth) return; + + const current = auth as WorkforceStoredAuth; + let tokens: Record = { ...(current.workforce?.workspaceTokens ?? {}) }; + const target = workspace?.trim(); + if (target) { + tokens = Object.fromEntries( + Object.entries(tokens).filter(([key, login]) => key !== target && !workspaceMatches(login, target)) + ); + } else { + tokens = {}; + } + + const next: WorkforceStoredAuth = { + ...current, + workforce: { + ...(current.workforce ?? {}), + workspaceTokens: tokens + } + }; + if (!target || current.workforce?.activeWorkspace === target) { + delete next.workforce?.activeWorkspace; + delete next.workforceWorkspaceToken; + } + await writeStoredAuth(next as StoredAuth); } function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { @@ -307,9 +315,11 @@ function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { if (!token.trim()) return null; const workspace = typeof record.workspace === 'string' ? record.workspace - : typeof record.workspaceId === 'string' - ? record.workspaceId - : undefined; + : typeof record.workspaceSlug === 'string' + ? record.workspaceSlug + : typeof record.workspaceId === 'string' + ? record.workspaceId + : undefined; const expiresAt = typeof record.expiresAt === 'string' ? record.expiresAt : typeof record.accessTokenExpiresAt === 'string' @@ -318,6 +328,8 @@ function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { return { token: token.trim(), ...(workspace ? { workspace } : {}), + ...(typeof record.workspaceSlug === 'string' ? { workspaceSlug: record.workspaceSlug } : {}), + ...(typeof record.workspaceId === 'string' ? { workspaceId: record.workspaceId } : {}), ...(typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {}), ...(expiresAt ? { expiresAt } : {}), ...(typeof record.cloudUrl === 'string' ? { cloudUrl: record.cloudUrl } : {}) @@ -327,8 +339,30 @@ function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { } } -function workspaceMatches(login: StoredWorkspaceLogin, workspace: string): boolean { - return !login.workspace || login.workspace === workspace; +function legacyWorkspaceToken(auth: WorkforceStoredAuth): StoredWorkspaceLogin | undefined { + const token = auth.workspaceToken; + if (!token) return undefined; + return { + token, + ...(auth.workspace ? { workspace: auth.workspace } : {}), + ...(auth.workspaceSlug ? { workspaceSlug: auth.workspaceSlug } : {}), + ...(auth.workspaceId ? { workspaceId: auth.workspaceId } : {}), + cloudUrl: auth.apiUrl + }; +} + +function workspaceMatches(login: StoredWorkspaceLogin, workspace: string | undefined): boolean { + if (!workspace) return true; + return [login.workspace, login.workspaceSlug, login.workspaceId].some((value) => value === workspace); +} + +function cloudUrlMatches(login: StoredWorkspaceLogin, cloudUrl: string | undefined): boolean { + if (!cloudUrl || !login.cloudUrl) return true; + return normalizeUrl(login.cloudUrl) === normalizeUrl(cloudUrl); +} + +function storedWorkspaceName(login: StoredWorkspaceLogin): string | undefined { + return login.workspaceSlug ?? login.workspace ?? login.workspaceId; } function isExpired(expiresAt: string | undefined): boolean { @@ -337,16 +371,10 @@ function isExpired(expiresAt: string | undefined): boolean { return Number.isNaN(millis) ? false : millis <= Date.now(); } -function tryOpenBrowser(url: string): void { - const command = platform() === 'darwin' - ? 'open' - : platform() === 'win32' - ? 'cmd' - : 'xdg-open'; - const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]; - const child = spawn(command, args, { stdio: 'ignore', detached: true }); - child.on('error', () => { - // The login URL was already printed; browser launch is best-effort. - }); - child.unref(); +function normalizeUrl(url: string): string { + return url.trim().replace(/\/+$/, ''); +} + +function usesWorkspaceLoginFileOverride(): boolean { + return Boolean(process.env.WORKFORCE_LOGIN_FILE?.trim()); } diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index abc9e0fe..ae258311 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1,13 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { get } from 'node:http'; import os from 'node:os'; import path from 'node:path'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { createBufferedIO } from '../io.js'; import type { BundleResult, ModeLaunchInput } from '../types.js'; -import { cloudLauncher, type CloudRunHandle } from './cloud.js'; +import { + cloudLauncher, + configureCloudCredentialDepsForTest, + type CloudRunHandle +} from './cloud.js'; type FetchCall = { url: string; @@ -130,9 +133,9 @@ async function launch(overrides: { const { bundle, cleanup } = await withBundle(); const io = createBufferedIO(); const fetchMock = installFetch((url, init, calls) => { - if (overrides.defaultPlanCredential !== false && url.endsWith('/api/v1/users/me/provider_credentials')) { + if (overrides.defaultPlanCredential !== false && url.includes('/provider-credentials/managed')) { assert.equal(init?.method, 'POST'); - return okJson({ id: 'cred-1' }); + return okJson({ providerCredentialId: 'cred-1' }); } return overrides.fetch(url, init, calls); }); @@ -181,7 +184,7 @@ test('cloud launcher POSTs a deploy bundle and returns the cloud handle', async assert.equal(handle.id, 'agent-1'); assert.equal(handle.deploymentId, 'dep-1'); assert.equal((await handle.done).code, 0); - assert.equal(callsForUrl(calls, '/provider_credentials'), 1); + assert.equal(calls.filter((call) => call.url.includes('/provider-credentials/managed')).length, 1); }); test('cloud URL precedence is flag env, cloud env, persona deployUrl, then default', async () => { @@ -215,7 +218,7 @@ test('cloud URL precedence is flag env, cloud env, persona deployUrl, then defau ); assert.equal( await deployedUrl({}), - 'https://agentrelay.com/api/v1/workspaces/ws-test/deployments' + 'https://agentrelay.com/cloud/api/v1/workspaces/ws-test/deployments' ); }); @@ -225,13 +228,10 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, input: { harnessSource: 'plan' }, fetch(url, init) { - if (url.endsWith('/provider_credentials')) { + if (url.endsWith('/provider-credentials/managed?provider=openai')) { assert.equal(init?.method, 'POST'); - assert.deepEqual(JSON.parse(String(init?.body)), { - model_provider: 'openai-codex', - auth_type: 'relay_managed' - }); - return okJson({ id: 'cred-plan' }); + assert.equal(init?.body, undefined); + return okJson({ providerCredentialId: 'cred-plan' }); } if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { @@ -247,14 +247,15 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, input: { harnessSource: 'byok', byokKey: 'sk-test' }, fetch(url, init) { - if (url.endsWith('/provider_credentials')) { + if (url.endsWith('/provider-credentials/byok')) { assert.equal(init?.method, 'POST'); assert.deepEqual(JSON.parse(String(init?.body)), { - model_provider: 'openai-codex', - auth_type: 'byo_api_key', + modelProvider: 'openai', + model_provider: 'openai', + key: 'sk-test', api_key: 'sk-test' }); - return okJson({ id: 'cred-byok' }); + return okJson({ providerCredentialId: 'cred-byok' }); } if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { @@ -266,7 +267,44 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co assert.equal(byok.handle.id, 'agent-byok'); }); +test('cloud BYOK provider detection avoids substring false positives', async () => { + await launch({ + defaultPlanCredential: false, + persona: persona({ model: 'my-openai-alternative' }), + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + input: { harnessSource: 'byok', byokKey: 'sk-test' }, + fetch(url, init) { + if (url.endsWith('/provider-credentials/byok')) { + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'my-openai-alternative'); + return okJson({ providerCredentialId: 'cred-byok' }); + } + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-byok', deploymentId: 'dep-byok', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); +}); + test('cloud harness OAuth uses provider_credentials readiness and honors no-prompt failure', async () => { + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch(pathname: string, init?: RequestInit) { + assert.equal(pathname, '/api/v1/users/me/provider_credentials?model_provider=openai'); + assert.equal(init?.method, 'GET'); + return okJson({}); + } + }; + } + }); await assert.rejects( launch({ defaultPlanCredential: false, @@ -275,36 +313,45 @@ test('cloud harness OAuth uses provider_credentials readiness and honors no-prom WORKFORCE_DEPLOY_NO_PROMPT: '1' }, input: { harnessSource: 'oauth' }, - fetch(url, init) { - assert.equal(url, 'https://cloud.example.test/api/v1/users/me/provider_credentials?model_provider=openai-codex'); - assert.equal(init?.method, 'GET'); - return okJson({}); + fetch(url) { + throw new Error(`unexpected URL ${url}`); } }), /OAuth credentials are not connected/ - ); + ).finally(restoreDeps); }); test('cloud harness OAuth starts auth and polls until provider credentials are connected', async () => { let credentialChecks = 0; + const connected: string[] = []; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + connectProvider: async (options: { provider: string }) => { + connected.push(options.provider); + return { provider: options.provider, success: true }; + }, + createCloudApiClient() { + return { + async fetch(pathname: string, init?: RequestInit) { + if (pathname.endsWith('/provider_credentials?model_provider=openai')) { + credentialChecks += 1; + assert.equal(init?.method, 'GET'); + return okJson(credentialChecks < 3 ? {} : { id: 'cred-oauth', status: 'connected' }); + } + throw new Error(`unexpected path ${pathname}`); + } + }; + } + }); const io = createBufferedIO(); io.scriptConfirmations([true]); const { bundle, cleanup } = await withBundle(); - const fetchMock = installFetch((url, init) => { - if (url.endsWith('/provider_credentials?model_provider=openai-codex')) { - credentialChecks += 1; - assert.equal(init?.method, 'GET'); - return okJson(credentialChecks < 3 ? {} : { id: 'cred-oauth', status: 'connected' }); - } - if (url.endsWith('/api/v1/users/me/provider_credentials/auth-session')) { - assert.equal(init?.method, 'POST'); - assert.deepEqual(JSON.parse(String(init?.body)), { - model_provider: 'openai-codex', - provider: 'codex', - language: 'typescript' - }); - return okJson({ authUrl: 'https://cloud.example.test/oauth/codex' }); - } + const fetchMock = installFetch((url) => { if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-oauth', deploymentId: 'dep-oauth', status: 'active' }, 201); @@ -329,11 +376,12 @@ test('cloud harness OAuth starts auth and polls until provider credentials are c assert.equal(handle.id, 'agent-oauth'); } finally { fetchMock.restore(); + restoreDeps(); await cleanup(); } assert.equal(credentialChecks, 3); - assert.ok(io.messages.some((message) => message.message.includes('/oauth/codex'))); + assert.deepEqual(connected, ['openai']); }); test('cloud launcher maps 401 deploy responses to the workforce login guidance', async () => { @@ -372,7 +420,7 @@ test('cloud polling resolves done with code 0 on active and 1 on failed', async const { bundle, cleanup } = await withBundle(); const io = createBufferedIO(); const fetchMock = installFetch((url) => { - if (url.endsWith('/api/v1/users/me/provider_credentials')) return okJson({ id: 'cred-1' }); + if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: `agent-${finalStatus}`, deploymentId: `dep-${finalStatus}`, status: 'starting' }, 201); @@ -411,7 +459,7 @@ test('cloud stop calls the destroy agent endpoint', async () => { const { bundle, cleanup } = await withBundle(); const io = createBufferedIO(); const fetchMock = installFetch((url, init) => { - if (url.endsWith('/api/v1/users/me/provider_credentials')) return okJson({ id: 'cred-1' }); + if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); @@ -442,32 +490,11 @@ test('cloud stop calls the destroy agent endpoint', async () => { } }); -test('cloud integration stage opens OAuth session and waits for readiness', async () => { - let statusChecks = 0; - const baseIo = createBufferedIO(); - const io = { - ...baseIo, - info(message: string) { - baseIo.info(message); - if (message.includes('/integrations?')) { - const rawUrl = message.match(/https:\/\/\S+/)?.[0]; - if (!rawUrl) return; - const connectUrl = new URL(rawUrl); - const returnTo = connectUrl.searchParams.get('return_to'); - if (returnTo) void hitCallback(returnTo); - } - } - }; - io.scriptConfirmations([true]); +test('cloud launcher leaves integration preflight to the deploy orchestrator', async () => { + const io = createBufferedIO(); const { bundle, cleanup } = await withBundle(); const fetchMock = installFetch((url) => { - if (url.endsWith('/api/v1/users/me/provider_credentials')) { - return okJson({ id: 'cred-1' }); - } - if (url.endsWith('/integrations?provider=github')) { - statusChecks += 1; - return okJson(statusChecks < 2 ? { ready: false, state: 'pending' } : { ready: true, state: 'ready' }); - } + if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); @@ -493,8 +520,7 @@ test('cloud integration stage opens OAuth session and waits for readiness', asyn await cleanup(); } - assert.equal(statusChecks, 2); - assert.ok(io.messages.some((message) => message.message.includes('/integrations?provider=github'))); + assert.equal(fetchMock.calls.some((call) => call.url.includes('/integrations')), false); }); test('cloud existing-persona stage honors destroy and cancel choices', async () => { @@ -537,12 +563,3 @@ test('cloud existing-persona stage honors destroy and cancel choices', async () function callsForUrl(calls: FetchCall[], suffix: string): number { return calls.filter((call) => call.url.endsWith(suffix)).length; } - -function hitCallback(url: string): Promise { - return new Promise((resolve, reject) => { - get(url, (res) => { - res.resume(); - res.on('end', resolve); - }).on('error', reject); - }); -} diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index 2da7bfbe..13f5dadc 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -1,8 +1,12 @@ -import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import { platform } from 'node:os'; +import { + CloudApiClient, + connectProvider, + defaultApiUrl, + readStoredAuth, + refreshStoredAuth, + type StoredAuth +} from '@agent-relay/cloud'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import type { ModeLaunchInput, @@ -14,7 +18,6 @@ import { type WorkspaceAuthToken } from '../login.js'; -const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; const BUILD_YOUR_OWN_CLOUD_DOCS_URL = 'https://docs.agentworkforce.com/deploy/build-your-own-cloud'; const USER_AGENT = 'workforce-deploy'; const MAX_ATTEMPTS = 3; @@ -57,15 +60,6 @@ interface ProviderCredentialsResponse { createdAt?: unknown; } -interface IntegrationsResponse { - integrations?: unknown; - provider?: unknown; - ready?: unknown; - state?: unknown; - connectionId?: unknown; - currentConnectionId?: unknown; -} - interface ExistingAgentResponse { agent?: unknown; agents?: unknown; @@ -76,6 +70,41 @@ interface ExistingAgent { status?: string; } +type CloudApiClientLike = Pick; + +type CloudCredentialDeps = { + readStoredAuth: typeof readStoredAuth; + refreshStoredAuth: typeof refreshStoredAuth; + connectProvider: typeof connectProvider; + createCloudApiClient(auth: StoredAuth, apiUrl: string): CloudApiClientLike; +}; + +const defaultCloudCredentialDeps: CloudCredentialDeps = { + readStoredAuth, + refreshStoredAuth, + connectProvider, + createCloudApiClient(auth, apiUrl) { + return new CloudApiClient({ + apiUrl, + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + accessTokenExpiresAt: auth.accessTokenExpiresAt + }); + } +}; + +let cloudCredentialDeps = defaultCloudCredentialDeps; + +export function configureCloudCredentialDepsForTest( + overrides: Partial +): () => void { + const previous = cloudCredentialDeps; + cloudCredentialDeps = { ...cloudCredentialDeps, ...overrides }; + return () => { + cloudCredentialDeps = previous; + }; +} + /** * Cloud-hosted deploy mode. Uploads the deploy-ready persona bundle to a * workforce-compatible cloud endpoint. The implementation is intentionally @@ -96,7 +125,7 @@ export const cloudLauncher: ModeLauncher = { noPrompt }); - await ensureHarnessReady({ + const credentialSelections = await ensureHarnessReady({ cloudUrl, workspaceId: input.workspace, token: auth.token, @@ -107,15 +136,6 @@ export const cloudLauncher: ModeLauncher = { byokKey: input.byokKey }); - await ensureCloudIntegrations({ - cloudUrl, - workspaceId: input.workspace, - token: auth.token, - persona: input.persona, - io: input.io, - noPrompt - }); - const existingPersona = await handleExistingPersona({ cloudUrl, workspaceId: input.workspace, @@ -149,6 +169,10 @@ export const cloudLauncher: ModeLauncher = { agent: await readFile(input.bundle.bundlePath, 'utf8'), packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8')) as unknown }, + // Keep both casings until all cloud deploy endpoints converge; older + // previews read snake_case, while current routes read camelCase. + credentialSelections, + credential_selections: credentialSelections, inputs: input.inputs ?? readInputsOverride() }); @@ -221,9 +245,9 @@ function resolveCloudUrl(input: ModeLaunchInput): string { const fromEnv = process.env.WORKFORCE_DEPLOY_CLOUD_URL?.trim() || process.env.WORKFORCE_CLOUD_URL?.trim(); const fromPersona = readPersonaCloudDeployUrl(input.persona); - const raw = fromInput || fromEnv || fromPersona || DEFAULT_CLOUD_URL; + const raw = fromInput || fromEnv || fromPersona || defaultApiUrl(); const resolved = normalizeCloudUrl(raw); - if (resolved !== DEFAULT_CLOUD_URL) { + if (resolved !== normalizeCloudUrl(defaultApiUrl())) { input.io.info( `cloud: using custom cloud URL ${resolved}. Build your own cloud docs: ${BUILD_YOUR_OWN_CLOUD_DOCS_URL}` ); @@ -246,34 +270,37 @@ async function ensureHarnessReady(args: { noPrompt: boolean; harnessSource?: HarnessSource; byokKey?: string; -}): Promise { +}): Promise> { const source = await resolveHarnessSource(args); const modelProvider = deriveModelProvider(args.persona); if (source === 'plan') { - await saveProviderCredential({ + const credentialId = await saveProviderCredential({ cloudUrl: args.cloudUrl, + workspaceId: args.workspaceId, token: args.token, modelProvider, authType: 'relay_managed' }); args.io.info(`cloud: using workforce plan credentials for ${args.persona.harness}`); - return; + return { [modelProvider]: credentialId }; } if (source === 'byok') { const key = await resolveByokKey(args); - await saveProviderCredential({ + const credentialId = await saveProviderCredential({ cloudUrl: args.cloudUrl, + workspaceId: args.workspaceId, token: args.token, modelProvider, authType: 'byo_api_key', apiKey: key }); args.io.info(`cloud: using BYOK credentials for ${args.persona.harness}`); - return; + return { [modelProvider]: credentialId }; } await ensureHarnessOauth(args); + return {}; } async function resolveHarnessSource(args: { @@ -308,18 +335,17 @@ async function resolveHarnessSource(args: { async function isHarnessOauthConnected(args: { cloudUrl: string; - token: string; persona: PersonaSpec; }): Promise { - const url = `${args.cloudUrl}/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent( + const auth = await readUsableCloudAuth(args.cloudUrl); + if (!auth) return false; + const client = cloudCredentialDeps.createCloudApiClient(auth, args.cloudUrl); + const path = `/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent( deriveModelProvider(args.persona) )}`; - const res = await fetch(url, { + const res = await client.fetch(path, { method: 'GET', - headers: { - authorization: `Bearer ${args.token}`, - 'user-agent': USER_AGENT - } + headers: { 'user-agent': USER_AGENT } }); if (res.status === 404 || res.status === 405) return false; if (res.status === 401) { @@ -378,25 +404,15 @@ async function ensureHarnessOauth(args: { throw new Error(`cloud: ${args.persona.harness} credentials are required for deploy`); } const modelProvider = deriveModelProvider(args.persona); - const startUrl = `${args.cloudUrl}/api/v1/users/me/provider_credentials/auth-session`; - const body = await requestJsonWithRetry>( - startUrl, - { - method: 'POST', - headers: jsonHeaders(args.token), - body: JSON.stringify({ - model_provider: modelProvider, - provider: args.persona.harness, - language: 'typescript' - }) - }, - { action: 'cloud harness OAuth start' } - ); - const connectUrl = readFirstString(body, ['connectLink', 'authUrl', 'url', 'sandboxUrl']); - if (connectUrl) { - args.io.info(`cloud: open ${connectUrl} to finish ${args.persona.harness} OAuth`); - tryOpenBrowser(connectUrl); - } + await cloudCredentialDeps.connectProvider({ + provider: modelProvider, + apiUrl: args.cloudUrl, + language: 'typescript', + io: { + log: (...parts: unknown[]) => args.io.info(parts.map(String).join(' ')), + error: (...parts: unknown[]) => args.io.error(parts.map(String).join(' ')) + } + }); await pollUntil( () => isHarnessOauthConnected(args), `timed out waiting for ${args.persona.harness} OAuth credentials` @@ -404,89 +420,6 @@ async function ensureHarnessOauth(args: { args.io.info(`cloud: ${args.persona.harness} credentials connected`); } -async function ensureCloudIntegrations(args: { - cloudUrl: string; - workspaceId: string; - token: string; - persona: PersonaSpec; - io: ModeLaunchInput['io']; - noPrompt: boolean; -}): Promise { - const providers = Object.keys(args.persona.integrations ?? {}); - for (const provider of providers) { - const ready = await isIntegrationReady({ ...args, provider }); - if (ready) { - args.io.info(`cloud: integrations.${provider} ready`); - continue; - } - if (args.noPrompt) { - throw new Error( - `cloud: integrations.${provider} is not connected. Run without --no-prompt or connect it before deploying.` - ); - } - const ok = await args.io.confirm( - `Connect ${provider} in workforce cloud now? (opens browser)`, - { defaultValue: true } - ); - if (!ok) { - throw new Error(`cloud: integrations.${provider} is required for deploy`); - } - await connectIntegration({ ...args, provider }); - await pollUntil( - () => isIntegrationReady({ ...args, provider }), - `timed out waiting for integrations.${provider} to become ready` - ); - args.io.info(`cloud: integrations.${provider} connected`); - } -} - -async function isIntegrationReady(args: { - cloudUrl: string; - workspaceId: string; - token: string; - provider: string; -}): Promise { - const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( - args.workspaceId - )}/integrations?provider=${encodeURIComponent(args.provider)}`; - const res = await fetch(url, { - method: 'GET', - headers: { - authorization: `Bearer ${args.token}`, - 'user-agent': USER_AGENT - } - }); - if (res.status === 401) { - throw new Error('cloud integration check failed: unauthorized. Run `workforce login` and retry.'); - } - if (res.status === 404) return false; - if (!res.ok) { - throw new Error(`cloud integration check failed: ${res.status} ${await responseExcerpt(res)}`); - } - const body = (await res.json()) as IntegrationsResponse; - return integrationReady(body, args.provider); -} - -async function connectIntegration(args: { - cloudUrl: string; - workspaceId: string; - token: string; - provider: string; - io: ModeLaunchInput['io']; -}): Promise { - await waitForOAuthCallback({ - action: `integrations.${args.provider}`, - io: args.io, - buildUrl(returnTo) { - const url = new URL('/integrations', args.cloudUrl); - url.searchParams.set('provider', args.provider); - url.searchParams.set('workspace', args.workspaceId); - url.searchParams.set('return_to', returnTo); - return url.toString(); - } - }); -} - async function handleExistingPersona(args: { cloudUrl: string; workspaceId: string; @@ -618,33 +551,81 @@ function parseAgentLike(value: unknown): ExistingAgent | null { async function saveProviderCredential(args: { cloudUrl: string; + workspaceId: string; token: string; modelProvider: string; authType: 'relay_managed' | 'byo_api_key'; apiKey?: string; -}): Promise { - await requestJsonWithRetry>( - `${args.cloudUrl}/api/v1/users/me/provider_credentials`, +}): Promise { + if (args.authType === 'relay_managed') { + const url = new URL(`${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspaceId + )}/provider-credentials/managed`); + url.searchParams.set('provider', args.modelProvider); + const body = await requestJsonWithRetry>( + url.toString(), + { + method: 'POST', + headers: jsonHeaders(args.token) + }, + { action: 'cloud managed provider credentials' } + ); + return readCredentialId(body); + } + + const body = await requestJsonWithRetry>( + `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/provider-credentials/byok`, { method: 'POST', headers: jsonHeaders(args.token), body: JSON.stringify({ + // Keep both casings during the deploy-v1 rollout for the same mixed + // preview/production route compatibility as the deploy payload above. + modelProvider: args.modelProvider, model_provider: args.modelProvider, - auth_type: args.authType, - ...(args.apiKey ? { api_key: args.apiKey } : {}) + key: args.apiKey, + api_key: args.apiKey }) }, - { action: 'cloud provider credentials save' } + { action: 'cloud BYOK provider credentials' } ); + return readCredentialId(body); } function deriveModelProvider(persona: PersonaSpec): string { const model = typeof persona.model === 'string' ? persona.model.trim() : ''; + if (!model) return persona.harness; + const lower = model.toLowerCase(); + if (matchesProviderToken(lower, ['anthropic', 'claude'])) return 'anthropic'; + if (matchesProviderToken(lower, ['openai', 'codex', 'gpt'])) return 'openai'; + if (matchesProviderToken(lower, ['google', 'gemini'])) return 'google'; + if (matchesProviderToken(lower, ['openrouter', 'opencode'])) return 'openrouter'; const [provider] = model.split(/[/:]/, 1); - if (provider?.trim()) return provider.trim(); + if (provider?.trim()) return provider.trim().toLowerCase(); return persona.harness; } +function matchesProviderToken(model: string, tokens: readonly string[]): boolean { + return tokens.some((token) => new RegExp(`^${escapeRegExp(token)}($|[/:._-])`).test(model)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function readCredentialId(body: Record): string { + const direct = readFirstString(body, ['providerCredentialId', 'provider_credential_id', 'credentialId', 'id']); + if (direct) return direct; + for (const field of ['credential', 'providerCredential']) { + const nested = body[field]; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + const nestedId = readFirstString(nested, ['id', 'providerCredentialId', 'provider_credential_id']); + if (nestedId) return nestedId; + } + } + throw new Error('cloud provider credentials response missing credential id'); +} + function providerCredentialsReady(body: ProviderCredentialsResponse): boolean { const candidates = [ body.credential, @@ -664,93 +645,6 @@ function providerCredentialsReady(body: ProviderCredentialsResponse): boolean { }); } -function integrationReady(body: IntegrationsResponse, provider: string): boolean { - const candidates = [ - ...(Array.isArray(body.integrations) ? body.integrations : []), - body - ]; - return candidates.some((candidate) => { - if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; - const record = candidate as Record; - const recordProvider = typeof record.provider === 'string' ? record.provider : provider; - if (recordProvider !== provider) return false; - return record.ready === true - || record.state === 'ready' - || record.state === 'connected' - || typeof record.connectionId === 'string' - || typeof record.currentConnectionId === 'string'; - }); -} - -async function waitForOAuthCallback(args: { - action: string; - io: ModeLaunchInput['io']; - buildUrl(returnTo: string): string; -}): Promise { - const state = randomUUID(); - await new Promise((resolve, reject) => { - let settled = false; - const timeout = setTimeout(() => { - settleError(new Error(`timed out waiting for ${args.action} OAuth callback`)); - }, pollTimeoutMs()).unref(); - - const server = createServer((request, response) => { - const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); - if (requestUrl.pathname !== '/callback') { - response.statusCode = 404; - response.end('not found'); - return; - } - if (requestUrl.searchParams.get('state') !== state) { - response.statusCode = 400; - response.end('invalid state'); - settleError(new Error(`${args.action} OAuth callback returned an invalid state`)); - return; - } - const error = requestUrl.searchParams.get('error'); - if (error) { - response.statusCode = 400; - response.end('OAuth failed'); - settleError(new Error(error)); - return; - } - response.statusCode = 200; - response.end('workforce OAuth complete; you can close this tab'); - settleOk(); - }); - - function settleOk(): void { - if (settled) return; - settled = true; - clearTimeout(timeout); - server.close(); - resolve(); - } - - function settleError(error: Error): void { - if (settled) return; - settled = true; - clearTimeout(timeout); - server.close(); - reject(error); - } - - server.on('error', settleError); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - settleError(new Error(`failed to start ${args.action} OAuth callback server`)); - return; - } - const callback = new URL('/callback', `http://127.0.0.1:${address.port}`); - callback.searchParams.set('state', state); - const connectUrl = args.buildUrl(callback.toString()); - args.io.info(`cloud: open ${connectUrl} to finish ${args.action} OAuth`); - tryOpenBrowser(connectUrl); - }); - }); -} - function expectHarnessSource(value: string): HarnessSource { const normalized = value.trim().toLowerCase(); if (normalized === 'plan' || normalized === 'byok' || normalized === 'oauth') { @@ -806,10 +700,35 @@ function readPersonaCloudDeployUrl(persona: PersonaSpec): string | undefined { function normalizeCloudUrl(url: string): string { const trimmed = url.trim(); - if (!trimmed) return DEFAULT_CLOUD_URL; + if (!trimmed) return normalizeCloudUrl(defaultApiUrl()); return trimmed.replace(/\/+$/, ''); } +async function readUsableCloudAuth(apiUrl: string): Promise { + let auth = await cloudCredentialDeps.readStoredAuth().catch(() => null); + if (!auth) return null; + if (isAuthExpired(auth.accessTokenExpiresAt)) { + auth = await cloudCredentialDeps.refreshStoredAuth(auth).catch((err) => { + console.warn(`cloud: stored auth refresh failed: ${formatErrorMessage(err)}`); + return null; + }); + } + if (!auth) return null; + return { + ...auth, + apiUrl + }; +} + +function formatErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isAuthExpired(expiresAt: string): boolean { + const millis = Date.parse(expiresAt); + return Number.isNaN(millis) || millis <= Date.now() + 60_000; +} + function readInputsOverride(): Record | undefined { const raw = process.env.WORKFORCE_DEPLOY_INPUTS_JSON?.trim(); if (!raw) return undefined; @@ -933,20 +852,6 @@ function emitLog(args: { args.io.info(line); } -function tryOpenBrowser(url: string): void { - const command = platform() === 'darwin' - ? 'open' - : platform() === 'win32' - ? 'cmd' - : 'xdg-open'; - const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]; - const child = spawn(command, args, { stdio: 'ignore', detached: true }); - child.on('error', () => { - // URL is printed; browser launch is best-effort. - }); - child.unref(); -} - function pollTimeoutMs(): number { return numberFromEnv('WORKFORCE_DEPLOY_POLL_TIMEOUT_MS') ?? POLL_TIMEOUT_MS; } diff --git a/packages/deploy/src/modes/input-values.test.ts b/packages/deploy/src/modes/input-values.test.ts index 5efcdb7f..85735609 100644 --- a/packages/deploy/src/modes/input-values.test.ts +++ b/packages/deploy/src/modes/input-values.test.ts @@ -217,12 +217,19 @@ test('cloud launcher includes inputs in persona bundle POST body', async () => { try { const bundle = await writeBundle(dir); process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-cloud'; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - calls.push({ - url: typeof input === 'string' ? input : input.toString(), - body: init?.body ? JSON.parse(String(init.body)) : undefined - }); - return new Response( + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + calls.push({ + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined + }); + if (url.includes('/provider-credentials/byok')) { + return new Response(JSON.stringify({ providerCredentialId: 'cred-byok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + return new Response( JSON.stringify({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'starting' }), { status: 201, headers: { 'content-type': 'application/json' } } ); diff --git a/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts b/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts new file mode 100644 index 00000000..3906870d --- /dev/null +++ b/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as notionEssayPrModule from '../../../../examples/notion-essay-pr/agent.js'; +import type { WorkforceCtx, WorkforceProviderEvent } from '@agentworkforce/runtime'; + +type NotionEssayHandler = (ctx: WorkforceCtx, event: WorkforceProviderEvent) => Promise | void; + +const notionEssayPr = resolveHandler(notionEssayPrModule); + +function resolveHandler(moduleValue: unknown): NotionEssayHandler { + const firstDefault = (moduleValue as { default?: unknown }).default; + if (typeof firstDefault === 'function') return firstDefault as NotionEssayHandler; + const nestedDefault = (firstDefault as { default?: unknown } | undefined)?.default; + if (typeof nestedDefault === 'function') return nestedDefault as NotionEssayHandler; + throw new TypeError('notion-essay-pr smoke could not resolve the example handler default export'); +} + +test('notion-essay-pr e2e smoke runs a Notion page event to an essay PR with mocks', async () => { + const runtime = new MockNotionEssayRuntime(); + await runtime.spawnAndDispatch(notionEssayPr); + + assert.equal(runtime.sandboxSpawned, true); + assert.equal(runtime.files.get('/workspace/AGENTS.md'), '# Agent: notion-essay-pr\n'); + assert.deepEqual(runtime.fileReads, ['/notion/pages/page-123.md']); + assert.equal(runtime.files.get('/workspace/output/page-123.md'), '# A useful essay\n\nDraft body.\n'); + assert.equal(runtime.githubPullRequests.length, 1); + assert.deepEqual(runtime.githubPullRequests[0], { + owner: 'AgentWorkforce', + repo: 'proactive-agents', + title: 'Essay: Launch notes', + body: 'Drafted from Notion page page-123.\n\nOutput: /workspace/output/page-123.md', + head: 'essay/page-123', + base: 'main', + files: { + 'output/page-123.md': '# A useful essay\n\nDraft body.\n' + } + }); + assert.deepEqual(runtime.memorySaves, [ + { + content: 'Notion essay PR opened for Launch notes: https://github.com/AgentWorkforce/proactive-agents/pull/17', + scope: 'workspace', + tags: ['notion-essay-pr', 'page:page-123'] + } + ]); +} +); + +class MockNotionEssayRuntime { + readonly files = new Map([ + ['/notion/pages/page-123.md', '# Launch notes\n\nWe shipped the first customer-facing deploy path.'] + ]); + readonly fileReads: string[] = []; + readonly githubPullRequests: Array> = []; + readonly memorySaves: Array<{ content: string; scope?: string; tags?: string[] }> = []; + sandboxSpawned = false; + + async spawnAndDispatch(handler: (ctx: WorkforceCtx, event: WorkforceProviderEvent) => Promise | void): Promise { + this.sandboxSpawned = true; + this.files.set('/workspace/AGENTS.md', '# Agent: notion-essay-pr\n'); + await handler(this.ctx(), { + id: 'evt-page-123', + source: 'notion', + type: 'page.created', + workspaceId: 'ws-proactive', + occurredAt: '2026-05-13T12:00:00.000Z', + attempt: 1, + payload: { + pageId: 'page-123', + title: 'Launch notes' + }, + summary: { + title: 'Launch notes' + } + }); + } + + private ctx(): WorkforceCtx { + return { + persona: { + id: 'notion-essay-pr', + intent: 'documentation', + tags: ['documentation', 'release'], + description: 'fixture', + skills: [], + harness: 'claude', + model: 'claude-sonnet-4-6', + systemPrompt: '', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }, + inputs: { GITHUB_TARGET_REPO: 'AgentWorkforce/proactive-agents' }, + inputSpecs: {} + }, + agent: { id: 'agent-1', deployedName: 'notion-essay-pr', spawnedByAgentId: null }, + deployment: { id: 'deployment-1', triggerKind: 'radio', parentDeploymentId: null }, + workspaceId: 'ws-proactive', + agentName: 'notion-essay-pr', + llm: { + async complete() { + return ''; + } + }, + harness: { + async run() { + return { output: '# A useful essay\n\nDraft body.', exitCode: 0, durationMs: 25 }; + } + }, + sandbox: { + cwd: '/workspace', + async exec() { + return { output: '', exitCode: 0 }; + }, + readFile: (path) => this.read(path), + writeFile: (path, contents) => this.write(path, contents) + }, + files: { + read: (path) => this.read(path), + write: (path, contents) => this.write(path, contents) + }, + memory: { + async recall() { + return [{ + id: 'mem-1', + content: 'Previous essays should be concise.', + tags: ['notion-essay-pr'], + scope: 'workspace', + createdAt: '2026-05-13T10:00:00.000Z' + }]; + }, + save: async (content, opts) => { + this.memorySaves.push({ content, scope: opts?.scope, tags: opts?.tags }); + return { id: 'mem-2' }; + } + }, + workflow: { + async run() { + throw new Error('not configured'); + }, + async status() { + throw new Error('not configured'); + } + }, + schedule: { + async at() { + /* unused */ + }, + async cancel() { + /* unused */ + } + }, + log: () => undefined, + github: { + comment: async () => ({ id: 'comment-1', url: 'https://example.test/comment' }), + createIssue: async () => ({ number: 1, url: 'https://example.test/issue' }), + upsertIssue: async () => ({ number: 1, url: 'https://example.test/issue', created: true }), + getPr: async () => ({ title: '', body: '', diff: '', head: '', base: '', author: '' }), + postReview: async () => undefined, + createPullRequest: async (args) => { + this.githubPullRequests.push(args); + return { number: 17, url: 'https://github.com/AgentWorkforce/proactive-agents/pull/17' }; + } + } + }; + } + + private async read(path: string): Promise { + this.fileReads.push(path); + const value = this.files.get(path); + if (value === undefined) throw new Error(`missing file: ${path}`); + return value; + } + + private async write(path: string, contents: string): Promise { + this.files.set(path, contents); + } +} diff --git a/packages/runtime/src/clients/github.test.ts b/packages/runtime/src/clients/github.test.ts index 7115a95d..21d6b974 100644 --- a/packages/runtime/src/clients/github.test.ts +++ b/packages/runtime/src/clients/github.test.ts @@ -52,6 +52,36 @@ test('github.createIssue writes a draft issue file under issues/', async () => { } }); +test('github.createPullRequest writes a draft pull request file under pulls/', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + await client.createPullRequest({ + owner: 'o', + repo: 'r', + title: 'Essay: Draft', + body: 'Adds the essay.', + head: 'essay/page-1', + base: 'main', + files: { 'output/page-1.md': '# Essay' } + }); + + const dir = path.join(root, 'github/repos/o/r/pulls'); + const files = await readdir(dir); + const drafts = files.filter((name) => name.endsWith('.json')); + assert.equal(drafts.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, drafts[0] ?? ''), 'utf8')), { + title: 'Essay: Draft', + body: 'Adds the essay.', + head: 'essay/page-1', + base: 'main', + files: { 'output/page-1.md': '# Essay' } + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('github.upsertIssue updates an existing flat issue match', async () => { const root = await tempMount(); try { diff --git a/packages/runtime/src/clients/github.ts b/packages/runtime/src/clients/github.ts index c87f9ad8..d44cc731 100644 --- a/packages/runtime/src/clients/github.ts +++ b/packages/runtime/src/clients/github.ts @@ -30,6 +30,15 @@ export interface GithubClient { body: string; labels?: string[]; }): Promise<{ number: number; url: string }>; + createPullRequest(args: { + owner: string; + repo: string; + title: string; + body: string; + head: string; + base: string; + files?: Record; + }): Promise<{ number: number; url: string }>; upsertIssue(args: { owner: string; repo: string; @@ -130,6 +139,24 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient return { number: Number.isFinite(number) ? number : 0, url: result.receipt?.url ?? result.path }; }, + async createPullRequest(args) { + const result = await writeJsonFile( + opts, + 'github', + 'createPullRequest', + `${repoRoot(args.owner, args.repo)}/pulls/${draftFile('create pull request')}`, + { + title: args.title, + body: args.body, + head: args.head, + base: args.base, + ...(args.files ? { files: args.files } : {}) + } + ); + const number = Number(result.receipt?.created ?? result.receipt?.id ?? 0); + return { number: Number.isFinite(number) ? number : 0, url: result.receipt?.url ?? result.path }; + }, + async upsertIssue(args) { const issueDir = `${repoRoot(args.owner, args.repo)}/issues`; const flatIssues = await listJsonFiles(opts, 'github', 'upsertIssue.find.flat', issueDir); diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts new file mode 100644 index 00000000..77d3f1c5 --- /dev/null +++ b/packages/runtime/src/cloud-defaults.ts @@ -0,0 +1,542 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { accessSync, statSync } from 'node:fs'; +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + buildNonInteractiveSpec, + renderPersonaInputs, + resolveMcpServersLenient, + resolvePersonaInputs, + resolveStringMapLenient, + type PersonaSpec +} from '@agentworkforce/persona-kit'; +import { createGithubClient } from './clients/github.js'; +import type { + FilesContext, + HarnessRunArgs, + HarnessRunResult, + HarnessUsage, + SandboxContext, + WorkforceAgentContext, + WorkforceCtx, + WorkforceDeploymentContext +} from './types.js'; + +type AgentInputValue = string | number | boolean | null | undefined; +const USAGE_REPORT_TIMEOUT_MS = 5_000; + +interface AgentRowContext extends WorkforceAgentContext { + input_values?: Record; + inputValues?: Record; +} + +export interface CloudDefaultOptions { + persona: PersonaSpec; + agent: AgentRowContext; + deployment: WorkforceDeploymentContext; + workspaceId: string; + log: WorkforceCtx['log']; + env?: NodeJS.ProcessEnv; +} + +export interface CloudRuntimeDefaults { + sandbox: SandboxContext; + files: FilesContext; + integrations?: Record; + harnessRunner: (args: HarnessRunArgs) => Promise; +} + +export function createCloudRuntimeDefaults(options: CloudDefaultOptions): CloudRuntimeDefaults { + const env = options.env ?? process.env; + const root = resolveCloudWorkspaceRoot(env); + const sandbox = createProcessSandbox(root, env); + const files = filesFromSandbox(sandbox); + const integrations = createDefaultIntegrations({ + persona: options.persona, + workspaceId: options.workspaceId, + workspaceRoot: root, + env + }); + return { + sandbox, + files, + ...(integrations ? { integrations } : {}), + harnessRunner: createProcessHarnessRunner({ + ...options, + workspaceRoot: root, + env + }) + }; +} + +function resolveCloudWorkspaceRoot(env: NodeJS.ProcessEnv): string { + const configured = firstNonEmpty( + env.WORKFORCE_SANDBOX_ROOT, + env.WORKFORCE_WORKSPACE_DIR, + env.RELAYFILE_MOUNT_ROOT, + env.RELAYFILE_ROOT + ); + if (configured) return path.resolve(configured); + return canAccessSync('/workspace') ? '/workspace' : process.cwd(); +} + +function canAccessSync(candidate: string): boolean { + try { + accessSync(candidate, constants.R_OK | constants.W_OK); + return statSync(candidate).isDirectory(); + } catch { + return false; + } +} + +function createProcessSandbox(root: string, env: NodeJS.ProcessEnv): SandboxContext { + const cwd = path.resolve(root); + return { + cwd, + async exec(cmd, opts) { + const execCwd = resolveWorkspacePath(cwd, opts?.cwd ?? cwd); + await assertDirectory(execCwd); + const startedAt = Date.now(); + const result = await spawnAndCapture({ + bin: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + args: process.platform === 'win32' ? ['/d', '/s', '/c', cmd] : ['-lc', cmd], + cwd: execCwd, + env: { ...env, ...(opts?.env ?? {}) }, + timeoutMs: opts?.timeoutMs + }); + return { + output: result.output || result.stderr, + exitCode: result.exitCode + }; + }, + async readFile(filePath) { + return readFile(resolveWorkspacePath(cwd, filePath), 'utf8'); + }, + async writeFile(filePath, contents) { + const target = resolveWorkspacePath(cwd, filePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, contents, 'utf8'); + } + }; +} + +function filesFromSandbox(sandbox: SandboxContext): FilesContext { + return { + read(path) { + return sandbox.readFile(path); + }, + write(path, contents) { + return sandbox.writeFile(path, contents); + } + }; +} + +function createDefaultIntegrations(args: { + persona: PersonaSpec; + workspaceId: string; + workspaceRoot: string; + env: NodeJS.ProcessEnv; +}): Record | undefined { + const integrations: Record = {}; + if (args.persona.integrations?.github) { + integrations.github = createGithubClient({ + relayfileMountRoot: firstNonEmpty(args.env.RELAYFILE_MOUNT_ROOT, args.env.RELAYFILE_ROOT) ?? args.workspaceRoot, + workspaceCwd: args.workspaceRoot, + workspaceId: args.workspaceId, + writebackTimeoutMs: numberFromEnv(args.env.WORKFORCE_RELAYFILE_WRITEBACK_TIMEOUT_MS), + writebackPollMs: numberFromEnv(args.env.WORKFORCE_RELAYFILE_WRITEBACK_POLL_MS), + connectionId: args.env.WORKFORCE_INTEGRATION_GITHUB_CONNECTION_ID, + relayfileBaseUrl: args.env.RELAYFILE_BASE_URL, + relayfileApiToken: args.env.RELAYFILE_TOKEN, + cloudApiToken: firstNonEmpty(args.env.WORKFORCE_AGENT_TOKEN, args.env.WORKFORCE_WORKSPACE_TOKEN) + }); + } + return Object.keys(integrations).length > 0 ? integrations : undefined; +} + +function createProcessHarnessRunner(args: CloudDefaultOptions & { + workspaceRoot: string; + env: NodeJS.ProcessEnv; +}): (run: HarnessRunArgs) => Promise { + return async (run) => { + const inputValues = resolveAgentInputValues(args.agent); + const inputResolution = resolvePersonaInputs(args.persona.inputs, inputValues, args.env); + const callerEnv = { ...args.env, ...inputResolution.values }; + const envResolution = resolveStringMapLenient(args.persona.env, callerEnv, 'env'); + const mcpResolution = resolveMcpServersLenient(args.persona.mcpServers, callerEnv); + for (const warning of [ + ...envResolution.dropped.map((drop) => `${drop.field} dropped (env var ${drop.ref} is not set)`), + ...mcpResolution.dropped.map((drop) => `${drop.field} dropped (env var ${drop.ref} is not set)`), + ...mcpResolution.droppedServers.map((drop) => + `mcpServers.${drop.name} dropped entirely (required refs missing: ${drop.refs.join(', ')})` + ) + ]) { + args.log('warn', 'harness.config.dropped', { warning }); + } + + const renderedSystemPrompt = renderPersonaInputs(args.persona.systemPrompt, inputResolution.values); + const cwd = resolveWorkspacePath(args.workspaceRoot, run.cwd ?? args.workspaceRoot); + await assertDirectory(cwd); + await materializeSidecar({ + persona: args.persona, + inputValues: inputResolution.values, + cwd, + log: args.log + }); + const task = run.prompt; + const spec = buildNonInteractiveSpec({ + harness: args.persona.harness, + personaId: args.persona.id, + model: args.persona.model, + systemPrompt: renderedSystemPrompt, + harnessSettings: args.persona.harnessSettings, + mcpServers: mcpResolution.servers, + permissions: args.persona.permissions, + task, + name: args.persona.id, + workingDirectory: cwd + }); + for (const warning of spec.warnings) { + args.log('warn', 'harness.spec.warning', { warning }); + } + for (const file of spec.configFiles) { + await writeWorkspaceRelativeFile(cwd, file.path, file.contents); + } + const startedAt = Date.now(); + const childEnv = { + ...callerEnv, + ...(envResolution.value ?? {}), + ...inputResolution.values, + ...(run.inputs ?? {}), + ...(run.env ?? {}), + WORKFORCE_PERSONA_ID: args.persona.id, + WORKFORCE_AGENT_ID: args.agent.id, + WORKFORCE_DEPLOYMENT_ID: args.deployment.id, + WORKFORCE_WORKSPACE_ID: args.workspaceId + }; + const result = await spawnAndCapture({ + bin: spec.bin, + args: [...spec.args], + cwd, + env: childEnv, + timeoutMs: args.persona.harnessSettings.timeoutSeconds + ? args.persona.harnessSettings.timeoutSeconds * 1000 + : undefined + }); + const parsed = extractUsage(result.output, result.stderr); + const harnessResult: HarnessRunResult = { + output: parsed.output.trimEnd(), + exitCode: result.exitCode, + durationMs: Date.now() - startedAt, + ...(parsed.usage ? { usage: parsed.usage } : {}) + }; + await reportHarnessUsage({ + result: harnessResult, + persona: args.persona, + agent: args.agent, + deployment: args.deployment, + workspaceId: args.workspaceId, + env: args.env, + log: args.log + }); + return harnessResult; + }; +} + +async function materializeSidecar(args: { + persona: PersonaSpec; + inputValues: Record; + cwd: string; + log: WorkforceCtx['log']; +}): Promise { + const sidecar = sidecarForPersona(args.persona, args.inputValues); + if (!sidecar) return; + const target = resolveWorkspacePath(args.cwd, sidecar.file); + let body = sidecar.content; + if (sidecar.mode === 'extend') { + try { + const existing = await readFile(target, 'utf8'); + body = `${existing}\n\n---\n\n${sidecar.content}`; + } catch (err) { + if (!isNoEntry(err)) throw err; + } + } + await writeWorkspaceRelativeFile(args.cwd, sidecar.file, body.endsWith('\n') ? body : `${body}\n`); + args.log('debug', 'harness.sidecar.materialized', { file: sidecar.file, mode: sidecar.mode }); +} + +function sidecarForPersona( + persona: PersonaSpec, + inputValues: Record +): { file: 'CLAUDE.md' | 'AGENTS.md'; content: string; mode: 'overwrite' | 'extend' } | undefined { + if (persona.harness === 'claude' && persona.claudeMdContent) { + return { + file: 'CLAUDE.md', + content: renderPersonaInputs(persona.claudeMdContent, inputValues), + mode: persona.claudeMdMode ?? 'overwrite' + }; + } + if ((persona.harness === 'codex' || persona.harness === 'opencode') && persona.agentsMdContent) { + return { + file: 'AGENTS.md', + content: renderPersonaInputs(persona.agentsMdContent, inputValues), + mode: persona.agentsMdMode ?? 'overwrite' + }; + } + return undefined; +} + +async function spawnAndCapture(args: { + bin: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs?: number; +}): Promise<{ output: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const child = spawn(args.bin, args.args, { + cwd: args.cwd, + env: args.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false + }); + let stdout = ''; + let stderr = ''; + let forceKillTimeout: NodeJS.Timeout | undefined; + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + const timeout = + args.timeoutMs !== undefined + ? setTimeout(() => { + child.kill('SIGTERM'); + forceKillTimeout = setTimeout(() => child.kill('SIGKILL'), 1000); + }, args.timeoutMs) + : undefined; + const clearTimers = () => { + if (timeout) clearTimeout(timeout); + if (forceKillTimeout) clearTimeout(forceKillTimeout); + }; + child.on('error', (err) => { + clearTimers(); + resolve({ output: stdout, stderr: `${stderr}${err.message}\n`, exitCode: 1 }); + }); + child.on('close', (code, signal) => { + clearTimers(); + resolve({ + output: stdout, + stderr, + exitCode: typeof code === 'number' ? code : signal ? signalExitCode(signal) : 1 + }); + }); + }); +} + +function signalExitCode(signal: NodeJS.Signals): number { + const code = signal.startsWith('SIG') ? signalCode(signal.slice(3)) : undefined; + return code ? 128 + code : 1; +} + +function signalCode(name: string): number | undefined { + const signals: Record = { + HUP: 1, + INT: 2, + QUIT: 3, + ILL: 4, + TRAP: 5, + ABRT: 6, + BUS: 7, + FPE: 8, + KILL: 9, + USR1: 10, + SEGV: 11, + USR2: 12, + PIPE: 13, + ALRM: 14, + TERM: 15 + }; + return signals[name]; +} + +function extractUsage(output: string, stderr: string): { output: string; usage?: HarnessUsage } { + let usage: HarnessUsage | undefined; + const cleaned = output + .split(/\r?\n/) + .filter((line) => { + const parsed = parseUsageLine(line); + if (parsed) { + usage = parsed; + return false; + } + return true; + }) + .join('\n'); + if (!usage) { + for (const line of stderr.split(/\r?\n/)) { + const parsed = parseUsageLine(line); + if (parsed) { + usage = parsed; + break; + } + } + } + return { output: cleaned, ...(usage ? { usage } : {}) }; +} + +function parseUsageLine(line: string): HarnessUsage | undefined { + const trimmed = line.trim(); + const raw = trimmed.startsWith('WORKFORCE_USAGE_JSON=') + ? trimmed.slice('WORKFORCE_USAGE_JSON='.length) + : trimmed.startsWith('__WORKFORCE_USAGE__=') + ? trimmed.slice('__WORKFORCE_USAGE__='.length) + : undefined; + if (!raw) return undefined; + try { + return normalizeUsage(JSON.parse(raw)); + } catch { + return undefined; + } +} + +function normalizeUsage(value: unknown): HarnessUsage { + if (!isRecord(value)) return { raw: value }; + const inputTokens = finiteNumber(value.inputTokens ?? value.input_tokens ?? value.promptTokens ?? value.prompt_tokens); + const outputTokens = finiteNumber(value.outputTokens ?? value.output_tokens ?? value.completionTokens ?? value.completion_tokens); + const totalTokens = finiteNumber(value.totalTokens ?? value.total_tokens); + const costUsd = finiteNumber(value.costUsd ?? value.cost_usd); + return { + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(typeof value.model === 'string' ? { model: value.model } : {}), + ...(typeof value.provider === 'string' ? { provider: value.provider } : {}), + ...(costUsd !== undefined ? { costUsd } : {}), + raw: value + }; +} + +async function reportHarnessUsage(args: { + result: HarnessRunResult; + persona: PersonaSpec; + agent: WorkforceAgentContext; + deployment: WorkforceDeploymentContext; + workspaceId: string; + env: NodeJS.ProcessEnv; + log: WorkforceCtx['log']; +}): Promise { + const usageUrl = firstNonEmpty(args.env.WORKFORCE_USAGE_URL); + const token = firstNonEmpty(args.env.WORKFORCE_DEPLOYMENT_TOKEN); + if (!usageUrl || !token || !args.result.usage) return; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), USAGE_REPORT_TIMEOUT_MS); + try { + const response = await fetch(usageUrl, { + method: 'POST', + signal: controller.signal, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + workspaceId: args.workspaceId, + deploymentId: args.deployment.id, + agentId: args.agent.id, + personaId: args.persona.id, + harness: args.persona.harness, + model: args.result.usage.model ?? args.persona.model, + durationMs: args.result.durationMs, + exitCode: args.result.exitCode, + usage: args.result.usage + }) + }); + if (!response.ok) { + args.log('warn', 'harness.usage.report.failed', { status: response.status }); + } + } catch (err) { + args.log('warn', 'harness.usage.report.failed', { + error: err instanceof Error && err.name === 'AbortError' + ? `timeout after ${USAGE_REPORT_TIMEOUT_MS}ms` + : err instanceof Error ? err.message : String(err) + }); + } finally { + clearTimeout(timer); + } +} + +function resolveWorkspacePath(root: string, inputPath: string): string { + const normalizedRoot = path.resolve(root); + const candidate = inputPath.startsWith(normalizedRoot) + ? path.resolve(inputPath) + : inputPath === '/workspace' + ? normalizedRoot + : inputPath.startsWith('/workspace/') + ? path.resolve(normalizedRoot, inputPath.slice('/workspace/'.length)) + : inputPath.startsWith('/') + ? path.resolve(normalizedRoot, inputPath.slice(1)) + : path.resolve(normalizedRoot, inputPath); + const relative = path.relative(normalizedRoot, candidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`sandbox path escapes workspace root: ${inputPath}`); + } + return candidate; +} + +async function writeWorkspaceRelativeFile(root: string, relativePath: string, contents: string): Promise { + const target = resolveWorkspacePath(root, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, contents, 'utf8'); +} + +async function assertDirectory(dir: string): Promise { + const info = await stat(dir).catch(async (err) => { + if (isNoEntry(err)) { + await mkdir(dir, { recursive: true }); + return stat(dir); + } + throw err; + }); + if (!info.isDirectory()) { + throw new Error(`sandbox cwd is not a directory: ${dir}`); + } + await access(dir, constants.R_OK | constants.W_OK); +} + +function resolveAgentInputValues(agent: AgentRowContext): Record { + return { + ...(agent.inputValues ?? {}), + ...(agent.input_values ?? {}) + }; +} + +function finiteNumber(value: unknown): number | undefined { + const number = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + return Number.isFinite(number) ? number : undefined; +} + +function numberFromEnv(value: string | undefined): number | undefined { + if (!value?.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function firstNonEmpty(...values: Array): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNoEntry(error: unknown): boolean { + return isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} diff --git a/packages/runtime/src/ctx.test.ts b/packages/runtime/src/ctx.test.ts index f0750602..45fb3dc0 100644 --- a/packages/runtime/src/ctx.test.ts +++ b/packages/runtime/src/ctx.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { buildCtx } from './ctx.js'; -import type { SandboxContext } from './types.js'; +import type { MemoryItem, SandboxContext } from './types.js'; const basePersona: PersonaSpec = { id: 'demo', @@ -30,10 +30,14 @@ const stubSandbox: SandboxContext = { } }; -function ctxFor(persona: PersonaSpec, inputValues?: Record) { +function ctxFor( + persona: PersonaSpec, + inputValues?: Record, + workspaceId = 'ws-test' +) { return buildCtx({ persona, - workspaceId: 'ws-test', + workspaceId, sandbox: stubSandbox, harnessRunner: async () => ({ output: '', exitCode: 0, durationMs: 0 }), agent: { @@ -116,3 +120,289 @@ test('buildCtx exposes agent and deployment metadata', () => { parentDeploymentId: 'deployment_parent' }); }); + +test('buildCtx exposes ctx.files as a sandbox file helper', async () => { + const reads: string[] = []; + const writes: Array<{ path: string; contents: string }> = []; + const ctx = buildCtx({ + persona: basePersona, + workspaceId: 'ws-test', + sandbox: { + cwd: '/tmp', + async exec() { + return { output: '', exitCode: 0 }; + }, + async readFile(path) { + reads.push(path); + return 'page body'; + }, + async writeFile(path, contents) { + writes.push({ path, contents }); + } + }, + harnessRunner: async () => ({ output: '', exitCode: 0, durationMs: 0 }), + agent: { + id: 'agent_123', + deployedName: 'docs-demo', + spawnedByAgentId: null + }, + deployment: { + id: 'deployment_456', + triggerKind: 'inbox', + parentDeploymentId: null + } + }); + + assert.equal(await ctx.files.read('/notion/pages/page-1.md'), 'page body'); + await ctx.files.write('/workspace/output/page-1.md', 'essay'); + assert.deepEqual(reads, ['/notion/pages/page-1.md']); + assert.deepEqual(writes, [{ path: '/workspace/output/page-1.md', contents: 'essay' }]); +}); + +test('ctx.memory.save posts to the cloud memory endpoint when sandbox env is present', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test/', + WORKFORCE_WORKSPACE_ID: 'ws-env', + WORKFORCE_AGENT_TOKEN: 'agent-token' + }, + async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + await withFetch(async (url, init) => { + calls.push({ url: String(url), init }); + return jsonResponse({ id: 'mem_123' }); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: { enabled: true, scopes: ['workspace'] } }); + const result = await ctx.memory.save('remember this', { + scope: 'workspace', + tags: ['notion'], + expiresInMs: 2500 + }); + assert.deepEqual(result, { id: 'mem_123' }); + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://cloud.example.test/api/v1/workspaces/ws-env/memory'); + assert.equal(calls[0].init?.method, 'POST'); + assert.equal((calls[0].init?.headers as Record).authorization, 'Bearer agent-token'); + assert.deepEqual(JSON.parse(String(calls[0].init?.body)), { + scope: 'workspace', + content: 'remember this', + tags: ['notion'], + ttlSeconds: 3 + }); + } + ); +}); + +test('ctx.memory.recall fetches normalized cloud memory items', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_AGENT_TOKEN: 'agent-token', + WORKFORCE_WORKSPACE_ID: undefined, + RELAY_WORKSPACE_ID: undefined, + RELAY_DEFAULT_WORKSPACE: undefined + }, + async () => { + const calls: string[] = []; + const item: MemoryItem = { + id: 'mem_123', + content: 'old essay context', + tags: ['essay'], + scope: 'workspace', + createdAt: '2026-05-13T10:00:00.000Z' + }; + await withFetch(async (url) => { + calls.push(String(url)); + return jsonResponse({ items: [item] }); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: true }); + const items = await ctx.memory.recall('essay', { scope: 'workspace', limit: 5 }); + assert.deepEqual(items, [item]); + }); + + const url = new URL(calls[0]); + assert.equal(url.origin + url.pathname, 'https://cloud.example.test/api/v1/workspaces/ws-test/memory'); + assert.equal(url.searchParams.get('scope'), 'workspace'); + assert.equal(url.searchParams.get('query'), 'essay'); + assert.equal(url.searchParams.get('limit'), '5'); + } + ); +}); + +test('ctx.memory uses relaycast sandbox env fallbacks for workspace and agent token', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_WORKSPACE_ID: undefined, + WORKFORCE_AGENT_TOKEN: undefined, + RELAY_WORKSPACE_ID: undefined, + RELAY_AGENT_TOKEN: undefined, + RELAYFILE_TOKEN: undefined, + RELAY_DEFAULT_WORKSPACE: 'ws-relay', + RELAY_AGENT_NAME: 'notion-essay-pr', + RELAY_AGENT_TOKENS: JSON.stringify({ 'notion-essay-pr': 'agent-token-from-map' }) + }, + async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + await withFetch(async (url, init) => { + calls.push({ url: String(url), init }); + return jsonResponse({ id: 'mem_relay' }); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: true }, undefined, ''); + assert.deepEqual(await ctx.memory.save('from relay env'), { id: 'mem_relay' }); + }); + + assert.equal(calls[0].url, 'https://cloud.example.test/api/v1/workspaces/ws-relay/memory'); + assert.equal((calls[0].init?.headers as Record).authorization, 'Bearer agent-token-from-map'); + } + ); +}); + +test('ctx.memory.recall falls back to [] on network failure', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_AGENT_TOKEN: 'agent-token' + }, + async () => { + await withFetch(async () => { + throw new Error('offline'); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: true }); + assert.deepEqual(await ctx.memory.recall('anything'), []); + }); + } + ); +}); + +test('ctx.memory logs a bounded timeout when cloud memory fetch aborts', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_AGENT_TOKEN: 'agent-token' + }, + async () => { + const logs: Array<{ level: string; message: string; attrs?: Record }> = []; + await withFetch(async (_url, init) => { + init?.signal?.dispatchEvent(new Event('abort')); + const err = new Error('aborted'); + err.name = 'AbortError'; + throw err; + }, async () => { + const ctx = buildCtx({ + persona: { ...basePersona, memory: true }, + workspaceId: 'ws-test', + sandbox: stubSandbox, + harnessRunner: async () => ({ output: '', exitCode: 0, durationMs: 0 }), + log: (level, message, attrs) => logs.push({ level, message, attrs }), + agent: { + id: 'agent_123', + deployedName: 'docs-demo', + spawnedByAgentId: null + }, + deployment: { + id: 'deployment_456', + triggerKind: 'inbox', + parentDeploymentId: null + } + }); + assert.equal(await ctx.memory.save('anything'), undefined); + }); + assert.equal(logs[0].message, 'memory.save.failed'); + assert.match(String(logs[0].attrs?.error), /timeout after \d+ms/); + } + ); +}); + +test('ctx.memory stays a safe no-op when cloud auth is absent', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: undefined, + WORKFORCE_AGENT_TOKEN: undefined, + RELAY_AGENT_TOKEN: undefined, + RELAY_API_KEY: undefined, + WORKFORCE_WORKSPACE_TOKEN: undefined + }, + async () => { + let fetchCalled = false; + await withFetch(async () => { + fetchCalled = true; + return jsonResponse({}); + }, async () => { + const ctx = ctxFor({ ...basePersona, memory: true }); + assert.equal(await ctx.memory.save('quiet'), undefined); + assert.deepEqual(await ctx.memory.recall('quiet'), []); + }); + assert.equal(fetchCalled, false); + } + ); +}); + +test('ctx.memory respects memory.enabled=false', async () => { + await withEnv( + { + WORKFORCE_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_AGENT_TOKEN: 'agent-token', + WORKFORCE_WORKSPACE_ID: 'ws-env' + }, + async () => { + let fetchCalled = false; + await withFetch(async () => { + fetchCalled = true; + return jsonResponse({}); + }, async () => { + const ctx = ctxFor({ + ...basePersona, + memory: { enabled: false, scopes: ['workspace'] } + }); + assert.equal(await ctx.memory.save('quiet'), undefined); + assert.deepEqual(await ctx.memory.recall('quiet'), []); + }); + assert.equal(fetchCalled, false); + } + ); +}); + +async function withEnv( + values: Record, + fn: () => Promise +): Promise { + const previous: Record = {}; + for (const key of Object.keys(values)) { + previous[key] = process.env[key]; + const value = values[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function withFetch( + fetchImpl: typeof fetch, + fn: () => Promise +): Promise { + const previous = globalThis.fetch; + globalThis.fetch = fetchImpl; + try { + await fn(); + } finally { + globalThis.fetch = previous; + } +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init + }); +} diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index 09a4b4fb..e7f5f30a 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -2,6 +2,8 @@ import type { PersonaSpec } from '@agentworkforce/persona-kit'; import type { LlmContext, MemoryContext, + FilesContext, + MemoryItem, ScheduleContext, SandboxContext, WorkforceAgentContext, @@ -30,7 +32,8 @@ interface AgentRowContext extends WorkforceAgentContext { * provided — there is no sensible default for spawning a harness or * executing inside an isolated filesystem. Optional subsystems * (`llm`, `memory`, `workflow`, `schedule`, `log`, `integrations`) - * fall back to documented defaults: `memory` becomes a no-op (so + * fall back to documented defaults: `memory` becomes a cloud-backed + * adapter when the sandbox env has enough auth, otherwise a no-op (so * `ctx.memory.save(...)` is safe to call from any handler), the rest * throw with a single-line "not configured" message that names the * persona-side flag a caller would set to enable them. @@ -42,6 +45,7 @@ export interface CtxBuildOptions { agent: AgentRowContext; deployment: WorkforceDeploymentContext; sandbox: SandboxContext; + files?: FilesContext; llm?: LlmContext; memory?: MemoryContext; workflow?: WorkflowContext; @@ -60,6 +64,9 @@ const NOOP_MEMORY: MemoryContext = { } }; +const DEFAULT_CLOUD_BASE_URL = 'https://agentrelay.com'; +const MEMORY_HTTP_TIMEOUT_MS = 15_000; + const UNAVAILABLE_LLM: LlmContext = { async complete() { throw new Error( @@ -115,6 +122,8 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { ...(agent.inputValues ?? {}), ...(agent.input_values ?? {}) }; + const log = options.log ?? defaultLog; + const files = options.files ?? filesFromSandbox(options.sandbox); const ctx: WorkforceCtx = { persona: buildPersonaContext(options.persona, mergedAgentInputValues), agent: { @@ -128,10 +137,11 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { llm: options.llm ?? UNAVAILABLE_LLM, harness: { run: options.harnessRunner }, sandbox: options.sandbox, - memory: options.memory ?? NOOP_MEMORY, + files, + memory: options.memory ?? defaultMemoryFor(options.persona.memory, options.workspaceId, log), workflow: options.workflow ?? UNAVAILABLE_WORKFLOW, schedule: options.schedule ?? UNAVAILABLE_SCHEDULE, - log: options.log ?? defaultLog + log }; // Per-integration clients attach as named ctx fields. The deploy step @@ -167,12 +177,205 @@ const CORE_CTX_FIELDS: ReadonlySet = new Set([ 'llm', 'harness', 'sandbox', + 'files', 'memory', 'workflow', 'schedule', 'log' ]); +function filesFromSandbox(sandbox: SandboxContext): FilesContext { + return { + read(path) { + return sandbox.readFile(path); + }, + write(path, contents) { + return sandbox.writeFile(path, contents); + } + }; +} + +function defaultMemoryFor( + memoryConfig: PersonaSpec['memory'], + workspaceId: string, + log: WorkforceCtx['log'], + processEnv: NodeJS.ProcessEnv = process.env +): MemoryContext { + if ( + memoryConfig === false || + memoryConfig === undefined || + (typeof memoryConfig === 'object' && memoryConfig !== null && 'enabled' in memoryConfig && memoryConfig.enabled === false) + ) { + return NOOP_MEMORY; + } + const cloudBaseUrl = firstNonEmpty( + processEnv.WORKFORCE_CLOUD_URL, + processEnv.WORKFORCE_DEPLOY_CLOUD_URL, + processEnv.AGENTRELAY_CLOUD_URL + ) ?? DEFAULT_CLOUD_BASE_URL; + const agentToken = resolveAgentToken(processEnv); + const resolvedWorkspaceId = firstNonEmpty( + processEnv.WORKFORCE_WORKSPACE_ID, + processEnv.RELAY_WORKSPACE_ID, + processEnv.RELAY_DEFAULT_WORKSPACE, + workspaceId + ) ?? workspaceId; + if (!agentToken || !resolvedWorkspaceId) return NOOP_MEMORY; + return createCloudMemoryContext({ + cloudBaseUrl, + workspaceId: resolvedWorkspaceId, + agentToken, + log + }); +} + +function createCloudMemoryContext(args: { + cloudBaseUrl: string; + workspaceId: string; + agentToken: string; + log: WorkforceCtx['log']; +}): MemoryContext { + const endpoint = new URL( + `/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/memory`, + normalizeBaseUrl(args.cloudBaseUrl) + ); + return { + async save(content, opts) { + try { + const response = await fetchWithTimeout(endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${args.agentToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + scope: opts?.scope ?? 'workspace', + content, + ...(opts?.tags ? { tags: opts.tags } : {}), + ...memoryTtl(opts) + }) + }); + if (!response.ok) { + args.log('warn', 'memory.save.failed', { status: response.status }); + return undefined; + } + const body = await response.json().catch(() => ({})) as { id?: unknown }; + return typeof body.id === 'string' ? { id: body.id } : undefined; + } catch (err) { + args.log('warn', 'memory.save.failed', { error: memoryFetchErrorMessage(err) }); + return undefined; + } + }, + async recall(query, opts) { + try { + const url = new URL(endpoint); + url.searchParams.set('scope', opts?.scope ?? opts?.scopes?.[0] ?? 'workspace'); + url.searchParams.set('query', query); + if (opts?.limit !== undefined) url.searchParams.set('limit', String(opts.limit)); + if (opts?.tags?.length) url.searchParams.set('tags', opts.tags.join(',')); + const response = await fetchWithTimeout(url, { + headers: { authorization: `Bearer ${args.agentToken}` } + }); + if (!response.ok) { + args.log('warn', 'memory.recall.failed', { status: response.status }); + return []; + } + const body = await response.json().catch(() => ({})) as { items?: unknown }; + return normalizeMemoryItems(body.items); + } catch (err) { + args.log('warn', 'memory.recall.failed', { error: memoryFetchErrorMessage(err) }); + return []; + } + } + }; +} + +async function fetchWithTimeout(input: URL | string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MEMORY_HTTP_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function memoryFetchErrorMessage(error: unknown): string { + if (isAbortError(error)) return `timeout after ${MEMORY_HTTP_TIMEOUT_MS}ms`; + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function memoryTtl(opts: Parameters[1]): { ttlSeconds?: number } { + if (typeof opts?.ttlSeconds === 'number' && Number.isFinite(opts.ttlSeconds) && opts.ttlSeconds > 0) { + return { ttlSeconds: Math.ceil(opts.ttlSeconds) }; + } + if (typeof opts?.expiresInMs === 'number' && Number.isFinite(opts.expiresInMs) && opts.expiresInMs > 0) { + return { ttlSeconds: Math.ceil(opts.expiresInMs / 1000) }; + } + return {}; +} + +function normalizeMemoryItems(value: unknown): MemoryItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (typeof item !== 'object' || item === null) return []; + const record = item as Record; + if (typeof record.id !== 'string' || typeof record.content !== 'string') return []; + const scope = record.scope === 'user' || record.scope === 'global' ? record.scope : 'workspace'; + return [{ + id: record.id, + content: record.content, + tags: Array.isArray(record.tags) ? record.tags.filter((tag): tag is string => typeof tag === 'string') : [], + scope, + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '' + }]; + }); +} + +function firstNonEmpty(...values: Array): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function resolveAgentToken(processEnv: NodeJS.ProcessEnv): string | undefined { + return firstNonEmpty( + processEnv.WORKFORCE_AGENT_TOKEN, + processEnv.RELAY_AGENT_TOKEN, + processEnv.RELAYFILE_TOKEN, + tokenFromAgentTokenMap(processEnv.RELAY_AGENT_TOKENS, processEnv.RELAY_AGENT_NAME), + processEnv.RELAY_API_KEY, + processEnv.WORKFORCE_WORKSPACE_TOKEN + ); +} + +function tokenFromAgentTokenMap(raw: string | undefined, agentName: string | undefined): string | undefined { + const value = raw?.trim(); + if (!value) return undefined; + if (!value.startsWith('{')) return value; + try { + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const tokens = parsed as Record; + const namedToken = agentName ? tokens[agentName] : undefined; + if (typeof namedToken === 'string' && namedToken.trim()) return namedToken.trim(); + const singleToken = Object.values(tokens).find((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0); + return singleToken?.trim(); + } catch { + return undefined; + } +} + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/, ''); +} + function buildPersonaContext( persona: PersonaSpec, agentInputValues: Record | undefined, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 88ea43e8..4c473820 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -6,6 +6,7 @@ export { handler, isWorkforceHandler } from './handler.js'; export type { HarnessRunArgs, HarnessRunResult, + FilesContext, IntegrationClients, LlmContext, MemoryContext, diff --git a/packages/runtime/src/proactive.test.ts b/packages/runtime/src/proactive.test.ts index 8a1c8f72..2cfdfd64 100644 --- a/packages/runtime/src/proactive.test.ts +++ b/packages/runtime/src/proactive.test.ts @@ -42,6 +42,14 @@ function fakeCtx(over: Partial = {}): WorkforceCtx { /* no-op */ } }, + files: { + async read() { + return ''; + }, + async write() { + /* no-op */ + } + }, memory: { async save() { /* no-op */ diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index 7be3b03c..509e8f2d 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -1,5 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import http from 'node:http'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import type { AddressInfo } from 'node:net'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { startRunner } from './runner.js'; import { handler } from './handler.js'; @@ -137,6 +142,137 @@ test('startRunner skips envelopes that the shim can not translate', async () => assert.ok(logs.find((l) => l.message === 'runner.envelope.unsupported')); }); +test('startRunner supplies cloud github and harness defaults when generated runner passes none', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-runtime-cloud-defaults-')); + const binDir = path.join(dir, 'bin'); + const workspaceRoot = path.join(dir, 'workspace'); + const usageRequests: unknown[] = []; + const usageServer = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + usageRequests.push({ + authorization: req.headers.authorization, + body: JSON.parse(body) + }); + res.writeHead(204).end(); + }); + }); + await new Promise((resolve) => usageServer.listen(0, '127.0.0.1', resolve)); + const usageAddress = usageServer.address(); + assert.ok(usageAddress && typeof usageAddress === 'object'); + const usageUrl = `http://127.0.0.1:${(usageAddress as AddressInfo).port}/usage`; + + const previousEnv = snapshotEnv([ + 'PATH', + 'WORKFORCE_SANDBOX_ROOT', + 'RELAYFILE_MOUNT_ROOT', + 'WORKFORCE_USAGE_URL', + 'WORKFORCE_DEPLOYMENT_TOKEN' + ]); + try { + await writeFakeHarness(binDir, 'claude', [ + 'Generated essay from fake harness', + 'WORKFORCE_USAGE_JSON={"inputTokens":11,"outputTokens":7,"totalTokens":18,"model":"fake-model"}' + ].join('\n')); + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`; + process.env.WORKFORCE_SANDBOX_ROOT = workspaceRoot; + process.env.RELAYFILE_MOUNT_ROOT = workspaceRoot; + process.env.WORKFORCE_USAGE_URL = usageUrl; + process.env.WORKFORCE_DEPLOYMENT_TOKEN = 'deployment-token'; + + let harnessOutput = ''; + let pullRequestUrl = ''; + await startRunner({ + persona: { + ...persona, + integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + handler: handler(async (ctx) => { + assert.ok(ctx.github, 'github client should be attached from persona integrations'); + const result = await ctx.harness.run({ cwd: '/workspace', prompt: 'draft the essay' }); + harnessOutput = result.output; + const pr = await ctx.github.createPullRequest({ + owner: 'AgentWorkforce', + repo: 'proactive-agents', + title: 'Essay: Launch notes', + body: 'Drafted by test', + head: 'essay/launch-notes', + base: 'main', + files: { 'output/launch-notes.md': result.output } + }); + pullRequestUrl = pr.url; + }), + subsystems: { + log: () => { + /* keep test output quiet */ + } + }, + envelopes: streamOf([ + { id: 'e1', workspace: 'ws-test', type: 'cron.tick', occurredAt: 'x', name: 'tick' } + ]) + }); + + assert.equal(harnessOutput, 'Generated essay from fake harness'); + assert.match(pullRequestUrl, /^\/github\/repos\/AgentWorkforce\/proactive-agents\/pulls\//); + const pullDrafts = await readdir( + path.join(workspaceRoot, 'github/repos/AgentWorkforce/proactive-agents/pulls') + ); + assert.equal(pullDrafts.length, 1); + const pullDraft = JSON.parse( + await readFile( + path.join(workspaceRoot, 'github/repos/AgentWorkforce/proactive-agents/pulls', pullDrafts[0]), + 'utf8' + ) + ) as { title?: string; files?: Record }; + assert.equal(pullDraft.title, 'Essay: Launch notes'); + assert.equal(pullDraft.files?.['output/launch-notes.md'], 'Generated essay from fake harness'); + assert.equal(usageRequests.length, 1); + const usageRequest = usageRequests[0] as { + authorization?: string; + body?: { + durationMs?: number; + usage?: { raw?: unknown }; + [key: string]: unknown; + }; + }; + assert.equal(usageRequest.authorization, 'Bearer deployment-token'); + assert.equal(typeof usageRequest.body?.durationMs, 'number'); + assert.deepEqual(usageRequest.body, { + workspaceId: 'ws-test', + deploymentId: 'deployment_456', + agentId: 'agent_123', + personaId: 'demo', + harness: 'claude', + model: 'fake-model', + durationMs: usageRequest.body?.durationMs, + exitCode: 0, + usage: { + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, + model: 'fake-model', + raw: { + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, + model: 'fake-model' + } + } + }); + } finally { + restoreEnv(previousEnv); + await new Promise((resolve) => usageServer.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + } +}); + test('buildCtx rejects integrations that collide with core fields', async () => { const { buildCtx } = await import('./ctx.js'); assert.throws( @@ -174,3 +310,32 @@ test('startRunner throws when workspaceId is missing from both options and env', if (previous !== undefined) process.env.WORKFORCE_WORKSPACE_ID = previous; } }); + +async function writeFakeHarness(binDir: string, name: string, stdout: string): Promise { + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, name), + [ + '#!/usr/bin/env node', + `process.stdout.write(${JSON.stringify(stdout.endsWith('\n') ? stdout : `${stdout}\n`)});` + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, name), 0o755); +} + +function snapshotEnv(keys: string[]): Record { + const out: Record = {}; + for (const key of keys) out[key] = process.env[key]; + return out; +} + +function restoreEnv(snapshot: Record): void { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index 340187cc..df43cc80 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -1,11 +1,11 @@ import type { PersonaSpec } from '@agentworkforce/persona-kit'; +import { createCloudRuntimeDefaults } from './cloud-defaults.js'; import { buildCtx, type CtxBuildOptions } from './ctx.js'; import { isWorkforceHandler } from './handler.js'; import { shimEnvelope, type RawGatewayEnvelope } from './shim.js'; import type { HarnessRunArgs, HarnessRunResult, - SandboxContext, WorkforceAgentContext, WorkforceDeploymentContext, WorkforceEvent, @@ -40,7 +40,7 @@ export interface StartRunnerOptions { * package's mode-specific entry points (`runDev`, `runSandbox`) supply * the wired-up versions. Tests pass in-memory fakes here. */ - subsystems?: Partial>; + subsystems?: Partial>; /** * Source of raw envelopes to dispatch. The default reads NDJSON from * stdin so a parent process can write `RawGatewayEnvelope` lines and @@ -49,38 +49,13 @@ export interface StartRunnerOptions { */ envelopes?: AsyncIterable; /** - * Harness runner. Required because spawning a harness inside a sandbox - * is mode-specific (Daytona exec vs local child_process). When omitted, - * `ctx.harness.run` throws a clear error. + * Harness runner override. When omitted, the runtime spawns the persona's + * declared harness in the cloud workspace root (`/workspace` when present, + * otherwise cwd). */ harnessRunner?: (args: HarnessRunArgs) => Promise; } -const HARNESS_UNAVAILABLE: (args: HarnessRunArgs) => Promise = async () => { - throw new Error( - 'ctx.harness.run is unavailable: this runner was started without a harnessRunner. Use `workforce deploy --mode sandbox` to run inside Daytona, or supply a harnessRunner via StartRunnerOptions.' - ); -}; - -const PROCESS_FS_SANDBOX: SandboxContext = { - cwd: process.cwd(), - async exec() { - throw new Error( - 'ctx.sandbox.exec is unavailable: this runner was started without a SandboxContext. Use `workforce deploy --mode sandbox` to enable a Daytona sandbox.' - ); - }, - async readFile() { - throw new Error( - 'ctx.sandbox.readFile is unavailable: this runner was started without a SandboxContext.' - ); - }, - async writeFile() { - throw new Error( - 'ctx.sandbox.writeFile is unavailable: this runner was started without a SandboxContext.' - ); - } -}; - /** * Cold-start the agent. Returns a promise that resolves once the envelope * stream completes (in production this is essentially "never", since the @@ -115,19 +90,33 @@ export async function startRunner(options: StartRunnerOptions): Promise { ); } + const log = options.subsystems?.log; + const cloudDefaults = createCloudRuntimeDefaults({ + persona: options.persona, + agent: options.agent, + deployment: options.deployment, + workspaceId, + log: log ?? defaultRunnerLog + }); + const integrations = { + ...(cloudDefaults.integrations ?? {}), + ...(options.subsystems?.integrations ?? {}) + }; + const ctx = buildCtx({ persona: options.persona, agent: options.agent, deployment: options.deployment, workspaceId, - sandbox: options.subsystems?.sandbox ?? PROCESS_FS_SANDBOX, - harnessRunner: options.harnessRunner ?? HARNESS_UNAVAILABLE, + sandbox: options.subsystems?.sandbox ?? cloudDefaults.sandbox, + files: options.subsystems?.files ?? cloudDefaults.files, + harnessRunner: options.harnessRunner ?? cloudDefaults.harnessRunner, ...(options.subsystems?.llm ? { llm: options.subsystems.llm } : {}), ...(options.subsystems?.memory ? { memory: options.subsystems.memory } : {}), ...(options.subsystems?.workflow ? { workflow: options.subsystems.workflow } : {}), ...(options.subsystems?.schedule ? { schedule: options.subsystems.schedule } : {}), ...(options.subsystems?.log ? { log: options.subsystems.log } : {}), - ...(options.subsystems?.integrations ? { integrations: options.subsystems.integrations } : {}) + ...(Object.keys(integrations).length > 0 ? { integrations } : {}) }); ctx.log('info', 'runner.started', { @@ -150,6 +139,11 @@ export async function startRunner(options: StartRunnerOptions): Promise { ctx.log('info', 'runner.envelope-stream.ended', { persona: options.persona.id }); } +function defaultRunnerLog(level: 'debug' | 'info' | 'warn' | 'error', message: string, attrs?: Record): void { + const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout; + stream.write(`${JSON.stringify({ t: new Date().toISOString(), level, message, ...(attrs ?? {}) })}\n`); +} + async function dispatch( ctx: Parameters[0], fn: WorkforceHandler, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 23618dab..9929744f 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -74,6 +74,18 @@ export interface HarnessRunResult { exitCode: number; /** Wall-clock duration in milliseconds. */ durationMs: number; + /** Optional usage metadata emitted by a harness or launcher. */ + usage?: HarnessUsage; +} + +export interface HarnessUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + provider?: string; + costUsd?: number; + raw?: unknown; } export interface HarnessRunArgs { @@ -106,15 +118,25 @@ export interface SandboxContext { writeFile(path: string, contents: string): Promise; } +export interface FilesContext { + /** Read a Relayfile/sandbox-visible file. */ + read(path: string): Promise; + /** Write a Relayfile/sandbox-visible file. */ + write(path: string, contents: string): Promise; +} + export interface MemorySaveOptions { tags?: string[]; scope?: PersonaMemoryScope; + /** Optional expiry in seconds from now. */ + ttlSeconds?: number; /** Optional expiry in milliseconds from now. */ expiresInMs?: number; } export interface MemoryRecallOptions { limit?: number; + scope?: PersonaMemoryScope; scopes?: PersonaMemoryScope[]; tags?: string[]; } @@ -128,7 +150,7 @@ export interface MemoryItem { } export interface MemoryContext { - save(content: string, opts?: MemorySaveOptions): Promise; + save(content: string, opts?: MemorySaveOptions): Promise<{ id: string } | void>; recall(query: string, opts?: MemoryRecallOptions): Promise; } @@ -215,6 +237,8 @@ export interface WorkforceCtx extends IntegrationClients { }; /** Sandbox shell + filesystem. */ sandbox: SandboxContext; + /** Relayfile/sandbox file helpers for handlers that should not shell out. */ + files: FilesContext; /** Persistent memory (no-op when persona.memory is false or unset). */ memory: MemoryContext; /** Cloud workflows invocation (HTTP). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e17ec7c..20ce8247 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: packages/cli: dependencies: + '@agent-relay/cloud': + specifier: ^6.0.17 + version: 6.0.19 '@agentworkforce/deploy': specifier: workspace:* version: link:../deploy @@ -53,6 +56,9 @@ importers: packages/deploy: dependencies: + '@agent-relay/cloud': + specifier: ^6.0.17 + version: 6.0.19 '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit @@ -122,6 +128,12 @@ packages: '@agent-assistant/surfaces@0.4.32': resolution: {integrity: sha512-VI1UpwDD/RznfngRKatqL3zkZ6Bo9MszIGwZ5/H68r+6yghsFeTSq42eyoQM90DuQniX6Z/QfPmA890U1ZA2MQ==} + '@agent-relay/cloud@6.0.19': + resolution: {integrity: sha512-MCehAQwZBgoVh5ddFXUwDIxsuEzj6RfmyM3g5AExTYne2FZJmsPCcv/B916WVa7rpu2IxujP04CKEUcS4wiS5Q==} + + '@agent-relay/config@6.0.19': + resolution: {integrity: sha512-vARUMQm5066xyqSJgG1IgpS/4DcogOz/4Qjf5cgo/rs+F/f0R1D5YgvuBNKtmkqIQ9FZ3m6hwsK2ZM0kQyrQMQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -145,6 +157,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-s3@3.1020.0': + resolution: {integrity: sha512-ibfxjP5zLUqpujLE0OTgD+jZ3KStx9dTASL7d7Eekw4sv7ZHv1UN6CPDcKnCNXdPzlBWi5Wc5lWJ4sU1M8ygEQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1045.0': resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} engines: {node: '>=20.0.0'} @@ -1056,6 +1072,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1069,6 +1088,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1087,6 +1109,10 @@ packages: buffer@5.6.0: resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1165,6 +1191,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1519,6 +1549,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nan@2.27.0: + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nanoid@5.1.11: resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} engines: {node: ^18 || >=20} @@ -1713,6 +1746,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1770,6 +1807,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1868,6 +1908,22 @@ snapshots: '@agent-assistant/surfaces@0.4.32': {} + '@agent-relay/cloud@6.0.19': + dependencies: + '@agent-relay/config': 6.0.19 + '@aws-sdk/client-s3': 3.1020.0 + ignore: 7.0.5 + tar: 7.5.15 + optionalDependencies: + ssh2: 1.17.0 + transitivePeerDependencies: + - aws-crt + + '@agent-relay/config@6.0.19': + dependencies: + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -1915,6 +1971,66 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-s3@3.1020.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-bucket-endpoint': 3.972.10 + '@aws-sdk/middleware-expect-continue': 3.972.10 + '@aws-sdk/middleware-flexible-checksums': 3.974.16 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.5.1 + '@smithy/core': 3.24.1 + '@smithy/eventstream-serde-browser': 4.3.1 + '@smithy/eventstream-serde-config-resolver': 4.4.1 + '@smithy/eventstream-serde-node': 4.3.1 + '@smithy/fetch-http-handler': 5.4.1 + '@smithy/hash-blob-browser': 4.3.1 + '@smithy/hash-node': 4.3.1 + '@smithy/hash-stream-node': 4.3.1 + '@smithy/invalid-dependency': 4.3.1 + '@smithy/md5-js': 4.3.1 + '@smithy/middleware-content-length': 4.3.1 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 + '@smithy/middleware-stack': 4.3.1 + '@smithy/node-config-provider': 4.4.1 + '@smithy/node-http-handler': 4.7.1 + '@smithy/protocol-http': 5.4.1 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.3.1 + '@smithy/util-base64': 4.4.1 + '@smithy/util-body-length-browser': 4.3.1 + '@smithy/util-body-length-node': 4.3.1 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 + '@smithy/util-endpoints': 3.5.1 + '@smithy/util-middleware': 4.3.1 + '@smithy/util-retry': 4.4.1 + '@smithy/util-stream': 4.6.1 + '@smithy/util-utf8': 4.3.1 + '@smithy/util-waiter': 4.4.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.1045.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -3137,6 +3253,11 @@ snapshots: dependencies: color-convert: 2.0.1 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + optional: true + asynckit@0.4.0: {} axios@1.16.0: @@ -3151,6 +3272,11 @@ snapshots: base64-js@1.5.1: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + optional: true + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -3180,6 +3306,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -3241,6 +3370,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.27.0 + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3593,6 +3728,9 @@ snapshots: ms@2.1.3: {} + nan@2.27.0: + optional: true + nanoid@5.1.11: {} negotiator@1.0.0: {} @@ -3801,6 +3939,15 @@ snapshots: sisteransi@1.0.5: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.27.0 + optional: true + statuses@2.0.2: {} stdin-discarder@0.3.2: {} @@ -3864,6 +4011,9 @@ snapshots: tslib@2.8.1: {} + tweetnacl@0.14.5: + optional: true + type-is@2.0.1: dependencies: content-type: 1.0.5