fix(mission_runtime): seed per-mission gateway with shared pairing state
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 10s
ci / frontend (push) Failing after 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Fresh mission runtime containers had no ZEROCLAW pairing token so
the topology_worker got 401 Unauthorized on WS connect. Mount the
shared runtimes /root/clawmates-runtime/data as /zeroclaw-data so
the gateway boots pre-paired and accepts the servers ZEROCLAW_TOKEN.

Seed dir overridable via CLAWMATES_RUNTIME_SEED_DIR.

Known caveat: sqlite sessions dir is shared across concurrent
mission runtimes. Fine while topology_worker runs sequentially per
mission; next iteration should copy-on-write per-mission.
This commit is contained in:
Omar Sobh
2026-07-22 01:53:54 -07:00
parent 827b829993
commit c4e7ca8aa4
3 changed files with 33 additions and 20 deletions
+26
View File
@@ -53,6 +53,20 @@ const EDGE_NETWORK: &str = "clawmates_edge";
/// by `clawmates-runtime.service`. /// by `clawmates-runtime.service`.
const MISSIONS_HOST_ROOT: &str = "/var/lib/clawmates-missions"; const MISSIONS_HOST_ROOT: &str = "/var/lib/clawmates-missions";
/// Host path holding the pre-paired ZeroClaw state (config.toml,
/// pairing key, brains, agent definitions) that the shared
/// `clawmates-runtime.service` uses. Per-mission runtime containers
/// mount this same dir so their gateway accepts the server's already-
/// provisioned ZEROCLAW_TOKEN instead of demanding a fresh /pair
/// handshake. Overridable via `CLAWMATES_RUNTIME_SEED_DIR` for
/// dev / test.
///
/// Known caveat: the sqlite sessions dir under here is currently
/// shared across all concurrent mission runtimes. In practice missions
/// don't collide often (topology_worker runs them one at a time per
/// mission) but this is an obvious next-iteration split point.
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
/// Deterministic docker container name for a mission's runtime. /// Deterministic docker container name for a mission's runtime.
/// Uses the full UUID hex — UUIDv7 encodes time in the leading bytes, /// Uses the full UUID hex — UUIDv7 encodes time in the leading bytes,
/// so a short prefix isn't guaranteed unique across missions minted /// so a short prefix isn't guaranteed unique across missions minted
@@ -129,6 +143,8 @@ impl MissionRuntimeProvisioner {
// present or docker start fails with EACCES/ENOENT. // present or docker start fails with EACCES/ENOENT.
let mission_dir = format!("{MISSIONS_HOST_ROOT}/{mission_id}"); let mission_dir = format!("{MISSIONS_HOST_ROOT}/{mission_id}");
let _ = tokio::fs::create_dir_all(&mission_dir).await; let _ = tokio::fs::create_dir_all(&mission_dir).await;
let seed_dir =
std::env::var("CLAWMATES_RUNTIME_SEED_DIR").unwrap_or_else(|_| DEFAULT_SEED_DIR.to_string());
let mounts = vec![ let mounts = vec![
// Mount just this mission's directory. Agents can navigate // Mount just this mission's directory. Agents can navigate
// its `/repo` subdir but never see other missions'. // its `/repo` subdir but never see other missions'.
@@ -139,6 +155,16 @@ impl MissionRuntimeProvisioner {
read_only: Some(false), read_only: Some(false),
..Default::default() ..Default::default()
}, },
// Seed /zeroclaw-data from the shared runtime's paired
// data dir so the fresh gateway accepts our ZEROCLAW_TOKEN
// without a /pair handshake.
Mount {
target: Some("/zeroclaw-data".to_string()),
source: Some(seed_dir),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
]; ];
let host_config = HostConfig { let host_config = HostConfig {
@@ -119,7 +119,6 @@ export function MissionCanvas({
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const [runs, setRuns] = useState<MissionRunSummary[]>([]); const [runs, setRuns] = useState<MissionRunSummary[]>([]);
const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null); const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null);
const [refreshClicks, setRefreshClicks] = useState(0);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!selectedId) { if (!selectedId) {
+7 -19
View File
@@ -181,25 +181,13 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
const method = init?.method ?? "GET"; const method = init?.method ?? "GET";
const busted = const busted =
method === "GET" ? `${path}${path.includes("?") ? "&" : "?"}_t=${Date.now()}` : path; method === "GET" ? `${path}${path.includes("?") ? "&" : "?"}_t=${Date.now()}` : path;
const t0 = Date.now(); const r = await fetch(busted, {
// eslint-disable-next-line no-console ...init,
console.log(`[api] → ${method} ${busted}`); headers: {
let r: Response; "Content-Type": "application/json",
try { ...(init?.headers ?? {}),
r = await fetch(busted, { },
...init, });
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[api] ✗ ${method} ${busted} (${Date.now() - t0}ms):`, e);
throw e;
}
// eslint-disable-next-line no-console
console.log(`[api] ← ${method} ${busted} (${Date.now() - t0}ms) status=${r.status}`);
if (!r.ok) { if (!r.ok) {
const text = await r.text().catch(() => ""); const text = await r.text().catch(() => "");
throw new Error(`${init?.method ?? "GET"} ${path}${r.status} ${text}`); throw new Error(`${init?.method ?? "GET"} ${path}${r.status} ${text}`);