diff --git a/desktop/src/main/cloud.test.ts b/desktop/src/main/cloud.test.ts index 652eeb619..bf2ed84c1 100644 --- a/desktop/src/main/cloud.test.ts +++ b/desktop/src/main/cloud.test.ts @@ -49,17 +49,18 @@ describe('normalizeServerUrl', () => { describe('testCloudConnection', () => { it('reports healthy authenticated servers, install support, and health version', async () => { const fetchImpl = fakeFetch([ - json({ status: 'healthy', version: '1.2.3' }), + json({ status: 'healthy', version: '1.2.3', furrow_public_addr: 'furrow.example:8802' }), json({ packages: [] }), json([]) ]) await expect( - testCloudConnection('cp.example/', 'secret', { fetchImpl }) + testCloudConnection('cp.example/', 'secret', { fetchImpl, furrowProbe: async () => true }) ).resolves.toEqual({ ok: true, healthy: true, authOk: true, installApi: true, + furrowAvailable: true, version: '1.2.3', message: 'Connection successful' }) @@ -76,6 +77,7 @@ describe('testCloudConnection', () => { healthy: true, authOk: false, installApi: false, + furrowAvailable: false, message: 'API key rejected' }) }) @@ -87,6 +89,7 @@ describe('testCloudConnection', () => { healthy: false, authOk: false, installApi: false, + furrowAvailable: false, message: 'Could not reach https://cp.example' }) }) @@ -105,6 +108,7 @@ describe('testCloudConnection', () => { healthy: true, authOk: true, installApi: false, + furrowAvailable: false, version: '0.9.0', message: 'Connected; install API unavailable' }) @@ -121,6 +125,20 @@ describe('testCloudConnection', () => { expect(result.ok).toBe(true) expect(result).not.toHaveProperty('version') }) + + it('keeps a healthy connection when the advertised furrow port is unreachable', async () => { + const fetchImpl = fakeFetch([ + json({ status: 'healthy', furrow_public_addr: 'furrow.example:8802' }), + json({ packages: [] }), + json([]), + json({}, 404) + ]) + const result = await testCloudConnection('https://cp.example', 'key', { + fetchImpl, + furrowProbe: async () => false + }) + expect(result).toMatchObject({ ok: true, healthy: true, authOk: true, furrowAvailable: false }) + }) }) describe('applyConnectionProfile', () => { diff --git a/desktop/src/main/cloud.ts b/desktop/src/main/cloud.ts index 1e452955c..90ccbb748 100644 --- a/desktop/src/main/cloud.ts +++ b/desktop/src/main/cloud.ts @@ -1,4 +1,5 @@ import type { CloudTestResult, DesktopSettings } from '../shared/types' +import { connect as netConnect } from 'node:net' import { clearCloudConnection, setCloudConnection, setLocalApiKey } from './connection' export type { CloudTestResult } from '../shared/types' @@ -51,6 +52,54 @@ function versionFrom(value: unknown): string | undefined { : undefined } +function furrowAddressFrom(value: unknown): string | undefined { + const body = record(value) + if (!body) return undefined + for (const key of ['furrow_public_addr', 'furrowPublicAddr', 'FURROW_PUBLIC_ADDR']) { + if (typeof body[key] === 'string' && body[key] !== '') return body[key] as string + } + const furrow = record(body.furrow) + if (!furrow) return undefined + for (const key of ['public_addr', 'publicAddr', 'address']) { + if (typeof furrow[key] === 'string' && furrow[key] !== '') return furrow[key] as string + } + return undefined +} + +function probeFurrow(address: string): Promise { + return new Promise((resolve) => { + let parsed: URL + try { + parsed = new URL(`tls://${address}`) + } catch { + resolve(false) + return + } + const port = Number(parsed.port) + if (!parsed.hostname || !Number.isInteger(port) || port <= 0 || port > 65535) { + resolve(false) + return + } + let settled = false + const finish = (available: boolean): void => { + if (settled) return + settled = true + socket.destroy() + resolve(available) + } + // Reachability only. A TLS handshake here would have to skip certificate + // validation, since furrowd's default certificate is self-signed — and it + // would answer no more than a plain connect does. What actually protects + // the workspace is furrow's own payload encryption plus the per-run token, + // neither of which this probe touches. + const socket = netConnect({ host: parsed.hostname, port }) + socket.setTimeout(1500) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} + async function jsonBestEffort(response: Response): Promise { try { return await response.json() @@ -62,7 +111,7 @@ async function jsonBestEffort(response: Response): Promise { export async function testCloudConnection( url: string, apiKey: string, - deps: { fetchImpl?: typeof fetch } = {} + deps: { fetchImpl?: typeof fetch; furrowProbe?: (address: string) => Promise } = {} ): Promise { const normalized = normalizeServerUrl(url) if (!normalized) { @@ -71,6 +120,7 @@ export async function testCloudConnection( healthy: false, authOk: false, installApi: false, + furrowAvailable: false, message: 'Enter a valid server URL' } } @@ -90,6 +140,7 @@ export async function testCloudConnection( healthy: false, authOk: false, installApi: false, + furrowAvailable: false, message: `Could not reach ${normalized}` } } @@ -97,12 +148,14 @@ export async function testCloudConnection( const healthBody = await jsonBestEffort(health) const healthy = health.ok let version = versionFrom(healthBody) + const furrowAddress = furrowAddressFrom(healthBody) if (!healthy) { return { ok: false, healthy: false, authOk: false, installApi: false, + furrowAvailable: false, ...(version ? { version } : {}), message: `Control plane at ${normalized} is not healthy` } @@ -121,6 +174,7 @@ export async function testCloudConnection( healthy: true, authOk: false, installApi: false, + furrowAvailable: false, ...(version ? { version } : {}), message: 'Could not verify API access' } @@ -132,6 +186,7 @@ export async function testCloudConnection( healthy: true, authOk: false, installApi: false, + furrowAvailable: false, ...(version ? { version } : {}), message: authResponse.status === 401 || authResponse.status === 403 @@ -164,11 +219,21 @@ export async function testCloudConnection( } } + let furrowAvailable = false + if (furrowAddress) { + try { + furrowAvailable = await (deps.furrowProbe ?? probeFurrow)(furrowAddress) + } catch { + furrowAvailable = false + } + } + return { ok: true, healthy: true, authOk: true, installApi, + furrowAvailable, ...(version ? { version } : {}), message: installApi ? 'Connection successful' : 'Connected; install API unavailable' } diff --git a/desktop/src/main/deployEngine.test.ts b/desktop/src/main/deployEngine.test.ts index b3feb90ab..8034cfb25 100644 --- a/desktop/src/main/deployEngine.test.ts +++ b/desktop/src/main/deployEngine.test.ts @@ -40,6 +40,8 @@ function outputs(overrides: Record = {}) { project_id: { value: 'project' }, environment_id: { value: 'environment' }, service_id: { value: 'service' }, + furrow_domain: { value: 'furrow.proxy.test' }, + furrow_port: { value: 12345 }, ...overrides }) } @@ -61,6 +63,43 @@ function deployedState(apiKey = 'prior-key', subdomain = 'agentfield-dead') { } describe('deployment module and execution', () => { + // Workspace sync is an extra. A control plane that is up and reachable is a + // successful deploy whether or not a furrow address came back with it, so a + // missing proxy output must never turn into a failed deployment. + it('still succeeds when the deployment reports no furrow address', async () => { + const plain = workspace() + const fake = harness([ + {}, + { stdout: '{"type":"apply_complete","@message":"Apply complete"}\n' }, + { stdout: outputs({ furrow_domain: undefined, furrow_port: undefined }) } + ]) + const result = await runDeploy(plain.opts, fake.deps) + expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key' }) + expect((result as { furrowAddress?: string }).furrowAddress).toBeUndefined() + }) + + // The Railway provider hands back the proxy domain as an absolute FQDN on + // create ("altaria.proxy.rlwy.net.") but without the trailing dot on refresh. + // Interpolating it raw made the very next deploy rewrite FURROW_PUBLIC_ADDR, + // and a changed service variable restarts the control plane — so a re-deploy + // that should have been a no-op bounced the server. Normalising both places + // the domain is read keeps the published address identical across applies. + it('normalises the proxy domain so redeploys do not rewrite the address', async () => { + const plain = workspace() + const fake = harness([ + {}, + { stdout: '{"type":"apply_complete","@message":"Apply complete"}\n' }, + { stdout: outputs({}) } + ]) + await runDeploy(plain.opts, fake.deps) + const module = readFileSync(join(plain.opts.workspaceDir, 'main.tf'), 'utf8') + for (const line of module.split('\n')) { + if (!line.includes('railway_tcp_proxy.furrow.domain')) continue + expect(line).toContain('trimsuffix(railway_tcp_proxy.furrow.domain, ".")') + } + expect(module).toMatch(/railway_tcp_proxy\.furrow\.domain/) + }) + it('writes the module and a CLI mirror config only when a mirror exists', async () => { const withMirror = workspace(true) const fake = harness([ @@ -69,12 +108,14 @@ describe('deployment module and execution', () => { { stdout: outputs() } ]) const result = await runDeploy(withMirror.opts, fake.deps) - expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key' }) + expect(result).toMatchObject({ ok: true, url: 'https://cp.test', apiKey: 'key', furrowAddress: 'furrow.proxy.test:12345' }) const module = readFileSync(join(withMirror.opts.workspaceDir, 'main.tf'), 'utf8') expect(module).toContain('resource "railway_project" "cp"') expect(module).toContain('workspace_id = var.workspace_id') expect(module).toContain('source_image = "agentfield/control-plane-cloud:latest"') expect(module).not.toMatch(/\bvolume\s*=/) + expect(module).toMatch(/resource "railway_tcp_proxy" "furrow" \{[\s\S]*?application_port = 8802[\s\S]*?environment_id\s*= railway_project\.cp\.default_environment\.id[\s\S]*?service_id\s*= railway_service\.cp\.id[\s\S]*?\}/) + expect(module).toMatch(/resource "railway_variable" "furrow_public_addr" \{[\s\S]*?name\s*= "FURROW_PUBLIC_ADDR"[\s\S]*?value\s*= "\$\{trimsuffix\(railway_tcp_proxy\.furrow\.domain, "\."\)\}:\$\{railway_tcp_proxy\.furrow\.proxy_port\}"[\s\S]*?\}/) expect(module).toContain('output "project_id"') expect(module).toContain('output "environment_id"') expect(module).toContain('output "service_id"') @@ -145,7 +186,11 @@ describe('deployment module and execution', () => { const fake = harness([{}, {}, { stdout: outputs() }], fetchImpl) expect(await runDeploy({ ...fixture.opts, onLine: (line) => lines.push(line) }, fake.deps)).toEqual({ - ok: true, url: 'https://cp.test', apiKey: 'key', message: 'AgentField deployed to Railway.' + ok: true, + url: 'https://cp.test', + apiKey: 'key', + furrowAddress: 'furrow.proxy.test:12345', + message: 'AgentField deployed to Railway.' }) expect(fetchImpl).toHaveBeenCalledTimes(2) const requests = (fetchImpl as ReturnType).mock.calls as unknown as Array<[string, RequestInit]> diff --git a/desktop/src/main/deployEngine.ts b/desktop/src/main/deployEngine.ts index eb5d8b029..045f03bb1 100644 --- a/desktop/src/main/deployEngine.ts +++ b/desktop/src/main/deployEngine.ts @@ -23,6 +23,7 @@ export interface DeployResult { ok: boolean url?: string apiKey?: string + furrowAddress?: string message: string } @@ -71,7 +72,20 @@ resource "railway_service_domain" "cp" { environment_id = railway_project.cp.default_environment.id service_id = railway_service.cp.id } +resource "railway_tcp_proxy" "furrow" { + application_port = 8802 + environment_id = railway_project.cp.default_environment.id + service_id = railway_service.cp.id +} +resource "railway_variable" "furrow_public_addr" { + name = "FURROW_PUBLIC_ADDR" + value = "\${trimsuffix(railway_tcp_proxy.furrow.domain, ".")}:\${railway_tcp_proxy.furrow.proxy_port}" + environment_id = railway_project.cp.default_environment.id + service_id = railway_service.cp.id +} output "url" { value = "https://\${railway_service_domain.cp.domain}" } +output "furrow_domain" { value = trimsuffix(railway_tcp_proxy.furrow.domain, ".") } +output "furrow_port" { value = railway_tcp_proxy.furrow.proxy_port } output "project_id" { value = railway_project.cp.id } output "environment_id" { value = railway_project.cp.default_environment.id } output "service_id" { value = railway_service.cp.id } @@ -322,9 +336,19 @@ export async function runDeploy(opts: DeployEngineOptions, deps: DeploySpawnDeps const projectId = values.project_id?.value const environmentId = values.environment_id?.value const serviceId = values.service_id?.value + const furrowDomain = values.furrow_domain?.value + const furrowPort = values.furrow_port?.value if (typeof url !== 'string' || !url || typeof outputKey !== 'string' || !outputKey || typeof projectId !== 'string' || !projectId || typeof environmentId !== 'string' || !environmentId || typeof serviceId !== 'string' || !serviceId) throw new Error('missing') + // Workspace sync is an extra, so its outputs are read separately and never + // gate the deploy: a control plane that is up and reachable is a success + // even when no furrow address came back with it. + const furrowAddress = + typeof furrowDomain === 'string' && furrowDomain && + typeof furrowPort === 'number' && Number.isInteger(furrowPort) && furrowPort > 0 + ? `${furrowDomain}:${furrowPort}` + : undefined opts.onLine?.('Attaching storage volume…') try { await ensureVolume(opts.railwayToken, projectId, environmentId, serviceId, deps.fetchImpl ?? fetch) @@ -333,7 +357,7 @@ export async function runDeploy(opts: DeployEngineOptions, deps: DeploySpawnDeps return { ok: false, message: `Deployed, but attaching the storage volume failed: ${detail}. Re-run deploy to retry.` } } opts.onLine?.('Storage volume ready') - return { ok: true, url, apiKey: outputKey, message: 'AgentField deployed to Railway.' } + return { ok: true, url, apiKey: outputKey, furrowAddress, message: 'AgentField deployed to Railway.' } } catch { return { ok: false, message: 'Deployment completed, but required outputs are missing.' } } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 28ff77252..a3d8f1e59 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -557,7 +557,7 @@ function main(): void { await saveSettings(settingsFile(), settings) applyConnectionProfile(settings) } - return { ok: result.ok, url: result.url, message: result.message } + return { ok: result.ok, url: result.url, furrowAddress: result.furrowAddress, message: result.message } } finally { cloudDeployInFlight = false } diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index c3ac66e49..9d91a8bf4 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -232,6 +232,7 @@ export interface CloudTestResult { healthy: boolean authOk: boolean installApi: boolean + furrowAvailable: boolean version?: string message: string } @@ -246,6 +247,7 @@ export interface RailwayStatus { export interface CloudDeployResult { ok: boolean url?: string + furrowAddress?: string message: string } diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index 1e54edae2..05f49f1fd 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -210,6 +210,44 @@ Long-running agents can take tens of minutes — poll with backoff (start ~5s, settle at ~30s) and tell the user what is in flight. For live progress, stream Server-Sent Events from `GET /api/v1/executions//events`. +### If the result carries a `workspace_handle`, you can read the files + +Some agents (SWE-AF) mirror the workspace they are building in, so you can open +the actual files instead of reasoning from the summary — including uncommitted +edits and untracked files that no git push would carry. You do not ask whether +this is available and there is nothing to configure: the handle is in the result +when it works and absent when it doesn't. + +```json +"workspace_handle": {"v":1, "remote":"ssh://host:port"|"dir:/path", + "namespace":"...", "key":"<64 hex>", "token":"..."} +``` + +Use it only if `furrow` is on PATH (and, for an `ssh://` handle, `furrow-dial`). +Otherwise ignore it silently and work from the result as usual. + +```bash +# ssh:// handle — furrow-dial carries the protocol; nothing else changes +export FURROW_SSH_COMMAND=furrow-dial FURROW_DIAL_TOKEN= FURROW_DIAL_INSECURE=1 +FURROW_RECOVERY_KEY= furrow clone / ./run-workspace --no-watch + +# dir: handle (same machine) — clone rejects directory remotes, so pair instead. +# The path is the handle's remote with the "dir:" prefix removed; don't append +# anything to it. +git init -q run-workspace && furrow --repo run-workspace watch --no-daemon +furrow --repo run-workspace pair --name --key +furrow --repo run-workspace sync --pull --bootstrap +``` + +`furrow --repo run-workspace sync --follow` keeps it current while the run +works. Read and diff freely. Treat it as a mirror, not a shared drive: it is +one-writer, and edits go back as a merge (`furrow merge --check ""`), +so change files between issues or on a fork rather than while the agent writes. + +`get_workspace_handle` re-fetches a handle mid-run: +`POST /api/v1/execute/.get_workspace_handle` with `{"input":{"run_id":"..."}}`. +`{"available": false}` means no mirror — carry on without it. + **Several at once:** `POST /api/v1/executions/batch-status` with `{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — responses can be large (100KB+), so write to a file and parse from there; never @@ -280,6 +318,9 @@ is enabled), and verify offline with `af verify audit.json`. ## Hard rules - Every call goes through the control plane — never POST to an agent's own port. + The one exception is a `workspace_handle`: its `ssh://` endpoint is a furrow + transport, not the agent's HTTP port, and the per-run token in the handle is + what authorizes it. Reading files there is not an agent call. - Kwargs live under `"input"`. Empty input is `{"input": {}}`. - Async + poll for anything that might exceed a few seconds; sync is for quick lookups only. Independent async calls go out together, not one at a time.