Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions desktop/src/main/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
Expand All @@ -76,6 +77,7 @@ describe('testCloudConnection', () => {
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'API key rejected'
})
})
Expand All @@ -87,6 +89,7 @@ describe('testCloudConnection', () => {
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'Could not reach https://cp.example'
})
})
Expand All @@ -105,6 +108,7 @@ describe('testCloudConnection', () => {
healthy: true,
authOk: true,
installApi: false,
furrowAvailable: false,
version: '0.9.0',
message: 'Connected; install API unavailable'
})
Expand All @@ -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', () => {
Expand Down
62 changes: 61 additions & 1 deletion desktop/src/main/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CloudTestResult, DesktopSettings } from '../shared/types'
import { connect as tlsConnect } from 'node:tls'
import { clearCloudConnection, setCloudConnection, setLocalApiKey } from './connection'

export type { CloudTestResult } from '../shared/types'
Expand Down Expand Up @@ -51,6 +52,49 @@
: 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<boolean> {
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)
}
const socket = tlsConnect({ host: parsed.hostname, port, rejectUnauthorized: false })
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
socket.setTimeout(1500)
socket.once('secureConnect', () => finish(true))
socket.once('timeout', () => finish(false))
socket.once('error', () => finish(false))
})
}

async function jsonBestEffort(response: Response): Promise<unknown> {
try {
return await response.json()
Expand All @@ -62,7 +106,7 @@
export async function testCloudConnection(
url: string,
apiKey: string,
deps: { fetchImpl?: typeof fetch } = {}
deps: { fetchImpl?: typeof fetch; furrowProbe?: (address: string) => Promise<boolean> } = {}
): Promise<CloudTestResult> {
const normalized = normalizeServerUrl(url)
if (!normalized) {
Expand All @@ -71,6 +115,7 @@
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: 'Enter a valid server URL'
}
}
Expand All @@ -90,19 +135,22 @@
healthy: false,
authOk: false,
installApi: false,
furrowAvailable: false,
message: `Could not reach ${normalized}`
}
}

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`
}
Expand All @@ -121,6 +169,7 @@
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
...(version ? { version } : {}),
message: 'Could not verify API access'
}
Expand All @@ -132,6 +181,7 @@
healthy: true,
authOk: false,
installApi: false,
furrowAvailable: false,
...(version ? { version } : {}),
message:
authResponse.status === 401 || authResponse.status === 403
Expand Down Expand Up @@ -164,11 +214,21 @@
}
}

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'
}
Expand Down
27 changes: 25 additions & 2 deletions desktop/src/main/deployEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function outputs(overrides: Record<string, unknown> = {}) {
project_id: { value: 'project' },
environment_id: { value: 'environment' },
service_id: { value: 'service' },
furrow_domain: { value: 'furrow.proxy.test' },
furrow_port: { value: 12345 },
...overrides
})
}
Expand All @@ -61,6 +63,21 @@ 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()
})

it('writes the module and a CLI mirror config only when a mirror exists', async () => {
const withMirror = workspace(true)
const fake = harness([
Expand All @@ -69,12 +86,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*= "\$\{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"')
Expand Down Expand Up @@ -145,7 +164,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<typeof vi.fn>).mock.calls as unknown as Array<[string, RequestInit]>
Expand Down
26 changes: 25 additions & 1 deletion desktop/src/main/deployEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface DeployResult {
ok: boolean
url?: string
apiKey?: string
furrowAddress?: string
message: string
}

Expand Down Expand Up @@ -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 = "\${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 = 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 }
Expand Down Expand Up @@ -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)
Expand All @@ -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.' }
}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export interface CloudTestResult {
healthy: boolean
authOk: boolean
installApi: boolean
furrowAvailable: boolean
version?: string
message: string
}
Expand All @@ -246,6 +247,7 @@ export interface RailwayStatus {
export interface CloudDeployResult {
ok: boolean
url?: string
furrowAddress?: string
message: string
}

Expand Down
39 changes: 39 additions & 0 deletions skills/agentfield-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,42 @@ 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/<execution_id>/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=<token> FURROW_DIAL_INSECURE=1
FURROW_RECOVERY_KEY=<key> furrow clone <remote>/<namespace> ./run-workspace --no-watch

# dir: handle (same machine) — clone rejects directory remotes, so pair instead
git init -q run-workspace && furrow --repo run-workspace watch --no-daemon
furrow --repo run-workspace pair <path>/<run_id> --name <namespace> --key <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 <fork> --check "<cmd>"`),
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/<agent>.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
Expand Down Expand Up @@ -280,6 +316,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.
Expand Down
Loading