@botbuddy/cli 1.40.11 → 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 +1 -1
- package/src/commands.mjs +11 -1
- package/src/docker-publish-ip.mjs +228 -0
- package/src/stack.mjs +57 -8
package/package.json
CHANGED
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
|
+
}
|
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
|
-
/**
|
|
1257
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|