@botbuddy/cli 1.40.10 → 1.40.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.40.10",
3
+ "version": "1.40.12",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,6 @@
1
1
  // BOT-1649 — diagnose and repair a worktree's short-lived agent credential
2
2
  // without ever printing the token unless the operator explicitly requests an
3
3
  // export snippet for the current shell.
4
- import { hostname } from "node:os";
5
4
  import { readAgentBinding } from "./wait-profile.mjs";
6
5
  import { resolveOwnerToken, CLIENT_KEY_SERVICE, OWNER_TOKEN_SERVICE } from "./cli-credentials.mjs";
7
6
  import { readKeychainSecret } from "./agent-credential-store.mjs";
@@ -9,6 +8,7 @@ import { getConfig } from "./config.mjs";
9
8
  import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
10
9
  import { selfHealAgentSession } from "./agent-session.mjs";
11
10
  import { machineUuid } from "./machine-id.mjs";
11
+ import { machineHost } from "./machine-host.mjs";
12
12
 
13
13
  export function parseDoctorArgs(argv = []) {
14
14
  const parsed = { fix: false, printExports: false, json: false, errors: [] };
@@ -97,6 +97,16 @@ function applyStackLeaseCapability(report, capability) {
97
97
  if (!capability || typeof capability.status !== "string") return;
98
98
  report.stack_lease.capability = capability.status;
99
99
  if (capability.reason) report.stack_lease.reason = capability.reason;
100
+ if (capability.reported_host && capability.enrolled_host) {
101
+ report.stack_lease.hostname_drift = {
102
+ reported: capability.reported_host,
103
+ enrolled: capability.enrolled_host,
104
+ remedies: [
105
+ "use the macOS LocalHostName reported by `scutil --get LocalHostName`",
106
+ "or re-enrol the Helper if this is intentionally a different machine",
107
+ ],
108
+ };
109
+ }
100
110
  if (capability.status !== "established" && !report.recovery) {
101
111
  report.recovery = `stack leases need a device-consented Helper on this machine: ${capability.reason ?? "worktree not attested"}`;
102
112
  }
@@ -126,7 +136,7 @@ export async function doctorAgentAuth({
126
136
  // inside selfHeal, which the `--fix` gate skips when it reports the key missing.
127
137
  ownerToken = readClientKeyStrict,
128
138
  selfHeal = selfHealAgentSession,
129
- hostFn = hostname, machineUuidFn = machineUuid,
139
+ hostFn = machineHost, machineUuidFn = machineUuid,
130
140
  } = {}) {
131
141
  let owner = null;
132
142
  let ownerReadFailed = false;
@@ -140,6 +150,7 @@ export async function doctorAgentAuth({
140
150
  // LaunchAgent). Env only, exactly like selfHealAgentSession; never the value.
141
151
  const mcpEnv = typeof bound?.mcpEnv === "string" && bound.mcpEnv ? bound.mcpEnv : null;
142
152
  const mcpKeyInEnv = Boolean(mcpEnv && typeof env?.[mcpEnv] === "string" && env[mcpEnv]);
153
+ const resolvedHost = await hostFn();
143
154
  const report = {
144
155
  schema_version: 1,
145
156
  worktree: cwd,
@@ -156,7 +167,7 @@ export async function doctorAgentAuth({
156
167
  // is known only after a register call, so a plain `bb doctor` reports `unverified`
157
168
  // and `--fix` re-registers to (re)establish it (`established` | `unattested`).
158
169
  stack_lease: {
159
- host: hostFn(),
170
+ host: resolvedHost,
160
171
  machine_id_readable: Boolean(machineUuidFn()),
161
172
  capability: "unverified",
162
173
  },
Binary file
package/src/commands.mjs CHANGED
@@ -28,6 +28,7 @@ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
28
28
  import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
29
29
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
30
30
  import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repairFailureRecovery } from "./agent-doctor.mjs";
31
+ import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } from "./docker-publish-ip.mjs";
31
32
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
32
33
  import { runUsage, usageStatus } from "./usage/sync.mjs";
33
34
  import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
@@ -251,11 +252,17 @@ ${SETUP_BLOCK}`);
251
252
  export async function cmdDoctor(args, {
252
253
  log = (line) => console.log(line), errorLog = (line) => console.error(line),
253
254
  doctor = doctorAgentAuth, usageReport = doctorUsageSync, bootstrapReport = doctorBootstrapState,
255
+ publishReport = () => probeEnginePublishIp(),
254
256
  } = {}) {
255
257
  const parsed = parseDoctorArgs(args);
256
258
  if (parsed.help) { log(doctorHelp()); return 0; }
257
259
  if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
258
260
  const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
261
+ // BOT-2146: where would this host's engine publish a leased stack's ports?
262
+ // Informational (read-only, never changes the exit code); reported only when
263
+ // doctor reports stack leases at all.
264
+ const publish = result.report.stack_lease ? await publishReport().catch(() => null) : null;
265
+ if (publish) result.report.stack_lease = { ...result.report.stack_lease, publish_ip: publish };
259
266
  // BOT-1021: informational only — never changes doctor's exit code.
260
267
  const usage = await usageReport().catch(() => null);
261
268
  // BOT-1945 (AC-3): informational, like usage sync — never changes the exit code.
@@ -269,7 +276,10 @@ export async function cmdDoctor(args, {
269
276
  log(`agent session: ${result.report.session.status}${result.report.session.minted_with ? ` (minted_with: ${result.report.session.minted_with})` : ""}`);
270
277
  if (result.report.stack_lease) {
271
278
  const sl = result.report.stack_lease;
272
- log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
279
+ log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}${publish ? ` ${formatPublishIp(publish)}` : ""}`);
280
+ if (publish && !isLoopbackIp(publish.publish_ip)) {
281
+ log(`stack publish: leased stacks on this host are reachable beyond loopback (\`bb stack up --local-exec\` refuses; the Helper refuses ports_exposed_beyond_loopback) — fix: ${publishIpFix(publish.engine)}`);
282
+ }
273
283
  }
274
284
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
275
285
  if (bootstrap) log(formatBootstrapLine(bootstrap));
@@ -0,0 +1,228 @@
1
+ // BOT-2146 — where would this Docker engine publish a leased stack's ports?
2
+ //
3
+ // The Supabase CLI has no bind-address option: it publishes every stack port
4
+ // with an UNSPECIFIED host IP, so the engine's default decides. With the
5
+ // default, a leased stack (Postgres superuser `postgres:postgres`, demo JWTs)
6
+ // listens on 0.0.0.0 and is reachable from the whole LAN.
7
+ //
8
+ // What controls that default, proven on Docker Desktop 4.91 / engine 29.8:
9
+ // • Docker Desktop: Settings → Resources → Network → "Port binding behavior"
10
+ // (`portBindingBehavior` in Desktop's own settings API): `local-only-port-binding`
11
+ // and `default-local-port-binding` publish unspecified ports on 127.0.0.1
12
+ // and ::1. `default-port-binding` (or unset) publishes on 0.0.0.0 and ::.
13
+ // The engine-level per-network option below is IGNORED by Desktop's
14
+ // host-side forwarder.
15
+ // • OrbStack / a plain Linux engine: daemon config `default-network-opts`
16
+ // → bridge → `com.docker.network.bridge.host_binding_ipv4`. The daemon
17
+ // `ip` key covers only the DEFAULT bridge; Supabase always creates its own
18
+ // `supabase_network_<project>`, so `ip` alone changes nothing here.
19
+ //
20
+ // Docker Desktop's settings are read from its backend API
21
+ // (`GET /app/settings/flat` on ~/Library/Containers/com.docker.docker/Data/
22
+ // backend.sock), the same resolved view its Settings UI renders. Reading the
23
+ // settings-store.json in ~/Library/Group Containers instead BLOCKS: macOS App
24
+ // Data protection holds the read on a consent prompt (measured on the MacBook,
25
+ // 2026-10-07), which would hang `bb doctor` and `bb stack up`.
26
+ //
27
+ // Read-only: this module GETs settings, reads daemon config files and runs
28
+ // `docker info`; it never changes engine configuration (a restart would kill
29
+ // every live stack).
30
+ import { spawnSync } from "node:child_process";
31
+ import { readFileSync, realpathSync } from "node:fs";
32
+ import { request } from "node:http";
33
+ import { homedir } from "node:os";
34
+ import { join } from "node:path";
35
+
36
+ export const ENGINE_PUBLISHES_BEYOND_LOOPBACK = "ENGINE_PUBLISHES_BEYOND_LOOPBACK";
37
+
38
+ const DESKTOP_LOOPBACK = new Set(["local-only-port-binding", "default-local-port-binding"]);
39
+ const DESKTOP_OPEN = "default-port-binding";
40
+ const HOST_BINDING_IPV4 = "com.docker.network.bridge.host_binding_ipv4";
41
+
42
+ export function dockerDesktopBackendSocket(home = homedir()) {
43
+ return join(home, "Library", "Containers", "com.docker.docker", "Data", "backend.sock");
44
+ }
45
+
46
+ /** GET a Docker Desktop backend API path over its unix socket → parsed JSON. */
47
+ export function getDockerDesktopSettings({ socketPath = dockerDesktopBackendSocket(), timeoutMs = 5000, httpRequest = request } = {}) {
48
+ return new Promise((resolve, reject) => {
49
+ const req = httpRequest({ socketPath, path: "/app/settings/flat", method: "GET", timeout: timeoutMs }, (res) => {
50
+ let body = "";
51
+ res.setEncoding("utf8");
52
+ res.on("data", (chunk) => { body += chunk; });
53
+ res.on("end", () => {
54
+ if (res.statusCode !== 200) return reject(new Error(`docker desktop settings API returned HTTP ${res.statusCode}`));
55
+ resolve(body);
56
+ });
57
+ });
58
+ req.on("timeout", () => req.destroy(new Error("docker desktop settings API timed out")));
59
+ req.on("error", reject);
60
+ req.end();
61
+ });
62
+ }
63
+
64
+ export function orbstackDockerConfigPath(home = homedir()) {
65
+ return join(home, ".orbstack", "config", "docker.json");
66
+ }
67
+
68
+ export const LINUX_DAEMON_CONFIG_PATH = "/etc/docker/daemon.json";
69
+
70
+ /** `docker info --format '{{.OperatingSystem}}'` → the engine family, or null. */
71
+ export function engineFromOperatingSystem(os) {
72
+ const value = typeof os === "string" ? os.trim() : "";
73
+ if (!value) return null;
74
+ if (value === "Docker Desktop") return "docker-desktop";
75
+ if (value === "OrbStack") return "orbstack";
76
+ return "docker-engine";
77
+ }
78
+
79
+ function parseObject(text) {
80
+ try {
81
+ const parsed = JSON.parse(text);
82
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Docker Desktop's flat settings (`/app/settings/flat`) → `{ publish_ip, setting }`. */
89
+ export function parseDockerDesktopSettings(text) {
90
+ const settings = typeof text === "string" ? parseObject(text) : null;
91
+ if (!settings) return { publish_ip: "unknown", setting: "unreadable" };
92
+ const behavior = settings.portBindingBehavior;
93
+ if (behavior === undefined || behavior === null) return { publish_ip: "0.0.0.0", setting: "unset" };
94
+ if (DESKTOP_LOOPBACK.has(behavior)) return { publish_ip: "127.0.0.1", setting: behavior };
95
+ if (behavior === DESKTOP_OPEN) return { publish_ip: "0.0.0.0", setting: behavior };
96
+ return { publish_ip: "unknown", setting: String(behavior) };
97
+ }
98
+
99
+ /**
100
+ * A dockerd daemon.json (OrbStack's docker.json, /etc/docker/daemon.json) →
101
+ * `{ publish_ip, setting }`. `null` text means the file does not exist, which
102
+ * is the engine default: every interface.
103
+ */
104
+ export function parseDaemonConfig(text) {
105
+ if (text === null || text === undefined) return { publish_ip: "0.0.0.0", setting: "absent" };
106
+ const config = parseObject(text);
107
+ if (!config) return { publish_ip: "unknown", setting: "unreadable" };
108
+ const ip = config["default-network-opts"]?.bridge?.[HOST_BINDING_IPV4];
109
+ if (typeof ip === "string" && ip) {
110
+ return { publish_ip: ip, setting: `default-network-opts.bridge.host_binding_ipv4=${ip}` };
111
+ }
112
+ return { publish_ip: "0.0.0.0", setting: "unset" };
113
+ }
114
+
115
+ /** IPv4 127.0.0.0/8 or IPv6 ::1. Anything else — including "unknown" — is not loopback. */
116
+ export function isLoopbackIp(ip) {
117
+ if (typeof ip !== "string") return false;
118
+ if (ip === "::1") return true;
119
+ const octets = ip.split(".");
120
+ return octets.length === 4 && octets[0] === "127" &&
121
+ octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255);
122
+ }
123
+
124
+ function readOrNull(readFile, path) {
125
+ try {
126
+ return readFile(path, "utf8");
127
+ } catch (error) {
128
+ if (error?.code === "ENOENT") return null;
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ /** Resolve the publish IP for a known engine family from its own settings. */
134
+ export async function resolvePublishIp({
135
+ engine, home = homedir(), readFile = readFileSync, desktopSettings = () => getDockerDesktopSettings({ socketPath: dockerDesktopBackendSocket(home) }),
136
+ } = {}) {
137
+ if (engine === "docker-desktop") {
138
+ const source = "docker-desktop:/app/settings/flat";
139
+ let text;
140
+ try { text = await desktopSettings(); } catch { return { engine, publish_ip: "unknown", setting: "settings_api_unreachable", source }; }
141
+ return { engine, ...parseDockerDesktopSettings(text), source };
142
+ }
143
+ if (engine === "orbstack" || engine === "docker-engine") {
144
+ const source = engine === "orbstack" ? orbstackDockerConfigPath(home) : LINUX_DAEMON_CONFIG_PATH;
145
+ try {
146
+ return { engine, ...parseDaemonConfig(readOrNull(readFile, source)), source };
147
+ } catch {
148
+ return { engine, publish_ip: "unknown", setting: "unreadable", source };
149
+ }
150
+ }
151
+ return { engine: "unknown", publish_ip: "unknown", setting: "engine_unknown", source: null };
152
+ }
153
+
154
+ /**
155
+ * Codex P1/P2 on #1076: the settings this module reads describe THIS
156
+ * machine's engine. They are evidence for a daemon only when its endpoint is
157
+ * that engine's own local unix socket, after resolving symlinks
158
+ * (`/var/run/docker.sock` → Docker Desktop's `~/.docker/run/docker.sock`). A
159
+ * tcp:// or ssh:// endpoint, a foreign socket, or one that cannot be resolved
160
+ * is not this machine's engine.
161
+ */
162
+ export function engineSocketIsLocal(engine, endpoint, { home = homedir(), realpath = realpathSync } = {}) {
163
+ if (typeof endpoint !== "string" || !endpoint.startsWith("unix://")) return false;
164
+ let socketPath;
165
+ let realHome = home;
166
+ try { socketPath = realpath(endpoint.slice("unix://".length)); } catch { return false; }
167
+ try { realHome = realpath(home); } catch { /* compare against the literal home */ }
168
+ const under = (dir) => [join(home, dir) + "/", join(realHome, dir) + "/"];
169
+ const roots = {
170
+ "docker-desktop": [...under(join(".docker", "run")), ...under(join("Library", "Containers", "com.docker.docker"))],
171
+ orbstack: under(".orbstack"),
172
+ }[engine];
173
+ // A plain dockerd: /etc/docker/daemon.json configures whatever local unix
174
+ // socket it serves, so any resolvable local socket is its own.
175
+ if (engine === "docker-engine") return true;
176
+ return Boolean(roots) && roots.some((root) => socketPath.startsWith(root));
177
+ }
178
+
179
+ const notThisMachine = (engine, endpoint) =>
180
+ ({ engine: engine || "unknown", publish_ip: "unknown", setting: "endpoint_not_this_machines_engine", source: endpoint || null });
181
+
182
+ /**
183
+ * Ask the engine what it is, then resolve — for the daemon the docker CLI
184
+ * would actually talk to: an explicit context or endpoint, else DOCKER_CONTEXT,
185
+ * else DOCKER_HOST, else the current context. Never reads this machine's
186
+ * settings for a daemon they do not describe.
187
+ */
188
+ export async function probeEnginePublishIp({
189
+ spawn = spawnSync, home = homedir(), readFile = readFileSync, desktopSettings, context = null, endpoint = null,
190
+ env = process.env, realpath = realpathSync, timeoutMs = 20000,
191
+ } = {}) {
192
+ const run = (args) => spawn("docker", args, { encoding: "utf8", timeout: timeoutMs, env });
193
+ let target = endpoint;
194
+ // Docker's own precedence: DOCKER_CONTEXT overrides DOCKER_HOST (Codex P1
195
+ // round 3 on #1076); `docker context inspect` with no name honours it.
196
+ if (!target && !context && !env.DOCKER_CONTEXT && env.DOCKER_HOST) target = env.DOCKER_HOST;
197
+ if (!target) {
198
+ const inspected = run(["context", "inspect", ...(context ? [context] : []), "--format", "{{.Endpoints.docker.Host}}"]);
199
+ target = inspected?.status === 0 ? String(inspected.stdout || "").trim() : "";
200
+ }
201
+ const selector = context ? ["--context", context] : endpoint ? ["--host", endpoint] : [];
202
+ const info = run([...selector, "info", "--format", "{{.OperatingSystem}}"]);
203
+ if (!info || info.status !== 0) return { engine: "unknown", publish_ip: "unknown", setting: "docker_info_failed", source: null };
204
+ const engine = engineFromOperatingSystem(info.stdout);
205
+ if (!engine) return { engine: "unknown", publish_ip: "unknown", setting: "docker_info_empty", source: null };
206
+ if (!engineSocketIsLocal(engine, target, { home, realpath })) return notThisMachine(engine, target);
207
+ return resolvePublishIp({ engine, home, readFile, ...(desktopSettings ? { desktopSettings } : {}) });
208
+ }
209
+
210
+ /** The operator fix, spelled out per engine. This module never applies it. */
211
+ export function publishIpFix(engine) {
212
+ const restart = "Restarting the engine stops every running container, so do it when no stack lease is active on this host.";
213
+ if (engine === "docker-desktop") {
214
+ return "Docker Desktop → Settings → Resources → Network → Port binding behavior → Localhost only, then Apply & restart Docker Desktop " +
215
+ `(portBindingBehavior=local-only-port-binding). ${restart}`;
216
+ }
217
+ const opts = `"default-network-opts": {"bridge": {"${HOST_BINDING_IPV4}": "127.0.0.1"}}`;
218
+ if (engine === "orbstack") return `add ${opts} to ~/.orbstack/config/docker.json, then restart OrbStack. ${restart}`;
219
+ if (engine === "docker-engine") return `add ${opts} to /etc/docker/daemon.json, then restart dockerd. ${restart}`;
220
+ return "make the Docker engine reachable (docker info), then re-run `bb doctor` to read its port binding setting.";
221
+ }
222
+
223
+ export function formatPublishIp(result) {
224
+ const detail = result.engine === "docker-desktop"
225
+ ? `docker-desktop portBindingBehavior=${result.setting}`
226
+ : result.engine === "unknown" ? result.setting : `${result.engine} ${result.setting}`;
227
+ return `publish_ip=${result.publish_ip} (${detail})`;
228
+ }
@@ -0,0 +1,26 @@
1
+ import { execFile } from "node:child_process";
2
+ import { hostname, platform } from "node:os";
3
+ import { promisify } from "node:util";
4
+ import { canonicalizeHostString } from "./pw/host.mjs";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Return the host identity used by Helper enrolment. macOS's kernel hostname can
10
+ * follow DHCP; Bonjour LocalHostName is stable and is what the Helper records.
11
+ */
12
+ export async function machineHost({
13
+ platformFn = platform,
14
+ hostnameFn = hostname,
15
+ execFileFn = execFileAsync,
16
+ } = {}) {
17
+ if (platformFn() !== "darwin") return hostnameFn();
18
+ try {
19
+ const { stdout } = await execFileFn("scutil", ["--get", "LocalHostName"]);
20
+ const localName = canonicalizeHostString(stdout);
21
+ if (localName) return `${localName}.local`;
22
+ } catch {
23
+ // Minimal shells can lack scutil; the existing hostname behaviour is safe.
24
+ }
25
+ return hostnameFn();
26
+ }
@@ -8,6 +8,8 @@ import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup }
8
8
  import { readAgentBinding } from "./wait-profile.mjs";
9
9
  import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
10
10
  import { getConfig } from "./config.mjs";
11
+ import { machineHost } from "./machine-host.mjs";
12
+ import { machineUuid } from "./machine-id.mjs";
11
13
 
12
14
  // BOT-1650 → BOT-1649 reconciliation: the harness session lives in the ONE
13
15
  // unified client cache (agent-state.mjs), written in main's `agent_session_token`
@@ -29,7 +31,7 @@ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
29
31
  /** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
30
32
  * does not echo the tenant, so derive it from the committed binding exactly as
31
33
  * selfHealAgentSession does; host/worktree/minted_at are derived locally. */
32
- export async function saveHarnessSession(session, { cwd = process.cwd() } = {}) {
34
+ export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost } = {}) {
33
35
  const root = await worktreeRoot(cwd);
34
36
  let tenant;
35
37
  try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
@@ -39,7 +41,7 @@ export async function saveHarnessSession(session, { cwd = process.cwd() } = {})
39
41
  agent_session_token: session.session_token,
40
42
  session_id: session.session_id ?? null,
41
43
  ...(tenant ? { tenant } : {}),
42
- host: os.hostname(),
44
+ host: await machineHostFn(),
43
45
  worktree: root,
44
46
  minted_at: new Date().toISOString(),
45
47
  expires_at: session.expires_at,
@@ -218,6 +220,8 @@ export async function bootstrapHarnessSession({
218
220
  model = null,
219
221
  effort = null,
220
222
  harness = type,
223
+ machineHostFn = machineHost,
224
+ machineUuidFn = machineUuid,
221
225
  call = callToolJson,
222
226
  callOptions = undefined,
223
227
  resolveCleanupOptions = durableCleanupOptions,
@@ -249,11 +253,14 @@ export async function bootstrapHarnessSession({
249
253
  // bootstrap straight through so the server reuses a still-live incarnation
250
254
  // or rotates a truly expired one; never revoke an active wait from a stale
251
255
  // local timestamp.
256
+ const host = await machineHostFn();
257
+ const hardwareUuid = machineUuidFn();
252
258
  const response = await call("bootstrap_agent_session", {
253
259
  name,
254
260
  type,
255
261
  harness_session_id: harnessSessionId,
256
- host: os.hostname(),
262
+ host,
263
+ ...(hardwareUuid ? { machine_uuid: hardwareUuid } : {}),
257
264
  worktree,
258
265
  ...(branch ? { branch } : {}),
259
266
  ...(ticketId ? { ticket_id: ticketId } : {}),
package/src/stack.mjs CHANGED
@@ -25,7 +25,7 @@ import { writeLaneStatus } from "./lane-status.mjs";
25
25
  import { spawn, spawnSync } from "child_process";
26
26
  import { readFileSync, realpathSync } from "fs";
27
27
  import { open, unlink, readFile } from "fs/promises";
28
- import { tmpdir } from "os";
28
+ import { homedir, tmpdir } from "os";
29
29
  import { basename, dirname, join, relative, isAbsolute } from "path";
30
30
  import { randomUUID } from "crypto";
31
31
  import { fileURLToPath } from "url";
@@ -39,6 +39,7 @@ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from
39
39
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
40
40
  import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
41
41
  import { machineUuid } from "./machine-id.mjs";
42
+ import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
42
43
  import { bold, dim, yellow } from "./utils.mjs";
43
44
 
44
45
  export const STACK_SCHEMA_VERSION = 1;
@@ -96,6 +97,8 @@ ${bold("up OPTIONS")}
96
97
  deadline (exit 2) and a failed wait (its own exit code) never retry.
97
98
  --local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
98
99
  stack and self-activate. Refused at the worktree root (shared stack).
100
+ Refused (exit 4, ENGINE_PUBLISHES_BEYOND_LOOPBACK) when the Docker
101
+ engine would publish the stack's ports beyond loopback (BOT-2146).
99
102
  --docker-context <name> Explicit Docker context for the mandatory local preflight
100
103
  (an allowlisted engine: OrbStack or Docker Desktop, e.g. desktop-linux).
101
104
  --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
@@ -1253,12 +1256,55 @@ const nonQueued = (s) => s != null && s !== "queued";
1253
1256
  // its full timeout instead of surfacing the failure immediately.
1254
1257
  const isReaped = (s) => s === "reaping" || s === "reaped" || s === "reap_failed";
1255
1258
 
1256
- /** Read-only pressure gate used before local managed-stack provisioning. */
1257
- export async function runLocalExecPreflight(opts, runCommand = runDockerCommand) {
1259
+ /**
1260
+ * BOT-2146: the publish IP the validated engine would give a leased stack's
1261
+ * ports. Read from the engine's own settings (Docker Desktop's settings API,
1262
+ * OrbStack's docker.json); never modified here.
1263
+ */
1264
+ export function localExecPublishIp(target, { home = homedir(), resolve = resolvePublishIp, realpath = realpathSync } = {}) {
1265
+ // Codex P1/P2 on #1076: the setting read here describes THIS machine's
1266
+ // engine (Desktop's settings API, OrbStack's docker.json). It is evidence
1267
+ // only when the daemon the preflight validated is that engine's own local
1268
+ // socket (symlinks resolved); anything else fails closed, unread.
1269
+ if (!engineSocketIsLocal(target?.validation, target?.resolved_endpoint, { home, realpath })) {
1270
+ return { engine: target?.validation || "unknown", publish_ip: "unknown", setting: "endpoint_not_this_machines_engine", source: target?.resolved_endpoint || null };
1271
+ }
1272
+ return resolve({ engine: target.validation, home });
1273
+ }
1274
+
1275
+ /**
1276
+ * Read-only gate used before local managed-stack provisioning: Docker pressure
1277
+ * and identity, then (BOT-2146) where the engine would publish the stack. The
1278
+ * Supabase CLI publishes every port with an unspecified host IP, so an engine
1279
+ * that defaults to 0.0.0.0 puts a `postgres:postgres` superuser on the LAN.
1280
+ * Local-exec has no Helper readiness check behind it, so it is refused here —
1281
+ * fail closed when the setting cannot be read — with the operator fix.
1282
+ */
1283
+ export async function runLocalExecPreflight(opts, runCommand = runDockerCommand, publishIp = localExecPublishIp) {
1258
1284
  const selector = opts.dockerContext
1259
1285
  ? ["--context", opts.dockerContext]
1260
1286
  : ["--endpoint", opts.dockerEndpoint];
1261
- return runCommand(["preflight", ...selector, "--json"]);
1287
+ const checked = await runCommand(["preflight", ...selector, "--json"]);
1288
+ const target = checked?.exitCode === 0 ? dockerTargetFromPreflight(checked.receipt) : null;
1289
+ if (!target) return checked;
1290
+ const exposure = await publishIp(target);
1291
+ const receipt = { ...checked.receipt, publish_ip: exposure };
1292
+ if (isLoopbackIp(exposure.publish_ip)) return { ...checked, receipt };
1293
+ const error = `refusing --local-exec: ${ENGINE_PUBLISHES_BEYOND_LOOPBACK} — this Docker engine would publish the leased stack's ports on ` +
1294
+ `${exposure.publish_ip}, not loopback (${formatPublishIp(exposure)}); every machine on the network could reach its Postgres ` +
1295
+ `as superuser with the Supabase default credentials. Fix: ${publishIpFix(exposure.engine)}`;
1296
+ return {
1297
+ ...checked,
1298
+ exitCode: EXIT.INVALID,
1299
+ receipt: { ...receipt, outcome: "refused", code: ENGINE_PUBLISHES_BEYOND_LOOPBACK, errors: [error, ...(receipt.errors || [])] },
1300
+ };
1301
+ }
1302
+
1303
+ /** The exit code and receipt code for a refused local-exec preflight. */
1304
+ function localPreflightRefusal(receipt) {
1305
+ return receipt?.code === ENGINE_PUBLISHES_BEYOND_LOOPBACK
1306
+ ? { exitCode: EXIT.INVALID, code: ENGINE_PUBLISHES_BEYOND_LOOPBACK }
1307
+ : { exitCode: EXIT.LEASE_FAILED, code: undefined };
1262
1308
  }
1263
1309
 
1264
1310
  /** Read-only daemon identity check for teardown; never report provision telemetry. */
@@ -1393,6 +1439,7 @@ function compactPreflight(receipt) {
1393
1439
  warnings: receipt.warnings,
1394
1440
  errors: receipt.errors,
1395
1441
  recommendation: receipt.recommendation,
1442
+ ...(receipt.publish_ip ? { publish_ip: receipt.publish_ip } : {}),
1396
1443
  };
1397
1444
  }
1398
1445
 
@@ -1742,11 +1789,12 @@ export async function cmdUp(opts, {
1742
1789
  localPreflight = compactPreflight(checked.receipt);
1743
1790
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
1744
1791
  if (checked.exitCode !== 0 || !localDockerTarget) {
1792
+ const refusal = localPreflightRefusal(checked.receipt);
1745
1793
  return emitResult(buildReceipt({
1746
- command: "up", outcome: "refused", slot,
1794
+ command: "up", outcome: "refused", slot, ...(refusal.code ? { code: refusal.code } : {}),
1747
1795
  error: checked.receipt.errors?.[0] || "the local preflight did not return a stable Docker server identity",
1748
1796
  preflight: localPreflight,
1749
- }), opts, EXIT.LEASE_FAILED);
1797
+ }), opts, refusal.exitCode);
1750
1798
  }
1751
1799
  if (checked.receipt.outcome === "warn") {
1752
1800
  // BOT-1681: surface the engine-aware recommendation (dry-run hygiene is
@@ -1941,12 +1989,13 @@ export async function cmdUp(opts, {
1941
1989
  const leaseCancellation = cancelled.ok && cancelled.data?.success
1942
1990
  ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
1943
1991
  : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
1992
+ const refusal = localPreflightRefusal(checked.receipt);
1944
1993
  return emitResult(buildReceipt({
1945
- command: "up", outcome: "refused", lease_id: leaseId, state, slot,
1994
+ command: "up", outcome: "refused", lease_id: leaseId, state, slot, ...(refusal.code ? { code: refusal.code } : {}),
1946
1995
  error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
1947
1996
  preflight: localPreflight,
1948
1997
  lease_cancellation: leaseCancellation,
1949
- }), opts, EXIT.LEASE_FAILED);
1998
+ }), opts, refusal.exitCode);
1950
1999
  }
1951
2000
  // BOT-1711 (Codex R8 P2): the target config could have been edited while this lease
1952
2001
  // was QUEUED for capacity. Re-run the non-mutating target validation NOW, before