~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
1159 lines · 57.1 KB Raw History

Contributing

Read AGENTS.md first: working rules, edit discipline, live-run conventions. docs/ARCHITECTURE.md holds decisions and invariants; plans and backlog live in docs/plans/ and NEXT.yaml.

Work in a plain git worktree or branch. Commit small logical commits and push as you go. Live work runs as the VM account (test-0001) from a dev build and a mirror that account can read.

Commands marked "variables carry over" use values set in the block above them.

Dev build

Build the working tree, uncommitted changes included:

cd "$(git rev-parse --show-toplevel)"
scripts/build-socialweb-computer.sh --dev --publish /var/tmp/swc
export PATH=/var/tmp/swc/bin:$PATH
socialweb-computer version

Artifacts:

PathHolds
/var/tmp/swc/bin/socialweb-computerHost binary from --publish.
/var/tmp/swc/srcMirror of the working tree, readable by every account.
$HOME/.tmp/socialweb-computer-build/dev/<worktree>/socialweb-computerHost binary without --publish.
$HOME/.tmp/socialweb-computer-build/dev/<worktree>/<goos>_<goarch>/socialweb-computerPer-platform binary.

A dev binary prints dev build, not reproducible with its commit and dirty state.

Options:

OptionEffect
--devBuild the working tree for the host platform.
--publish DIRCopy the host binary to DIR/bin/socialweb-computer, mirror the tree to DIR/src, and run from DIR/src.
--sync-onlyWith --publish, mirror the tree again without rebuilding.
--platform Plinux/amd64 or linux/arm64; repeat for more.
--commit REVCommit to build. Default HEAD; uncommitted changes are not built.
--build-root DIRCheckouts, caches and outputs. Default $HOME/.tmp/socialweb-computer-build.
--update-pinsRebuild owned assets and write new pins to internal/versions/versions.json. make passes it.

A code change in a running market network. Every component runs in the core process, so a component change needs a new core binary; --sync-only only re-mirrors the tree and does not build one:

cd "$(git rev-parse --show-toplevel)"
scripts/build-socialweb-computer.sh --dev --publish /var/tmp/swc
sudo -n -u test-0001 env HOME=/home/test-0001 XDG_RUNTIME_DIR=/run/user/$(id -u test-0001) \
  /var/tmp/swc/bin/socialweb-computer dev restart mn1 core --binary /var/tmp/swc/bin/socialweb-computer

dev restart [<instance>] [<app>/]core replaces the core of an instance, or of a market network child application with <app>/; replace mn1 with yours. dev restart <instance> [<app>/][<workspace>:][<kind>/]<component> restarts one component through its kcp object without a new binary.

Release build

make builds both release platforms and updates owned asset pins:

cd "$(git rev-parse --show-toplevel)"
make
C=$(git rev-parse HEAD)
ls "$HOME/.tmp/socialweb-computer-build/release/$C"

Artifacts under $HOME/.tmp/socialweb-computer-build/release/<commit>/:

PathHolds
socialweb-computer-linux-amd64Host binary, Linux amd64.
socialweb-computer-linux-arm64Host binary, Linux arm64.
SHA256SUMSsha256 of both binaries.
release.jsonCommit, build mode, one entry per platform.
version-linux-<arch>.json, .txtPinned version and sha256 of every embedded asset.

Build one commit and platform:

cd "$(git rev-parse --show-toplevel)"
C=$(git rev-parse HEAD)
scripts/build-socialweb-computer.sh --platform linux/amd64 --commit "$C"
B=$HOME/.tmp/socialweb-computer-build/release/$C/socialweb-computer-linux-amd64
SHA=$(sha256sum "$B" | cut -d' ' -f1)
echo "$B $SHA"

If make changed internal/versions/versions.json, commit it with the source change.

B and SHA are what install and upgrade --binary --sha256 take. CI publishes the same names to the public bucket: releases/<tag>/ on a v* tag, main/<commit>/ and main/latest/ on main. The guest image is published once per pin, to guest-images/<target>/<sha256 of the pinned image.json>/, and CI fetches it from there instead of rebuilding it when its pins did not change.

Copy a release to another host:

# variables carry over: C, B
R=$(dirname "$B")
DEST=user@host
scp "$B" "$R/SHA256SUMS" "$DEST:/var/tmp/"
ssh "$DEST" 'cd /var/tmp && grep " socialweb-computer-linux-amd64$" SHA256SUMS | sha256sum -c -'

Then install or upgrade there:

# variables carry over: DEST
SHA=$(ssh "$DEST" "awk '\$2 == \"socialweb-computer-linux-amd64\" {print \$1}' /var/tmp/SHA256SUMS")
ssh "$DEST" 'sudo install -m 0755 /var/tmp/socialweb-computer-linux-amd64 /usr/local/bin/socialweb-computer'
ssh "$DEST" "sudo /usr/local/bin/socialweb-computer install bidder --handle <handle>"

Fresh host

NeedUbuntu / DebianFedora / RHEL
newuidmap, newgidmap for per-VM user namespacesuidmapshadow-utils
ssh, ssh-keygenopenssh-clientopenssh-clients
curlcurlcurl
zoneinfo for embedded postgrestzdatatzdata
jq for examplesjqjq
# Ubuntu / Debian
sudo apt-get update && sudo apt-get install -y uidmap openssh-client curl tzdata jq
# Fedora / RHEL
# sudo dnf install -y shadow-utils openssh-clients curl tzdata jq

Host requirements:

  • 64-bit Linux, /dev/kvm, /dev/net/tun, systemd 253+, cgroup v2.
  • /dev/kvm read-write for the kvm group.
  • One /etc/subuid and /etc/subgid range of N * 65536 ids for the bidder account (install bidder --vm-slots N, default 256).
  • A gateway host also runs Caddy with /etc/caddy/sites/*.caddy imported from /etc/caddy/Caddyfile.

install bidder and install gateway create their accounts (swc-bidder, socialweb), subuid ranges, units and linger. Do not create them by hand for a production install. For a dev host, create the test-0001 account once:

sudo useradd -m -s /bin/bash -G kvm -K SUB_UID_COUNT=0 -K SUB_GID_COUNT=0 test-0001
sudo usermod --add-subuids 100000-1148575 --add-subgids 100000-1148575 test-0001
sudo install -d -m 0700 -o test-0001 -g test-0001 ~test-0001/.ssh
sudo install -m 0600 -o test-0001 -g test-0001 ~/.ssh/authorized_keys ~test-0001/.ssh/
sudo loginctl enable-linger test-0001

Log in with ssh test-0001@<host> or sudo machinectl shell test-0001@. sudo -u and runuser give no /run/user/<uid> session.

After an install, check the host contract as the account:

sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  /var/lib/socialweb-computer/swc-bidder/bin/current/socialweb-computer \
  check-host -role bidder -state-dir /var/lib/socialweb-computer/swc-bidder
sudo runuser -u socialweb -- env XDG_RUNTIME_DIR=/run/user/$(id -u socialweb) \
  /var/lib/socialweb-computer/socialweb/bin/current/socialweb-computer \
  check-host -role gateway -state-dir /var/lib/socialweb-computer/socialweb

Install a release bidder

From the public bucket, with no credentials:

HANDLE=<your atproto handle>
BUCKET=https://socialweb-computer-kcp.sfo3.digitaloceanspaces.com
V=$(curl -fsSL https://api.github.com/repos/publicdomainrelay/socialweb-computer-kcp/tags \
  | jq -r '.[].name' | grep '^v' | sort -V | tail -1)
ARCH=linux-$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
mkdir -p /var/tmp/swc-release && cd /var/tmp/swc-release
curl -fLO "$BUCKET/releases/$V/binaries/socialweb-computer-$ARCH"
curl -fLO "$BUCKET/releases/$V/binaries/SHA256SUMS"
grep " socialweb-computer-$ARCH$" SHA256SUMS | sha256sum -c -
sudo install -m 0755 "socialweb-computer-$ARCH" /usr/local/bin/socialweb-computer
sudo /usr/local/bin/socialweb-computer install bidder --handle "$HANDLE"

Artifacts and state:

ItemPath
Unitsocialweb-computer-bidder@<handle>.service in swc-bidder's user manager.
State/var/lib/socialweb-computer/swc-bidder.
kcp admin kubeconfig/var/lib/socialweb-computer/swc-bidder/handles/<hash>/kcp/admin.kubeconfig (root only).
Installed binary<state>/bin/current/socialweb-computer; previous at bin/previous.
Config<state>/config.json; env file <state>/gateway.env for a gateway.

install bidder is idempotent. A re-run with another --handle adds an instance. A re-run after a changed config or unit restarts the core. --dry-run prints the step list and changes nothing.

Install a locally built release binary:

# variables carry over: C, B
SHA=$(sha256sum "$B" | cut -d' ' -f1)
sudo "$B" install bidder --handle <handle>

Bidder association

A fresh install creates a new DID. Association is one badgeBlueKeys record in the operator's repo with challenge the operator DID and keyId the bidder DID, service bidder_associate. The AtprotoRecordWatch/association of the application reads it and the identity controller writes the operator DID into MarketIdentity.status.associations; no manual patch is needed. The helper reads the handle and password from a mode-0600 credentials file and prints neither.

HANDLE=bobvmbuilder.bsky.social
ACCOUNT=BOB
CREDS=$HOME/.bobvmbuilder_bsky_social_source
STATE=/var/lib/socialweb-computer/swc-bidder
K=$(sudo sh -c "ls $STATE/handles/*/kcp/admin.kubeconfig" | head -1)
BIDDER_DID=$(sudo kubectl --kubeconfig "$K" get marketidentity bidder -o jsonpath='{.status.did}')
deno run -A --no-lock scripts/associate-bidder.ts --creds "$CREDS" --account "$ACCOUNT" --bidder-did "$BIDDER_DID"
sudo kubectl --kubeconfig "$K" get marketidentity bidder \
  -o jsonpath='{.status.phase}{" "}{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'

Wait for Associated=True. The bidder side declaration AtprotoRecord/association is rendered and reconciled through the app's PDS.

A requester uses the same helper with its own service and DID. The operator writes it once in the operator's repo; the gateway's AtprotoRecordWatch/association (service requester_associate) verifies it and the identity controller approves the requester automatically. No manual approvedAssociations patch is needed. The gateway kcp is under its state dir (default /var/lib/socialweb-computer/socialweb); the requester MarketIdentity lives in the workspace root:app.

ACCOUNT=ALICE
CREDS=$HOME/.aliceoa_bsky_social_source
KGW=/var/lib/socialweb-computer/socialweb/kcp/admin.kubeconfig
GW=https://127.0.0.1:6443/clusters/root:app
REQUESTER_DID=$(sudo kubectl --kubeconfig "$KGW" --server "$GW" get marketidentity requester -o jsonpath='{.status.did}')
deno run -A --no-lock scripts/associate-bidder.ts --creds "$CREDS" --account "$ACCOUNT" --requester-did "$REQUESTER_DID"
sudo kubectl --kubeconfig "$KGW" --server "$GW" get marketidentity requester \
  -o jsonpath='{.status.phase}{" "}{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'

Non-association mode: set the identity association to Skip and let the controller reconcile it. No badgeBlueKeys record is written.

STATE=/var/lib/socialweb-computer/swc-bidder
K=$(sudo sh -c "ls $STATE/handles/*/kcp/admin.kubeconfig" | head -1)
sudo kubectl --kubeconfig "$K" patch marketbidder app --type=merge \
  -p '{"spec":{"identity":{"association":"Skip"}}}'
sudo kubectl --kubeconfig "$K" get marketidentity bidder \
  -o jsonpath='{.status.phase}{" "}{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'

Bidder on a real account (oauthQr)

Run the bidder as an existing atproto account instead of its own local PDS. The account's repo holds the market records; the bidder keeps no local Pds. Add a systemd drop-in so the mode survives install bidder and upgrade:

STATE=/var/lib/socialweb-computer/swc-bidder
HANDLE=bobvmbuilder.bsky.social
DROP=$STATE/.config/systemd/user/socialweb-computer-bidder@$HANDLE.service.d/10-oauth-qr.conf
sudo -u swc-bidder mkdir -p "$(dirname "$DROP")"
sudo -u swc-bidder tee "$DROP" >/dev/null <<EOF
[Service]
ExecStart=
ExecStart=$STATE/bin/current/socialweb-computer run bidder --handle $HANDLE --fg -- --atproto-oauth-qr
EOF
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  systemctl --user daemon-reload
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  systemctl --user restart socialweb-computer-bidder@$HANDLE.service
K=$(sudo sh -c "ls $STATE/handles/*/kcp/admin.kubeconfig" | head -1)
until sudo kubectl --kubeconfig "$K" get marketidentity bidder -o jsonpath='{.status.pendingAction.url}' 2>/dev/null | grep -q .; do sleep 3; done
U=$(sudo kubectl --kubeconfig "$K" get marketidentity bidder -o jsonpath='{.status.pendingAction.url}')
deno run -A --no-lock scripts/oauth-signin.ts --creds ~/.bobvmbuilder_bsky_social_source --account BOB --url "$U"
sudo kubectl --kubeconfig "$K" get marketidentity bidder \
  -o jsonpath='{.spec.mode}{" "}{.status.phase}{" "}{.status.did}{" "}{.status.pdsUrl}{"\n"}'

Expect oauthQr Ready <the account DID> <the account PDS> and kubectl get pds to return no resources. The QR url expires after 5 minutes.

Upgrade, reinstall, uninstall

Upgrade an installed bidder as its account, then install again:

C=$(git rev-parse HEAD)
BIN=$HOME/.tmp/socialweb-computer-build/release/$C/socialweb-computer-linux-amd64
SHA=$(sha256sum "$BIN" | cut -d' ' -f1)
STATE=/var/lib/socialweb-computer/swc-bidder
sudo install -d -m 0755 /var/tmp/swc-release
sudo install -m 0755 "$BIN" /var/tmp/swc-release/socialweb-computer-${C:0:8}
STAGED=/var/tmp/swc-release/socialweb-computer-${C:0:8}
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  CONFIG_PATH_SOCIALWEB_COMPUTER=$STATE/config.json \
  "$STATE/bin/current/socialweb-computer" upgrade --binary "$STAGED" --sha256 "$SHA"
sudo "$STAGED" install bidder --handle <handle>

Upgrade keeps the state dir, OpenBao, DID and association. rollback swaps bin/current and bin/previous and restarts the core:

sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  CONFIG_PATH_SOCIALWEB_COMPUTER=/var/lib/socialweb-computer/swc-bidder/config.json \
  /var/lib/socialweb-computer/swc-bidder/bin/current/socialweb-computer rollback

Reinstall: run the same install bidder with the new binary. Completed steps are skipped; a changed config or unit restarts the core. If install refuses because bin/current is not the binary you invoked, run upgrade --binary --sha256 first, then install again.

Uninstall, keeping state:

sudo /var/lib/socialweb-computer/swc-bidder/bin/current/socialweb-computer uninstall bidder

Uninstall and purge (removes the state dir, VMs, PDS repo, OpenBao and the account install created):

sudo /var/lib/socialweb-computer/swc-bidder/bin/current/socialweb-computer uninstall bidder --purge

--force proceeds while VM units are active; each stopped VM becomes Lost. --json prints the result. --account NAME selects another account.

A fresh install over an old-shape workspace must purge first: the app WorkspaceType name is immutable. Back up the state dir and Caddy sites before a purge. After a purge the DID is new and the association is needed again. A purge keeps an account that install did not create (an adopted account) but removes the install record, so the next install bidder on that host needs --adopt-account. A shape change of the kcp storage (a new kcp build) also needs a purge: the new kcp does not read the old store.

Dev build on a bidder host

A dev binary needs --allow-dev-binary. Add --adopt-account only when install adopts an account that has no install record (as after a purge that kept the account). --force only if uninstall refuses on active VM units.

cd "$(git rev-parse --show-toplevel)"
HANDLE=bobvmbuilder.bsky.social
STATE=/var/lib/socialweb-computer/swc-bidder
scripts/build-socialweb-computer.sh --dev --publish /var/tmp/swc
C=$(git rev-parse HEAD)
sudo mkdir -p /var/tmp/swc-release
sudo install -m 0755 /var/tmp/swc/bin/socialweb-computer /var/tmp/swc-release/socialweb-computer-${C:0:8}
DEV=/var/tmp/swc-release/socialweb-computer-${C:0:8}
SHA=$(sha256sum "$DEV" | cut -d' ' -f1)
sudo "$DEV" uninstall bidder --purge --json
sudo "$DEV" install bidder --handle "$HANDLE" --allow-dev-binary --adopt-account \
  --state-dir "$STATE" --guest-image-source file:///var/lib/socialweb-computer/guest-image --dry-run
sudo "$DEV" install bidder --handle "$HANDLE" --allow-dev-binary --adopt-account \
  --state-dir "$STATE" --guest-image-source file:///var/lib/socialweb-computer/guest-image

If the install record and old shape are compatible, upgrade instead:

# variables carry over: HANDLE, STATE, DEV, SHA
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  CONFIG_PATH_SOCIALWEB_COMPUTER=$STATE/config.json \
  "$STATE/bin/current/socialweb-computer" upgrade --binary "$DEV" --sha256 "$SHA" --allow-dev-binary
sudo "$DEV" install bidder --handle "$HANDLE" --allow-dev-binary

Deploy the gateway

scripts/deploy.sh installs on a fresh host or upgrades an installed gateway, ships gateway.env and the Gateway spec, writes the Caddy sites, hands over the Caddy certificates, validates Caddy and reloads it.

Fresh or routine gateway deploy:

cd "$(git rev-parse --show-toplevel)"
BINARY=$HOME/.tmp/socialweb-computer-build/release/$(git rev-parse HEAD)/socialweb-computer-linux-amd64
SHA=$(sha256sum "$BINARY" | cut -d' ' -f1)
SSH_KEY=~/.ssh/id_ed25519_no_password \
SSH_TARGET=root@socialweb.computer SSH_PORT=1997 \
ACCOUNT=socialweb STATE_DIR=/var/lib/socialweb-computer/socialweb \
GATEWAY_ENV_FILE=$HOME/.tmp/deploy/gateway.env \
GATEWAY_SPEC_FILE=$HOME/.tmp/deploy/gateway-spec.yaml \
BINARY=$BINARY SHA256=$SHA scripts/deploy.sh

gateway.env and the Gateway spec are required on a fresh host; an existing host may omit them and keep the files in its state. The script refuses a digest mismatch. If BINARY is unset it builds the release of HEAD.

Environment variables:

VariableMeaningDefault
BINARYRelease binary to ship.Build HEAD.
SHA256Published sha256 of BINARY.From SHA256SUMS when the script builds.
SSH_KEYSSH key.
SSH_TARGETDeploy host.root@socialweb.computer
SSH_PORTSSH port.1997
ACCOUNTGateway account.socialweb
STATE_DIRGateway state dir./var/lib/socialweb-computer/socialweb
DOMAINBase domain.socialweb.computer
ORIGINWeb app host.k8s.<DOMAIN>
KCP_ORIGINkcp host.kcp.<DOMAIN>
GATEWAY_ENV_FILEShipped as $STATE_DIR/gateway.env.
GATEWAY_SPEC_FILEShipped as /var/lib/socialweb-computer/gateway-spec-$ACCOUNT.yaml.
ALLOW_DEV_BINARYPass --allow-dev-binary to install gateway and upgrade.off

Caddy certificate handoff (inside scripts/deploy.sh, after the gateway answers):

  • Reads the kcp CA from the gateway's admin kubeconfig into /etc/caddy/socialweb-computer/kcp.crt.
  • Copies $STATE_DIR/tls/tls.crt to gateway.crt.
  • Copies $STATE_DIR/tls/edge-client.crt to edge-client.crt and its key (root:caddy, 0640) to edge-client.key.
  • Writes /etc/caddy/sites/k8s.<domain>.caddy and /etc/caddy/sites/kcp.<domain>.caddy, imports the sites directory, adds skip_install_trust, runs caddy validate and reloads or restarts Caddy.

Verify:

curl -fsS https://k8s.socialweb.computer/api/config | jq
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl status caddy --no-pager
sudo ls -l /etc/caddy/socialweb-computer

k8s.<domain> proxies to the gateway API front on 127.0.0.1:8788. kcp.<domain> proxies to kcp 6443 without a client certificate and to the certificate front 8444 with one.

After a fresh deploy the requester has no gateway session, so cluster claims log no oauth session stored. Sign in with scripts/oauth-signin.ts --creds <file> --account ALICE --url https://k8s.socialweb.computer/ --start-api.

Upgrade a gateway: run the same scripts/deploy.sh with the new BINARY and SHA256. It runs upgrade --binary --sha256 as the account, then install gateway again with bin/current. A routine upgrade keeps state, kcp objects, OpenBao, the requester DID, association, web sessions and running components. A fresh install keeps nothing.

Uninstall the gateway and purge its state:

sudo /var/lib/socialweb-computer/socialweb/bin/current/socialweb-computer uninstall gateway --purge

The deploy inputs live on the orphan branch swc-deploy, in the worktree ~/.tmp/deploy. Credential files ~/.aliceoa_bsky_social_source and ~/.bobvmbuilder_bsky_social_source are mode 0600. Never print, log or commit their values.

PDS ingress provider

A production PDS is a public HTTPS service. Its account DID document carries an atproto_pds service entry whose serviceEndpoint is scheme, host and optional port only. The PDS /.well-known/did.json, createAccount and requestCrawl use the same public host. The PDS may bind loopback; an IngressProxy provides the public URL.

Every app root selects the provider with spec.ingress. The production profiles (prod-qr, market-network, the requester default, gateway prod) default to xrpc-subscription with ingressProxyHost: xrpc.fedproxy.com and IngressProxy/ingress. xrpc.fedproxy.com gives a stable https://<did-subdomain>.<host> while the ingress identity keypair persists.

XRPC subscription:

spec:
  ingress:
    provider: xrpc-subscription
    ingressRef:
      kind: IngressProxy
      name: ingress
    publicHost: example.fedproxy.com
    xrpc:
      ingressProxyHost: xrpc.fedproxy.com
      relayURL: https://relay.example

OpenTunnel (optional; stable route name required):

spec:
  ingress:
    provider: opentunnel
    ingressRef:
      kind: IngressProxy
      name: ingress
    publicHost: example.example
    opentunnel:
      command: [/usr/local/bin/opentunnel, route, add]
      routeName: alice-pds
      localHost: 127.0.0.1:2583
      proxyProtocol: v2

Selection rules:

  • xrpc-subscription when ingress.xrpc.ingressProxyHost is set.
  • opentunnel when ingress.opentunnel is set.
  • Otherwise fail closed. There is no implicit local fallback and no GuestTlsUrl override.
  • iroh is recognized by the API and rejected until a later plan.
  • A missing provider, command, route name or local host is a named failure.
  • A provider that returns a different public URL on restart is StableHostMismatch.
  • Set Pds.spec.requireIngress for every production PDS. No account is created and no sign-in completes while the ingress is not Ready.
  • The first public URL is persisted in Pds.spec.publicURL and Pds.spec.stableHost.

On run bidder and run request, --ingress-provider, --ingress-public-host, --ingress-xrpc-host, --ingress-relay-url, --ingress-opentunnel-command, --ingress-opentunnel-route-name, --ingress-opentunnel-local-host and --ingress-opentunnel-proxy-protocol after -- override the profile defaults. A gateway spec that sets spec.ingress.opentunnel selects OpenTunnel. A MarketNetwork publishes one IngressProxy/dispatcher in status.network.ingress; children inherit it.

Known gap (plan 0022): OpenTunnel is wired but not yet proven on a prod host. Check Pds.spec.publicURL, the ingress Exposure and the DID document before trusting a standalone PDS whose host does not resolve.

Market transport modes

spec.comms.mode is XRPC, Firehose or Both; the requester defaults to Both and a bidder to XRPC. PDS writes happen in every mode.

An external relay or firehose is an EventSource over a RelayEventSource (or a JetstreamEventSource) whose spec.url is the endpoint; the root's spec.comms.eventSourceRefs names those EventSources.

In Firehose and Both the requester composes the public sources itself. spec.comms.relayUrls defaults to our relay https://relay.mini-cloud-0002.chadig.com (a market network's relay replaces it), and spec.comms.jetstreamUrls defaults to the two Jetstream v2 instances, wss://jetstream.us-east.bsky.network and wss://jetstream.us-west.bsky.network at /xrpc/network.bsky.jetstream.subscribeEvents. An empty list turns that source off. A bidder composes no per-bidder stream; while it has live ComputeVMs it composes requester streams from those requesters' DIDs, and resumes one from its stored cursor only when the cursor was saved in the last 10 minutes.

K=$(sudo sh -c "ls /var/lib/socialweb-computer/swc-bidder/handles/*/kcp/admin.kubeconfig" | head -1)
sudo kubectl --kubeconfig "$K" apply -f - <<'YAML'
apiVersion: events.socialweb.computer/v1alpha1
kind: RelayEventSource
metadata: {name: relay-example}
spec: {url: https://relay.example}
---
apiVersion: events.socialweb.computer/v1alpha1
kind: EventSource
metadata: {name: relay-example}
spec: {backendRef: {group: events.socialweb.computer, kind: RelayEventSource, name: relay-example}}
YAML
# Firehose only: no submit XRPC; events come from the named EventSource
socialweb-computer run bidder --host-backend proc --profile prod-qr --handle <handle> \
  -- --comms-mode Firehose --comms-event-source relay-example
# Both: XRPC first, then the public sources and the named EventSource
socialweb-computer run request --name demo \
  -- --comms-mode Both --comms-event-source relay-example

A bidder in Firehose or Both needs a source (a named EventSource or the network relay); otherwise the root fails closed instead of falling back to XRPC. Every market EventStream of a role delivers to one sink, which drops a uri|cid it took in the last 15 s and logs market_event_duplicate_dropped. The StateSync inboxes are not the dedup fact: the requester keeps only records of its own ComputeContracts and the bidder only RFPs, accepts whose bid is in its repo and events whose receipt is in its repo. The requester prunes the inbox of a Released or Failed contract 10 minutes after it is Acked; the bidder prunes every Acked inbox after 10 minutes. Switching back to XRPC deletes the root's market EventStreams.

Account modes

spec.identity.modeWhat runsAssociation
localRepo (default)Local Pds component and a new DIDRequireApproval or Skip
pdsAccountExisting account on a RemotePDS; password in OpenBaoNone
oauthBrowserExisting account signed in from a browser callbackNone
oauthQrExisting account signed in by scanning a QRNone
oauthDelegatedExisting account signed in by a delegated flowNone
oauthSessionProxyThe gateway's per-account session: spec.did and a spec.sessionRef to that session in the vaultNone

run bidder and run request do not set oauthSessionProxy; a cluster claim uses it to act as the signed-in account.

# Managed, unassociated: patch the root after it exists (or use run request -- --skip-qr)
K=$(sudo sh -c "ls /var/lib/socialweb-computer/swc-bidder/handles/*/kcp/admin.kubeconfig" | head -1)
sudo kubectl --kubeconfig "$K" patch marketbidder app --type=merge \
  -p '{"spec":{"identity":{"association":"Skip"}}}'
# Existing account on another PDS (password comes from ATPROTO_PASSWORD)
export ATPROTO_PASSWORD=...   # never echo it
socialweb-computer run bidder --host-backend proc --profile prod-qr --handle <handle> \
  -- --atproto-identity-mode pdsAccount --atproto-pds-url https://pds.example
# OAuth browser
socialweb-computer run request --name demo \
  -- --atproto-identity-mode oauthBrowser --atproto-pds-url https://pds.example

pdsAccount fails closed when it names no PDS. OAuth modes fail closed without a pdsUrl or a resolvable handle. Tokens stay in OpenBao; MarketIdentity.status carries refs only.

The prod CLI requester (run request without --network) signs in with -- --atproto-oauth-qr: the MarketIdentity requester reads and writes its oauth session in the invocation's own OpenBaoServer requester (mount requester, identity/requester/pds-session), asks for the requester role scopes (guest.* included) through qr.fedfork.com, and the CLI prints, on stderr, identity requester is AwaitingScan (no DID yet): scan the qr url to sign in and then identity requester pendingAction scanQr url <url> expiresAt <RFC 3339>, again for every new URL. The operator signs in with scripts/oauth-signin.ts --url "<url>" (AGENTS.md, Prod deployments). It waits without a deadline once the identity waits on a person; Ctrl-C stops it before any contract is posted. The default localRepo makes a new DID per invocation and waits in AwaitingApproval for the operator's claim (scripts/associate-bidder.ts --requester-did <did>; the CLI prints the command with the DID); -- --skip-qr skips it but leaves the requester with no operator, so requester-tangled-vouch vouches no prod bidder.

Verify public PDS, DID and requestCrawl

HANDLE=bobvmbuilder.bsky.social
DID=$(curl -fsS "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$HANDLE" | jq -r .did)
PDS=$(curl -fsS "https://plc.directory/$DID" | jq -r '.service[] | select(.id == "#atproto_pds") | .serviceEndpoint')
HOST=${PDS#https://}; HOST=${HOST%%/*}
echo "$DID $PDS $HOST"
curl -fsS "$PDS/.well-known/did.json" | jq '{id, service}'
curl -fsS "$PDS/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=com.publicdomainrelay.temp.badgeBlueKeys" | jq '.records | length'
curl -fsS -X POST "$PDS/xrpc/com.atproto.sync.requestCrawl" -H 'Content-Type: application/json' \
  -d "{\"hostname\":\"$HOST\"}" -o /dev/null -w '%{http_code}\n'

serviceEndpoint must be HTTPS with no path. The last command returns 200; confirm the crawl in the PDS journal:

sudo journalctl _SYSTEMD_USER_UNIT='socialweb-computer-bidder@<handle>.service' --since -5min | grep -i requestCrawl

hat-9 and hat-7

SSH aliases exist on the lead's workstation: ssh hat-9, ssh hat-7, ssh socialweb.computer. No credentials belong in this document; use your own SSH key.

HostState rootBidder DIDAssociation
this host/var/lib/socialweb-computer/swc-bidderdid:plc:vxd7pmlhw6bnpyu6wngewthnAssociated, RequireApproval
hat-9/var/lib/socialweb-computer/swc-bidderdid:plc:cjuftobfmmmdkmblcl4h54ebAssociated, RequireApproval
hat-7/var/lib/socialweb-computer/swc-bidderdid:plc:exjutifioeu2p7oorbdrex2oAssociationSkipped, Skip

The bidder state dir is root-readable only:

ssh hat-9
sudo ls /var/lib/socialweb-computer/swc-bidder/handles
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  systemctl --user status 'socialweb-computer-bidder@<handle>.service'

Troubleshooting

Units, logs and kcp status. The unit account for a bidder is swc-bidder, for a gateway socialweb.

# units as the account (the unit itself reads the backend and state from config.json)
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  /var/lib/socialweb-computer/swc-bidder/bin/current/socialweb-computer units list \
  --host-backend systemd-user --state-dir /var/lib/socialweb-computer/swc-bidder
# bidder unit and journal (system journal: swc-bidder is a system account)
sudo runuser -u swc-bidder -- env XDG_RUNTIME_DIR=/run/user/$(id -u swc-bidder) \
  systemctl --user status 'socialweb-computer-bidder@<handle>.service'
sudo journalctl _SYSTEMD_USER_UNIT='socialweb-computer-bidder@<handle>.service' --since -10min
# gateway unit and journal
sudo systemctl --user -M socialweb@ status socialweb-computer-gateway.service
sudo journalctl --user -M socialweb@ -u socialweb-computer-gateway.service --since -10min
# kcp status (there is no kubectl kcp plugin in this tree; use the admin kubeconfig)
K=$(sudo sh -c 'ls /var/lib/socialweb-computer/swc-bidder/handles/*/kcp/admin.kubeconfig' | head -1)
sudo kubectl --kubeconfig "$K" get --raw /readyz
sudo kubectl --kubeconfig "$K" get workspaces,marketbidder,marketidentity,pds,exposure
sudo kubectl --kubeconfig "$K" get marketbidder app -o jsonpath='{.status.phase}{"\n"}'
sudo kubectl --kubeconfig "$K" get marketidentity bidder -o jsonpath='{.spec}{"\n"}{.status}{"\n"}'
# a dev instance
sudo -n -u test-0001 env HOME=/home/test-0001 XDG_RUNTIME_DIR=/run/user/$(id -u test-0001) \
  /var/tmp/swc/bin/socialweb-computer dev status --instance mn1
sudo -n -u test-0001 env HOME=/home/test-0001 XDG_RUNTIME_DIR=/run/user/$(id -u test-0001) \
  /var/tmp/swc/bin/socialweb-computer dev logs core --instance mn1 --tail 50
# gateway's embedded k3s on a host without kubectl
K3S=$(sudo find /var/lib/socialweb-computer/socialweb/runtime -name k3s -type f | head -1)
sudo "$K3S" kubectl get ns

Paths:

ItemPath
Bidder state/var/lib/socialweb-computer/swc-bidder
Gateway state/var/lib/socialweb-computer/socialweb
kcp admin kubeconfig<state>/handles/<hash>/kcp/admin.kubeconfig
Tenant k3s kubeconfig<state>/.../server/cred/admin.kubeconfig
Core binary<state>/bin/current/socialweb-computer
Previous binary<state>/bin/previous/socialweb-computer

Dev market network

Run as the VM account on a host prepared above. The first dev up fetches and verifies the guest image unless --guest-image-source names one.

cd "$(git rev-parse --show-toplevel)"
swc() {
  sudo -n -u test-0001 env HOME=/home/test-0001 \
    XDG_RUNTIME_DIR=/run/user/$(id -u test-0001) \
    DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u test-0001)/bus \
    /var/tmp/swc/bin/socialweb-computer "$@"
}
swc dev up --instance mn1 --profile market-network --bidders 1 --with-gateway --vm-host-backend systemd-user
swc dev status --instance mn1 | jq '.network.status.apps'
swc status --instance mn1
eval "$(swc dev env --instance mn1)"
swc dev logs core --instance mn1 --tail 50
swc dev down --instance mn1

Rent a VM through the RFP flow:

# variables carry over: swc
sudo -n -u test-0001 tee /var/tmp/swc-secrets.json >/dev/null <<'JSON'
[{"path":"/root/x","value":"hello"}]
JSON
swc run request --network mn1 --secrets /var/tmp/swc-secrets.json --exec 'cat /root/x'
swc run request list --json | jq -r '.[].name'
swc run request release "$(swc run request list --json | jq -r '.[0].name')"

run request options: --network, --name, --identity, --secrets, --tunnel, --exec, --hold, --skip-ssh, --contract-timeout, --transport, --tag; run request ssh, resume and proxy also take --contract and --log-level. --tunnel is <tag>:<port>[:<transport>][:shell[=<envFile>]], transport opentunnel (default) or webrtc.

--identity <name> reuses one localRepo did:plc, repo and embedded PDS state across invocations from <state-root>/identities/<name>/; the first run needs -- --skip-qr. Each invocation still gets a fresh kcp and OpenBao. One PDS process per identity store is enforced; ssh/release attach to a running kcp. release keeps the identity store; delete it manually only after the invocation is released.

Run a bidder as a child application of the market network:

# variables carry over: swc
swc run bidder --host-backend proc --profile market-network --network mn1 --handle b2.mn1.bob.test

With --fg it stays until Ctrl-C, which removes it from the network. With --network the profile defaults to market-network.

Foreground bidder (no systemd)

README: run in the foreground has the prod command, the prerequisites and the paths. run bidder --host-backend proc without --network is its own instance b-<handle-with-dashes>: bringUp of a quick-profile instance under a devsup (die-with this process with --fg), then the MarketBidder root object, the identity wait and the Ready wait. It runs the bidder host checks of check-host -role bidder first, without the systemd ones.

Join a dev market network over the market protocol only, in its own kcp (the standalone path, with no public PLC DID):

# variables carry over: swc
swc dev status --instance mn1 | jq .network.status > /var/tmp/swc/mn1.json
swc run bidder --market-network-file /var/tmp/swc/mn1.json --handle b3.mn1.bob.test --host-backend proc --fg
K=/home/test-0001/.local/state/socialweb-computer/dev/b-b3-mn1-bob-test/kcp/admin.kubeconfig
sudo -n -u test-0001 kubectl --kubeconfig "$K" patch marketbidder app --type=merge \
  -p '{"spec":{"identity":{"association":"Skip"}}}'
swc run request --network mn1 --exec 'uname -r'
swc status --instance b-b3-mn1-bob-test

Ctrl-C (or SIGTERM) on run bidder prints the dev down --keep-state report (vmsStopped, remaining: []). swc dev down --instance b-b3-mn1-bob-test removes its state.

Run the requester where the release assets are not staged

run request brings up its own kcp (kine over sqlite, the port-0 front, one kcp slot, the core) as units of the proc placement backend (devsup, hostsvc backend proc), then posts the RFP from a requester that runs in the core process. The backend has a darwin implementation, so the requester runs on macOS natively; the bidder role does not (it needs the launchd backend and the Virtualization.framework runtime, plan 0012). On a release platform, scripts/build-socialweb-computer.sh --dev stages every pinned asset and the binary resolves them itself. This section is for the hosts where that cannot run: macOS (not a release platform in internal/versions/versions.json), and a Linux host whose asset staging fails on an unrelated pinned asset (the PGDG deb by-hash the pin names has since been withdrawn, or its rustc is not rustup-managed). Give the four executables by path instead.

The four executables

ExecutableWhyPin
kcpthe API server of the invocation's kcpbuilt from the pinned commit; kcp --version must name the version internal/versions/versions.json pins
kinethe sqlite etcd shim under kcpthe release binary, its sha256 pinned
baothe requester's own OpenBao (the vault phase of its set)the release archive, verified against checksums.txt
dumbpipeiroh ssh into the guest (--transport iroh, the default)the release archive, the sha256 GitHub publishes for it is the pin

internal/versions/versions.json carries the URL and the sha256 of each under assets.<name>.fetch.<os>/<arch>, so a host reads them from the checkout it built:

v=internal/versions/versions.json
url=$(jq -r '.assets.kine.fetch["linux/amd64"].url' "$v")
sha=$(jq -r '.assets.kine.fetch["linux/amd64"].sha256' "$v")
curl -fsSL -o kine "$url" && echo "$sha  kine" | sha256sum -c -

kcp is built from source, CGO_ENABLED=0, with the version flags the pin names:

git clone https://github.com/kcp-dev/kcp
git -C kcp checkout 74e9fb9c03f778c57d1a6a782d5adc9476ec1af5
cd kcp && CGO_ENABLED=0 go build -trimpath -buildvcs=false -mod=readonly \
  -ldflags="-s -w -buildid= \
    -X k8s.io/client-go/pkg/version.gitVersion=v1.36.0+kcp-v0.33.0-162-g74e9fb9c03f778 \
    -X k8s.io/client-go/pkg/version.gitCommit=74e9fb9c03f778c57d1a6a782d5adc9476ec1af5 \
    -X k8s.io/client-go/pkg/version.gitTreeState=clean \
    -X k8s.io/component-base/version.gitVersion=v1.36.0+kcp-v0.33.0-162-g74e9fb9c03f778 \
    -X k8s.io/component-base/version.gitCommit=74e9fb9c03f778c57d1a6a782d5adc9476ec1af5 \
    -X k8s.io/component-base/version.gitTreeState=clean" \
  -o ../bin/kcp ./cmd/kcp

Two build facts for this binary: the generated deepcopy imports encoding/json/jsontext, so a Go 1.26 toolchain needs GOEXPERIMENT=jsonv2 (Go 1.27 has it on); and CGO_ENABLED=0 is enough and avoids needing libc headers. GOTOOLCHAIN=go1.26.6 (the version go.mod names) builds on a host whose own Go differs.

The policy engine embeds no Deno outside a -tags embed_deno release build, so deno must be on PATH when the PolicyEngine component starts (internal/policyengine/deno, pinned v2.9.3). Without it the root stays Provisioning with PolicyEngine/policy-engine ready=false phase=Pending.

Then build this tree and run one invocation, with the four paths:

go build -o bin/socialweb-computer ./cmd/socialweb-computer
bin/socialweb-computer run request --name host1 \
  --kcp-path "$PWD/bin/kcp" --kine-path "$PWD/bin/kine" --bao-path "$PWD/bin/bao" --dumbpipe-path "$PWD/bin/dumbpipe" \
  --exec 'uname -a' -- --skip-qr

macOS

scripts/build-socialweb-computer.sh --dev refuses darwin: it builds only the release platforms of versions.json, and darwin/arm64 is a seam there with only vfkit pinned. Build the binary with plain go build (Go 1.27 or newer) and pass the paths as above; the darwin kcp, kine (the cgo sqlite build, CGO_ENABLED=1 CGO_CFLAGS="-DSQLITE_USE_ALLOCA=1" go build -tags nats of v0.17.1), bao (openbao_2.6.2_darwin_arm64.tar.gz) and dumbpipe (dumbpipe-v0.39.0-darwin-aarch64.tar.gz) are built or fetched once into ~/.tmp/swc-mac/bin.

Linux

On a release platform, scripts/build-socialweb-computer.sh --dev --publish /var/tmp/<work> stages every pinned asset once and the binary resolves them itself, so run request then takes no path options. When that staging fails on an asset the requester never uses (the PGDG deb the postgres pin names has been withdrawn upstream; a rustc that is not rustup-managed stops the meshd downloads), use the explicit-path recipe above, with GOTOOLCHAIN and GOEXPERIMENT=jsonv2 as on Windows.

Windows

Use WSL2: a native Windows requester would need a windows placement backend, and there is none yet (plan 0011). Windows 11's WSL2 carries AF_UNIX, so the devsup, kine and front sockets work unchanged. Copy the checkout into the distro, install the toolchain there, and run as a non-root user, because dev refuses uid 0. In a WSL shell (wsl.exe -d Ubuntu), as root:

apt-get update && apt-get install -y golang-go jq zstd make
useradd -m -s /bin/bash swc

then, as that user (su - swc):

CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOTOOLCHAIN=go1.26.6 \
  go build -o ~/bin/socialweb-computer ./cmd/socialweb-computer
~/bin/socialweb-computer run request --name wsl1 \
  --kcp-path ~/bin/kcp --kine-path ~/bin/kine --bao-path ~/bin/bao --dumbpipe-path ~/bin/dumbpipe \
  --exec 'uname -a' -- --skip-qr

The distro's own golang-go is 1.26, hence GOEXPERIMENT=jsonv2 and the toolchain pin; the Ubuntu image's gcc has no libc headers, which CGO_ENABLED=0 avoids. The four executables are the linux/amd64 ones above, and deno has to be on PATH as on any other host.

What differs on macOS

AreaOn macOS
XDG_RUNTIME_DIRusually unset; the instance's runtime dir, its devsup socket and the front's control socket fall back to <state-dir>/run
front fd storethe proc backend offers none (hostsvc Capabilities.FDStore false), so the front binds its address itself and the kcp slot is restarted when the front is placed on a new port
unix socket pathssun_path is 104 bytes, so the limit is 103, not 107
bound portsread from lsof (about 25 ms per read) instead of /proc
process identitykinfo start time and kern.boottime instead of start ticks and /proc
child reapingno subreaper; an orphaned grandchild reparents to launchd and the env-attribution sweep kills it

Reading the result

kubectl --kubeconfig <state-dir>/requests/<name>/dev/<name>/kcp/admin.kubeconfig get marketrequester app reads the root (Ready when the pod-less stack is up); get computecontract shows the RFP it posted. The requester's PDS, ingress proxy and policy engine run in the core process, so nothing else is needed on the host. The RFP reaches public bidders through the ingress relay: the contract leaves Requesting with a verified receipt, goes Held when the guest publishes its address, and --exec runs over iroh ssh before the contract is released. --skip-ssh rents, reports and releases without a session.

Run a bidder that sells a deno sandbox

A bidder can offer a compute kind that is not a virtual machine: computeKind: deno in the contract (the payload's kind) makes the bidder run spec.userData as a Deno program on the bidder's host and report its output, instead of booting a firecracker guest. Nothing about that needs KVM, a guest image or a VM runtime, so this is the bidder a macOS or WSL2 host can run.

The pieces: kind on com.publicdomainrelay.temp.compute.vm (absent reads as vm), computeKind on the contract and the Bidder spec, the DenoSandbox backend kind on the ComputeVM, and the guest event com.publicdomainrelay.temp.compute.events.vm.sandbox (exitCode, timedOut, stdout, stderr). The sandbox runs deno run --no-remote --no-lock --deny-net --deny-run --deny-ffi with read and write of its own work dir only, at most 2 minutes.

A bidder serves the kinds it is told to, and drops an RFP of a kind it does not serve before it bids:

run bidder --host-backend proc --compute-kinds deno --handle <handle> --fg \
  --state-dir <state> --kcp-path <kcp> --kine-path <kine> --bao-path <bao> \
  --openbao-plugin-dir <dir with both openbao plugins>

--compute-kinds takes vm, deno or both; the default is vm. One bidder serves both (vm,deno): the kind of each accepted RFP decides whether a firecracker guest or a sandbox runs, and both count against maxConcurrentGuests. Without vm the bidder opens no VM runtime, runs no KVM preflight, and applies the bidder API sets itself (internal/computevm ApplyBidderSets), because the ComputeVM controller that usually applies them is not started. A bidder with no deno on PATH (pinned v2.9.3) declines deno RFPs rather than bidding on a kind it cannot run. On macOS the two OpenBao plugins need the darwin pins that internal/versions/versions.json now carries for them.

The requester asks for the kind after --, with the program in the usual user-data file:

run request --name deno1 <the four paths> -- --skip-qr --compute-kind deno --user-data prog.ts

--compute-kind deno skips ssh (a sandbox has no guest to ssh into). The contract goes Held when the sandbox has reported its result, and summary.json carries contract.sandbox {exitCode, timedOut, stdout, stderr}. A program:

console.log(`hello from ${Deno.build.os}/${Deno.build.arch}`);

One market caveat: a bidder running older code does not read kind, so it still bids on a deno RFP and, if it wins, boots a VM for it. On a market with other bidders, pin the requester to the deno bidder with --allow-bidder-dids <did> (the bidder's MarketIdentity bidder status; --deny-bidder-dids is the inverse).

Read on each host: macOS sandbox://... with hello from darwin/arm64, hat-7 and WSL2 hello from linux/x86_64, exit 0, release 200.

The proof recipe (three hosts)

One request runner, copied to every host; it prints the fields a proof reads:

#!/usr/bin/env bash
# run-one.sh <kind> <name> <bidder-did> <userdata>; env SWC_BIN SWC_STATE KCP KINE BAO
set -e
kind="$1"; name="$2"; did="$3"; ud="$4"
extra=()
[ "$kind" = deno ] && extra=(--compute-kind deno)
"$SWC_BIN" run request --name "$name" --state-dir "$SWC_STATE" \
  --kcp-path "$KCP" --kine-path "$KINE" --bao-path "$BAO" \
  --ready-timeout 90s --network-timeout 180s --contract-timeout 5m --skip-ssh \
  -- --skip-qr --allow-bidder-dids "$did" "${extra[@]}" --user-data "$ud" \
  > "$SWC_STATE/$name.log" 2>&1
grep -E "\"phase\": \"(Held|Released|Failed)\"|exitCode|\"stdout\"|vmAddress|releaseStatus|winnerDid" "$SWC_STATE/$name.log" | tail -6 | cut -c1-190

Start a bidder per host (all in the foreground under devsup, which dies with the process):

# macOS, deno only
nohup ~/.tmp/swc-mac/bin/socialweb-computer run bidder --host-backend proc --compute-kinds deno \
  --handle probe.deno.test --fg --state-dir ~/.tmp/swc-mac/state \
  --kcp-path ~/.tmp/swc-mac/bin/kcp --kine-path ~/.tmp/swc-mac/bin/kine --bao-path ~/.tmp/swc-mac/bin/bao \
  --openbao-plugin-dir ~/.tmp/swc-mac/bin/plugins > ~/.tmp/swc-mac/bidder-mac-deno.log 2>&1 &

# hat-7, both kinds (VM assets at explicit paths; see the VM half below)
B=$HOME/.tmp/swc-req/bin
nohup "$B/socialweb-computer" run bidder --host-backend proc --compute-kinds vm,deno --handle probe.mixed.test --fg \
  --state-dir "$HOME/.tmp/swc-req/state-mixed" \
  --kcp-path "$B/kcp" --kine-path "$B/kine" --bao-path "$B/bao" --openbao-plugin-dir "$B/plugins" \
  --userns-stage-path "$B/vm/swc-userns-stage" --pasta-path "$B/vm/pasta" \
  --vmexec-path "$B/vm/socialweb-vmexec" --firecracker-path "$B/vm/firecracker" \
  > "$HOME/.tmp/swc-req/mixed.log" 2>&1 &

# desktop: WSL2, deno only, as a non-root user (dev refuses uid 0)
wsl.exe -d Ubuntu -u root -e bash -c 'su - swc -c "export PATH=/usr/local/bin:\$PATH; cd ~; nohup /var/tmp/swc-req/bin/socialweb-computer run bidder --host-backend proc --compute-kinds deno --handle probe.deno.test --fg --state-dir /home/swc/.tmp/swc-req/state --kcp-path /var/tmp/swc-req/bin/kcp --kine-path /var/tmp/swc-req/bin/kine --bao-path /var/tmp/swc-req/bin/bao --openbao-plugin-dir /var/tmp/swc-req/bin/plugins > /home/swc/bidder-deno.log 2>&1 < /dev/null & echo started"'

Wait for the root, and read the bidder DID for the allowlist:

KUBECONFIG=<state>/dev/<instance>/kcp/admin.kubeconfig kubectl get marketbidder app -o jsonpath='{.status.phase}'
KUBECONFIG=<state>/dev/<instance>/kcp/admin.kubeconfig kubectl get marketidentity bidder -o jsonpath='{.status.did}'

Then, on macOS and hat-7, run the matrix directly:

export SWC_BIN=$HOME/.tmp/swc-mac/bin/socialweb-computer SWC_STATE=$HOME/.tmp/swc-mac/state \
       KCP=$HOME/.tmp/swc-mac/bin/kcp KINE=$HOME/.tmp/swc-mac/bin/kine BAO=$HOME/.tmp/swc-mac/bin/bao
bash run-one.sh vm   m3-mac-vm   <bidder-did> $HOME/.tmp/swc-mac/vm.yaml
bash run-one.sh deno m3-mac-deno <bidder-did> $HOME/.tmp/swc-mac/prog.ts

On desktop the arguments go into the script, not through the command line: a nested call crosses ssh, the MSYS bash, wsl.exe and su -c, and positional parameters are lost on the way.

# ~/swc-desk/wsl-deno.sh
wsl.exe -d Ubuntu -u root -e bash -c 'su - swc -c "export PATH=/usr/local/bin:\$PATH SWC_BIN=/var/tmp/swc-req/bin/socialweb-computer SWC_STATE=/home/swc/.tmp/swc-req/state KCP=/var/tmp/swc-req/bin/kcp KINE=/var/tmp/swc-req/bin/kine BAO=/var/tmp/swc-req/bin/bao; bash /home/swc/run-one.sh deno d-wsl-deno <bidder-did> /home/swc/prog.ts"'
ssh desktop 'bash ~/swc-desk/wsl-deno.sh' | tr -d '\r' | tail -5

The two user-data files:

// prog.ts, the program of a deno request
console.log(`hello from ${Deno.build.os}/${Deno.build.arch}`);
# vm.yaml, the cloud-init of a vm request
#cloud-config
runcmd:
  - [ sh, -c, "echo vm-ok > /tmp/vm-ok" ]

Getting the hosts to that point: build the binary with the pinned toolchain (CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOTOOLCHAIN=go1.26.6, cmd/socialweb-computer), ship the tree with rsync to a Linux host or a tar stream into the distro, and stage the two OpenBao plugins to a directory that matches the pins.

The VM half on hat-7 needs four pinned assets, a subuid range for the account, and /dev/kvm:

scripts/stage-assets.sh --platform linux/amd64 --asset firecracker --asset pasta --blobs $HOME/.tmp/swc-req/vmblobs
scripts/stage-assets.sh --platform linux/amd64 --asset socialweb-vmexec --print-pins --blobs $HOME/.tmp/swc-req/vmblobs
scripts/stage-assets.sh --platform linux/amd64 --asset swc-userns-stage --blobs $HOME/.tmp/swc-req/vmblobs
for a in firecracker pasta socialweb-vmexec swc-userns-stage; do
  zstd -q -d -c $HOME/.tmp/swc-req/vmblobs/linux_amd64/$a.tar.zst | tar -x -C $HOME/.tmp/swc-req/bin/vm
done
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER"

--print-pins is for a dirty tree: socialweb-vmexec embeds internal/versions/versions.json, so a changed pin makes the built binary differ from its own pin and stage-assets.sh refuses it. The staged binaries are what the live run uses, and the bidder takes them by path.

Each run keeps its log at <state>/<name>.log, so any result is re-read with grep -E "exitCode|vmAddress|releaseStatus" <state>/<name>.log.

The Web Worker runner in the browser build

../wasm (the kcp-plus-socialweb js/wasm checkout) builds the runner into two places:

  • socialweb-wasm/cmd/sandbox-wasm: a small module that exposes sandboxAvailable(), sandboxKind() and sandboxRun(source, timeoutMs) (a Promise). Built with GOOS=js GOARCH=wasm go build -modfile=.go.wasm.mod ./cmd/sandbox-wasm, its -replace pointing at the tree under test.
  • The combined page module (cmd/kcp-wasm): socialwebSandboxAvailable and the page export socialwebSandboxRun (through socialwebCall, answered by __kcpResolve).

Both are asynchronous by contract. A sandbox.Runner.Run call blocks its goroutine until the worker answers, and a JavaScript event cannot be delivered while a syscall/js callback is on the stack, so nothing may call Run synchronously from a js callback: the export hands the work to a goroutine and answers through a Promise (or __kcpResolve). In Go code the rule is the same: call Run from a Go goroutine, never from inside a js.FuncOf callback.

Proven with the sandbox-wasm module on 2026-10-27, same Go source, in headless Chrome (CDP Runtime.evaluate against a page loading wasm_exec.js + sandbox.wasm) and in Deno (the same two files through WebAssembly.instantiate): available true, kind webworker, console.log("hi from worker", 1 + 1) -> {"exitCode":0,"stdout":"hi from worker 2"}, a throw -> {"exitCode":1,...} with stdout: "before" and the stack on stderr, an awaited program -> {"exitCode":0,"stdout":"v+1 42"}, and a program that outlives its timeout -> {"exitCode":-1,"timedOut":true}. The combined page module carries the export but was not driven through the shell page in this pass: booting cmd/kcp-wasm needs the shell's fs shim and worker protocol, which the bare test page does not provide.

Which runtime runs the sandbox

The sandbox is a sandbox.Runner (internal/contract/sandbox): Run(Program{Source, Dir, Timeout}) returns {ExitCode, TimedOut, Stdout, Stderr}, Available() says whether the runtime can run one, and Kind() names it. Two implementations:

PackageBuildRuns the program as
internal/sandbox/deno!jsdeno run --no-remote --no-lock --quiet --allow-read=<dir> --allow-write=<dir> --deny-net --deny-run --deny-ffi --ext ts -, program on stdin
internal/sandbox/workerjs && wasma Web Worker: the shim in worker.go captures console.*, runs the program, and posts {exitCode, stdout, stderr} back

The composition layer picks one by build tag: internal/stack/componentcore/sandbox_deno.go and sandbox_worker.go both define newSandbox(), so neither the bidder nor the policy engine names a runtime. That is what lets a bidder built into the browser (the kcp-plus-socialweb wasm module in the wasm checkout, which already overlays the js versions of hostos, portscan and the atproto stores) serve kind: deno without a deno binary: it runs the requester's program as a Web Worker in the page. A bidder whose runner is absent or unavailable declines deno RFPs (Available()), it does not bid and fail. The native runner resolves deno through internal/policyengine/deno.Resolve, so a -tags embed_deno release build uses its embedded deno for both the sandbox and the policy engine.

The policy engine runs its gha-lite jobs through the same runner (workflow.Options.Sandbox): each if:/${{ }} expression and each uses: action is a Run with Net: true, a closed environment (NO_COLOR, DENO_NO_UPDATE_CHECK and the step env only) and no filesystem scope, which is the permission set the engine had before. Two differences, both deliberate:

  • A runner that reports Fenced() == false is refused for policy code. The expression then falls back to the pure-Go property-path resolver (no code runs) and an action step fails, so the verdict is Deny. This is why the browser build does not evaluate policy code: a Web Worker is not a security boundary against the page's own origin, and policy workflows are arbitrary counterparty TS. The deno runner reports Fenced() == true.
  • A non-zero exit, a timeout and an unavailable runner are all errors, and every error becomes a failed step, ExitStatus: failure and Deny (statusToVerdict allows only ExitStatus: success).

The deno runner's own flags follow Program.Net: --allow-net when the caller asks for network, otherwise --deny-net --no-remote --no-lock (no network means no remote resolution either), plus --deny-run --deny-ffi and read/write of Program.Dir only when it is set.

Limits of the worker runner: it executes JavaScript, not TypeScript (a browser Worker has no type stripping), and it has whatever the page's worker has, so the fence is the Worker's own isolation, not deno's permission flags. The deno runner keeps the explicit flags above.

Gate and acceptance

cd "$(git rev-parse --show-toplevel)"
deploy/check.sh --fast

deploy/check.sh --fast runs gofmt, go build, go vet, the generated-code drift check (go generate ./...), the architecture rules (scripts/check-architecture.sh), the cross-OS builds and the release builds. The full gate before a release adds the release closure and the live proofs this host can run; read AGENTS.md "How to verify".

There are no Go tests until plan 0027. The scenarios the old integration suite covered, and the acceptance rows of plan 0026, are in docs/spec/integration-catalog.yaml. A release candidate is proven live with docs/acceptance/0001-auth-modes.md.

See also