Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
35ccdd3
feat(furrow): workspace-handle contract for run workspace mirroring
AbirAbbas Aug 5, 2026
770ba3f
feat(furrow): token-scoped transport for reaching a run's workspace
AbirAbbas Aug 5, 2026
18323b3
feat(furrow): mirror each build's workspace and hand callers a way in
AbirAbbas Aug 5, 2026
b8ff88e
merge: furrowd transport into the furrow workspace-handle branch
AbirAbbas Aug 5, 2026
bc8edff
feat(furrow): let the node bring up its own transport daemon
AbirAbbas Aug 5, 2026
464b9d0
fix(furrow): stop one run's mirror work from blocking every other run
AbirAbbas Aug 5, 2026
c0f7748
fix(furrow): sweep without holding the registry against every other run
AbirAbbas Aug 5, 2026
6269b2a
fix(furrow): point a dir handle at the run's store, not the root abov…
AbirAbbas Aug 5, 2026
daa260d
Vendor furrow binaries for cloud installs
AbirAbbas Aug 6, 2026
0c0854e
Declare furrow configuration in package manifest
AbirAbbas Aug 6, 2026
1589914
Publish furrow workspace when attaching
AbirAbbas Aug 6, 2026
011dea5
Describe computed furrow path defaults instead of baking cloud paths
AbirAbbas Aug 6, 2026
c99f03a
Vendor the furrow-dial client shim alongside the daemon
AbirAbbas Aug 6, 2026
b6be99c
fix(furrow): honest handles, durable stores, sanitized paths
AbirAbbas Aug 6, 2026
455d6da
fix(issue): make the worktree-add retry idempotent
AbirAbbas Aug 7, 2026
2b5e728
fix(furrow): bound subprocess time and total disk against the hosted …
AbirAbbas Aug 7, 2026
5541f84
fix(issue): only reclaim a leftover branch after our own attempt failed
AbirAbbas Aug 7, 2026
87ba68c
fix(furrowd): serve the recorded store dir, not one rebuilt from the …
AbirAbbas Aug 7, 2026
d55b236
fix(issue): pin git to the C locale and make the retry tests determin…
AbirAbbas Aug 7, 2026
5561bcd
fix(furrow): repair vendored binaries the installer delivers non-exec…
AbirAbbas Aug 7, 2026
618fb21
fix(furrow): publish to the ref the handle advertises
AbirAbbas Aug 7, 2026
d1aa776
refactor(furrow): repair only the binaries we vendor, never an operat…
AbirAbbas Aug 7, 2026
455948e
Revert "fix(furrow): publish to the ref the handle advertises"
AbirAbbas Aug 7, 2026
22ef2c5
fix(furrow): stop advertising a ref nothing publishes
AbirAbbas Aug 7, 2026
39da6c2
fix(orch): exclude furrow's .furrow/ like the rest of the harness met…
AbirAbbas Aug 7, 2026
d38c636
fix(furrow): cap the client store so the sweeper always has a lever
AbirAbbas Aug 7, 2026
226dc99
fix(furrowd): close live connections on shutdown instead of hanging
AbirAbbas Aug 7, 2026
9b8fa18
fix(furrow): stop a run with no ID from sharing one build's mirror
AbirAbbas Aug 10, 2026
15fb1b5
fix(furrow): make an unlimited disk budget actually mean unlimited
AbirAbbas Aug 10, 2026
d9dffe9
fix(furrow): stop budget eviction from deleting a live run's mirror
AbirAbbas Aug 10, 2026
8e33b91
feat(furrow): make workspace mirroring opt-in, not default-on
AbirAbbas Aug 10, 2026
f3cba38
fix(furrow): redact recovery secrets from get_workspace_handle by def…
AbirAbbas Aug 10, 2026
a627698
fix(furrowd): stop loading every run's recovery key on each auth attempt
AbirAbbas Aug 10, 2026
5e7f180
harden(furrow): bound what the sweeper deletes, what furrowd execs, a…
AbirAbbas Aug 10, 2026
2e9a9f3
revert: split out unrelated issue/gitops changes
AbirAbbas Aug 10, 2026
bb1f45f
Merge origin/main: reconcile furrow reasoner with the tagged entrypoi…
AbirAbbas Aug 14, 2026
e413499
feat(furrow): mirror out of the box on cloud deploys — unset flag fol…
AbirAbbas Aug 14, 2026
ff65f32
fix(furrow): harden the cloud auto-enable path — free-space floor, tr…
AbirAbbas Aug 14, 2026
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
55 changes: 55 additions & 0 deletions go/agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,61 @@ user_environment:
description: Engine reasoning-effort variant (low | high) — unset keeps the engine default
- name: SWE_PRO_MAX_COST
description: Per-run USD ceiling for the engine — unset means no per-run cap
- name: SWE_FURROW_ENABLED
description: >-
Furrow workspace mirroring. Set 1/true/yes/on to force it on, 0 to force it
off. Unset, it follows FURROW_PUBLIC_ADDR: cloud deploys (where the desktop
app provisions a public sync port and sets that variable) mirror out of the
box, local installs stay off. Mirroring copies the whole build workspace —
including untracked files and any secrets the coder wrote there — into a
second encrypted store on this node.
# No default on purpose: a non-empty default is injected into the node's
# environment as an EXPLICIT value, which would defeat the unset-follows-
# FURROW_PUBLIC_ADDR behaviour that turns mirroring on in cloud deploys.
default: ""
- name: SWE_FURROW_EXPOSE_SECRETS
description: >-
Return the recovery key and transport token from get_workspace_handle instead of
redacting them. That reasoner authorizes no one — any caller with a run ID is
answered — and the key decrypts the workspace while the token grants read-write
access to its remote. Enable only on a single-tenant, trusted cluster.
default: "0"
- name: SWE_FURROW_BIN
description: Path to the furrow client binary — unset auto-resolves (/usr/local/bin/furrow, then vendored bin/furrow-<os>-<arch>)
default: ""
- name: SWE_FURROWD_BIN
description: Path to the furrow daemon binary — unset auto-resolves (/usr/local/bin/furrowd, then vendored bin/furrowd-<os>-<arch>)
default: ""
- name: SWE_FURROW_DATA_DIR
description: Furrow client data directory — unset uses $AGENTFIELD_HOME/furrow/store when AGENTFIELD_HOME is set, otherwise <workspace root>/.furrow-store
default: ""
- name: SWE_FURROW_REMOTES_ROOT
description: Furrow remote stores root — unset uses $AGENTFIELD_HOME/furrow/remotes when AGENTFIELD_HOME is set, otherwise <workspace root>/.furrow-remotes
default: ""
- name: SWE_FURROW_TTL_HOURS
description: Retention time for mirrored workspaces in hours
default: "72"
- name: SWE_FURROW_MAX_GB
description: Maximum aggregate furrow store size in GB; 0 means unlimited disk (no budget eviction)
default: "20"
- name: FURROWD_ADDR
description: Address on which furrowd listens
default: ":8802"
- name: FURROW_PUBLIC_ADDR
description: >-
Public furrowd host and port advertised in ssh:// workspace handles. The
AgentField desktop app's cloud deploy sets this on the control-plane service
(a Railway TCP proxy in front of furrowd's port 8802) and nodes inherit it;
its presence also turns mirroring on unless SWE_FURROW_ENABLED says otherwise.
default: ""
- name: FURROW_DIAL_TOKEN
description: Token from an ssh:// workspace handle used by furrow clients
- name: FURROW_DIAL_INSECURE
description: Allow the default self-signed furrowd certificate; required for ssh:// handles until certificate pinning is available
- name: FURROWD_TLS_CERT
description: Path to an operator-provided furrowd TLS certificate
- name: FURROWD_TLS_KEY
description: Path to the matching operator-provided furrowd TLS private key
- name: AGENTFIELD_SERVER
description: Control-plane URL
default: http://localhost:8080
Expand Down
Binary file added go/bin/furrow-dial-linux-amd64
Binary file not shown.
Binary file added go/bin/furrow-linux-amd64
Binary file not shown.
Binary file added go/bin/furrowd-linux-amd64
Binary file not shown.
103 changes: 103 additions & 0 deletions go/cmd/furrow-dial/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// furrow-dial is a FURROW_SSH_COMMAND shim. With FURROW_DIAL_INSECURE=1 TLS
// certificate verification is disabled; furrow's encrypted payload remains
// the confidentiality boundary, but transport authentication is then by token.
package main

import (
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"os"
"strings"
)

func main() {
if err := dial(os.Args[1:], os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "furrow-dial: %v\n", err)
os.Exit(1)
}
}

func dial(args []string, stdin io.Reader, stdout io.Writer) error {
if len(args) == 0 {
return errors.New("missing namespace")
}
namespace := args[len(args)-1]
addr := os.Getenv("FURROW_DIAL_ADDR")
if addr == "" {
for i, arg := range args {
if arg == "--" && i+1 < len(args) {
addr = args[i+1]
break
}
}
}
if addr == "" {
return errors.New("FURROW_DIAL_ADDR is unset and argv has no host")
}
token := os.Getenv("FURROW_DIAL_TOKEN")
if token == "" || strings.ContainsAny(token, " \r\n") || strings.ContainsAny(namespace, " \r\n") {
return errors.New("missing or invalid authentication parameters")
}
insecure := os.Getenv("FURROW_DIAL_INSECURE") == "1"
if insecure {
fmt.Fprintln(os.Stderr, "furrow-dial: warning: TLS certificate verification disabled; relying on furrow payload encryption")
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid address: %w", err)
}
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12}) //nolint:gosec -- explicitly operator-controlled for the generated self-signed certificate.
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close()
if _, err := fmt.Fprintf(conn, "AUTH %s %s\n", token, namespace); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil || line != "OK\n" {
return errors.New("authentication failed")
}
inputDone := make(chan error, 1)
go func() {
_, err := io.Copy(conn, stdin)
if tcp, ok := conn.NetConn().(*net.TCPConn); ok {
_ = tcp.CloseWrite()
}
inputDone <- err
}()
outputDone := make(chan error, 1)
go func() {
_, err := io.Copy(stdout, reader)
outputDone <- err
}()
var inputErr, outputErr error
select {
case outputErr = <-outputDone:
// The remote side ended first. Closing the connection makes a pending
// socket write fail; a goroutine blocked reading an interactive stdin
// is harmless because process exit releases it.
_ = conn.Close()
return copyError("receive output", outputErr)
case inputErr = <-inputDone:
// A stdin EOF is a half-close: retain the read side so the remote can
// flush its final protocol response before it exits.
outputErr = <-outputDone
}
if inputErr != nil && !errors.Is(inputErr, net.ErrClosed) {
return fmt.Errorf("send input: %w", inputErr)
}
return copyError("receive output", outputErr)
}

func copyError(operation string, err error) error {
if err != nil && !errors.Is(err, net.ErrClosed) {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
21 changes: 21 additions & 0 deletions go/cmd/furrow-dial/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestDialRejectsMissingNamespace(t *testing.T) {
if err := dial(nil, strings.NewReader(""), &bytes.Buffer{}); err == nil {
t.Fatal("dial accepted missing namespace")
}
}

func TestDialRejectsMissingAddress(t *testing.T) {
t.Setenv("FURROW_DIAL_ADDR", "")
t.Setenv("FURROW_DIAL_TOKEN", "token")
if err := dial([]string{"workspace"}, strings.NewReader(""), &bytes.Buffer{}); err == nil {
t.Fatal("dial accepted missing address")
}
}
Loading
Loading