@molecule/api-code-sandbox-flyio 1.0.1

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/dist/egress.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Fly network policies — the only egress restriction Fly.io exposes — and the
3
+ * pure logic behind the observation that proves one is actually in force.
4
+ *
5
+ * **What Fly actually offers.** 6PN custom private networks isolate apps from
6
+ * EACH OTHER ("Apps on separate 6PNs can never communicate unless explicitly
7
+ * configured to do so",
8
+ * https://fly.io/docs/networking/custom-private-networks/); they say nothing
9
+ * about reaching the public internet, which is a different question and the one
10
+ * `verifyEgress()` answers. The mechanism Fly documents for that is **network
11
+ * policies** (https://fly.io/docs/machines/guides-examples/network-policies/,
12
+ * announced at https://community.fly.io/t/new-feature-network-policies/19173):
13
+ * an app-scoped object with a `selector` and `allow` rules, where "Once you
14
+ * create a rule for a given direction, the default for that direction becomes
15
+ * drop."
16
+ *
17
+ * Three properties of that mechanism drive everything below, and every one of
18
+ * them is a limitation you cannot design around:
19
+ *
20
+ * 1. **Rules match protocol + port ONLY.** There is no host, IP or CIDR
21
+ * matching, and (as of the announcement) no port ranges. A network policy
22
+ * therefore CANNOT express a host allowlist. `allow tcp/443` permits a
23
+ * sandbox to reach every host on the internet that listens on 443.
24
+ * 2. **They do not apply to Fly Proxy traffic** — stated in the docs and, in
25
+ * the announcement, in capitals. Inbound preview traffic through the proxy
26
+ * is unaffected by an ingress rule.
27
+ * 3. **They take effect at Machine boot.** Fly's own troubleshooting note:
28
+ * "After creating or updating a policy, restart or redeploy the Machines for
29
+ * changes to take effect." A policy applied to an app with an already-running
30
+ * Machine does not retroactively cover it.
31
+ *
32
+ * The endpoints are documented in the guide and the announcement but are NOT in
33
+ * Fly's OpenAPI specification (https://docs.machines.dev/openapi.json — 68
34
+ * paths, none of them `network_policies`), so the request shape below is taken
35
+ * from those two documents verbatim and the LIST response shape, which neither
36
+ * document specifies, is parsed defensively by {@link extractPolicyId}.
37
+ *
38
+ * VERIFIED AGAINST A REAL FLY ORG, 2026-08-05 — the endpoints DO exist despite
39
+ * their absence from the spec:
40
+ *
41
+ * GET /apps/{app}/network_policies -> 200 []
42
+ * GET /apps/{app}/network_policies/ -> 404 (trailing slash is NOT accepted)
43
+ * POST /apps/{app}/network_policies -> 400 {"error":"name is required"}
44
+ *
45
+ * The POST error is the useful one: it proves the route is real and validating,
46
+ * and that `name` is mandatory — which is why {@link DEFAULT_EGRESS_POLICY_NAME}
47
+ * is always sent.
48
+ *
49
+ * @module
50
+ */
51
+ import { shellQuote } from './utilities.js';
52
+ /** Default name of the egress policy this provider owns on each sandbox app. */
53
+ export const DEFAULT_EGRESS_POLICY_NAME = 'molecule-sandbox-egress';
54
+ /**
55
+ * Probe exit status meaning at least one raw connection to a public IP
56
+ * SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
57
+ * never be mistaken for a completed observation.
58
+ */
59
+ export const EGRESS_PROBE_EXIT_REACHED = 9;
60
+ /** Probe exit status meaning every raw connection attempt was refused or timed out. */
61
+ export const EGRESS_PROBE_EXIT_BLOCKED = 0;
62
+ /**
63
+ * Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
64
+ * that answer on 443 from anywhere. IPv6 is included because Fly Machines have
65
+ * native public IPv6 egress and "often egress over IPv6 when the destination
66
+ * has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
67
+ * only IPv4 could miss an open v6 path entirely.
68
+ */
69
+ export const DEFAULT_EGRESS_PROBE_TARGETS = '1.1.1.1:443,[2606:4700:4700::1111]:443';
70
+ /** Matches a bare IPv4 literal. */
71
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
72
+ /** Matches a bracketed IPv6 literal, e.g. `[2606:4700:4700::1111]`. */
73
+ const IPV6_BRACKETED = /^\[[0-9a-fA-F:]+\]$/;
74
+ /**
75
+ * Renders a target the way it is written in configuration, so an operator can
76
+ * paste it straight back.
77
+ * @param target - The parsed target.
78
+ * @returns `host:port`, with IPv6 hosts bracketed.
79
+ */
80
+ export function formatEgressProbeTarget(target) {
81
+ const host = target.host.includes(':') ? `[${target.host}]` : target.host;
82
+ return `${host}:${target.port}`;
83
+ }
84
+ /**
85
+ * Parses probe targets from configuration.
86
+ *
87
+ * Only LITERAL addresses are accepted, never hostnames — Fly's own
88
+ * troubleshooting guidance for testing a policy is to "use direct IP addresses
89
+ * (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
90
+ * that failed at name resolution would report a blocked connection it never
91
+ * actually attempted.
92
+ * @param raw - Comma-separated `ip:port` string, or an array of them. IPv6
93
+ * literals must be bracketed (`[2606:4700:4700::1111]:443`).
94
+ * @returns The valid targets, in order. Invalid entries are dropped rather than
95
+ * throwing: the caller turns an empty result into an `inconclusive` verdict,
96
+ * which is the honest outcome for "nothing to probe".
97
+ */
98
+ export function parseEgressProbeTargets(raw) {
99
+ const entries = Array.isArray(raw) ? raw : (raw ?? '').split(',');
100
+ const targets = [];
101
+ for (const entry of entries) {
102
+ const trimmed = entry.trim();
103
+ const index = trimmed.lastIndexOf(':');
104
+ if (index <= 0)
105
+ continue;
106
+ const host = trimmed.slice(0, index);
107
+ const port = Number(trimmed.slice(index + 1));
108
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
109
+ continue;
110
+ if (IPV4.test(host))
111
+ targets.push({ host, port });
112
+ else if (IPV6_BRACKETED.test(host))
113
+ targets.push({ host: host.slice(1, -1), port });
114
+ }
115
+ return targets;
116
+ }
117
+ /**
118
+ * Parses an allowed-port list for the egress network policy.
119
+ *
120
+ * An EMPTY list is deliberately not expressible. Fly documents the deny default
121
+ * as a consequence of at least one `allow` rule existing for a direction; what
122
+ * an allow rule with zero ports does is not documented anywhere, so a provider
123
+ * that sent one would be guessing at a security control. An operator who wants
124
+ * near-total denial allows a single port nothing in the sandbox uses.
125
+ * @param raw - Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`.
126
+ * A bare port is treated as TCP.
127
+ * @returns The parsed ports, or `undefined` when the input is absent or empty
128
+ * (meaning "apply no policy"), which is NOT the same as "deny everything".
129
+ * @throws {Error} When an entry is present but unparseable — silently dropping a
130
+ * port from a security control would produce a policy the operator never wrote.
131
+ */
132
+ export function parseEgressAllowedPorts(raw) {
133
+ const entries = (raw ?? '')
134
+ .split(',')
135
+ .map((entry) => entry.trim())
136
+ .filter(Boolean);
137
+ if (entries.length === 0)
138
+ return undefined;
139
+ const ports = [];
140
+ for (const entry of entries) {
141
+ const [left, right] = entry.includes(':') ? entry.split(':', 2) : ['tcp', entry];
142
+ const protocol = left.toLowerCase();
143
+ const port = Number(right);
144
+ if ((protocol !== 'tcp' && protocol !== 'udp') ||
145
+ !Number.isInteger(port) ||
146
+ port < 1 ||
147
+ port > 65535) {
148
+ throw new Error(`Invalid Fly egress port "${entry}" — expected "tcp:443", "udp:53" or a bare port number.`);
149
+ }
150
+ ports.push({ protocol, port });
151
+ }
152
+ return ports;
153
+ }
154
+ /**
155
+ * Builds the shell command that observes egress from inside a Machine.
156
+ *
157
+ * Raw `net.connect` on purpose: an HTTP client would honour proxy environment
158
+ * variables and report the PROXY's policy instead of the network's. Every target
159
+ * is attempted in parallel and the process exits
160
+ * {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
161
+ * {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
162
+ * @param targets - Literal targets to attempt.
163
+ * @param timeoutMs - Per-connection timeout.
164
+ * @returns A `sh`-safe command string.
165
+ */
166
+ export function buildEgressProbeCommand(targets, timeoutMs) {
167
+ const script = `const net=require('net');const ts=${JSON.stringify(targets)};` +
168
+ `if(!ts.length)process.exit(1);` +
169
+ `let open=0,left=ts.length;` +
170
+ `const done=()=>{if(--left===0)process.exit(open>0?${EGRESS_PROBE_EXIT_REACHED}:${EGRESS_PROBE_EXIT_BLOCKED})};` +
171
+ `for(const t of ts){const s=net.connect({host:t.host,port:t.port});` +
172
+ `s.setTimeout(${timeoutMs});` +
173
+ `s.on('connect',()=>{open++;s.destroy();done()});` +
174
+ `s.on('timeout',()=>{s.destroy();done()});` +
175
+ `s.on('error',()=>done())}`;
176
+ return `node -e ${shellQuote(script)}`;
177
+ }
178
+ /**
179
+ * Describes the policy this provider applied, for the verdict message.
180
+ * @param policyPorts - Allowed ports, or `undefined` when no policy is applied.
181
+ * @returns A phrase naming the mechanism that was (or was not) in place.
182
+ */
183
+ function describePolicy(policyPorts) {
184
+ if (!policyPorts)
185
+ return 'no Fly network policy applied by this provider';
186
+ const ports = policyPorts.map((port) => `${port.protocol}/${port.port}`).join(', ');
187
+ return `a Fly network policy allowing egress on ${ports}`;
188
+ }
189
+ /**
190
+ * Maps a probe's exit status onto an {@link EgressVerdict}.
191
+ *
192
+ * The mapping is the whole security contract of this file, so it is total and
193
+ * has exactly one path to each state:
194
+ *
195
+ * | Exit | Verdict | Meaning |
196
+ * |---|---|---|
197
+ * | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
198
+ * | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
199
+ * | anything else | `inconclusive` | The probe did not complete its attempts. |
200
+ *
201
+ * Every other outcome — a missing interpreter (127), a killed process, Fly
202
+ * returning no status at all (-1), an API failure before the probe ever ran — is
203
+ * `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
204
+ * could not look" ⇒ "I looked and it is safe" conflation the capability exists
205
+ * to prevent, and the caller refuses to boot production on anything but
206
+ * `filtered`.
207
+ * @param exitCode - The probe process's exit status.
208
+ * @param context - App, targets and the policy this provider applied.
209
+ * @returns The verdict.
210
+ */
211
+ export function verdictForProbeExit(exitCode, context) {
212
+ const targets = context.targets.map(formatEgressProbeTarget).join(', ');
213
+ const policy = describePolicy(context.policyPorts);
214
+ if (exitCode === EGRESS_PROBE_EXIT_REACHED) {
215
+ return {
216
+ state: 'open',
217
+ detail: `A Machine in Fly app "${context.app}" (${policy}) opened a raw TCP connection to ` +
218
+ `${targets} — sandbox egress is NOT filtered.`,
219
+ remediation: context.policyPorts
220
+ ? 'The policy that is in force still permits the probe’s ports. A Fly network policy ' +
221
+ 'matches protocol and port ONLY — it has no host or CIDR matching — so it cannot be a ' +
222
+ 'host allowlist. Allow only the port of an egress proxy you control and route sandbox ' +
223
+ 'traffic through it. See https://fly.io/docs/machines/guides-examples/network-policies/.'
224
+ : 'Set config.egressAllowedPorts (or FLY_SANDBOX_EGRESS_ALLOWED_PORTS, e.g. ' +
225
+ '"tcp:3128,udp:53") so this provider applies a Fly network policy to every sandbox app. ' +
226
+ 'Fly drops everything else in that direction once one egress rule exists. See ' +
227
+ 'https://fly.io/docs/machines/guides-examples/network-policies/.',
228
+ };
229
+ }
230
+ if (exitCode === EGRESS_PROBE_EXIT_BLOCKED) {
231
+ return {
232
+ state: 'filtered',
233
+ detail: `Raw TCP connects to ${targets} from a Machine in Fly app "${context.app}" ` +
234
+ `(${policy}) were refused or timed out.`,
235
+ };
236
+ }
237
+ return {
238
+ state: 'inconclusive',
239
+ detail: `The egress probe in Fly app "${context.app}" exited ${exitCode}, which is neither ` +
240
+ `${EGRESS_PROBE_EXIT_BLOCKED} (every attempt blocked) nor ${EGRESS_PROBE_EXIT_REACHED} ` +
241
+ '(a target was reached) — it did not complete its connection attempts, so nothing was ' +
242
+ 'observed about egress.',
243
+ remediation: 'Check that the probe image can run `node -e` (override it with config.egressProbeImage), ' +
244
+ 'and that the Fly API token may create Machines in the probe app.',
245
+ };
246
+ }
247
+ /**
248
+ * Narrows an unknown value to a plain object.
249
+ * @param value - The value to test.
250
+ * @returns `true` when it is a non-null, non-array object.
251
+ */
252
+ function isRecord(value) {
253
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
254
+ }
255
+ /**
256
+ * Finds an existing policy's id in a LIST response, so the next write updates it
257
+ * instead of stacking duplicates.
258
+ *
259
+ * Neither the guide nor the announcement specifies what
260
+ * `GET /v1/apps/{app}/network_policies/` returns, and the endpoint is absent
261
+ * from Fly's OpenAPI specification — so this accepts a bare array or an object
262
+ * wrapping one under any of the plausible keys, and returns `undefined` for
263
+ * anything it does not recognize. `undefined` is safe: the caller then POSTs
264
+ * without an `id`, which still applies the policy.
265
+ * @param response - The raw parsed LIST response.
266
+ * @param name - The policy name to match.
267
+ * @returns The matching policy's id, or `undefined`.
268
+ */
269
+ export function extractPolicyId(response, name) {
270
+ const list = Array.isArray(response)
271
+ ? response
272
+ : isRecord(response)
273
+ ? [response.policies, response.network_policies, response.data].find(Array.isArray)
274
+ : undefined;
275
+ if (!Array.isArray(list))
276
+ return undefined;
277
+ for (const entry of list) {
278
+ if (isRecord(entry) && entry.name === name && typeof entry.id === 'string' && entry.id) {
279
+ return entry.id;
280
+ }
281
+ }
282
+ return undefined;
283
+ }
package/dist/exec.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Command execution on a Fly Machine, including the detach-and-poll path that
3
+ * works around Fly's 60-second exec ceiling.
4
+ *
5
+ * **The constraint.** `POST /v1/apps/{app}/machines/{id}/exec` is a
6
+ * request/response call whose `timeout` field is capped by Fly at 60 seconds —
7
+ * a longer value is rejected outright with
8
+ * `{"error":"timeout must not exceed 60 seconds"}`
9
+ * (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
10
+ * A sandbox routinely runs commands far past that (`npm install`, a production
11
+ * build), so a provider that only ever calls exec directly would fail every one
12
+ * of them. That is not an acceptable gap — it is the common case.
13
+ *
14
+ * **The mapping.** Commands whose budget fits inside the ceiling run directly.
15
+ * Anything longer is written to a script file inside the Machine, launched
16
+ * detached with its stdout/stderr redirected to files and its exit status
17
+ * written to a third file, and then POLLED with short direct execs until the
18
+ * status file appears. The caller sees one `ExecResult` either way; the only
19
+ * observable difference is that output arrives at the end rather than streaming
20
+ * (which the core `exec` contract does not offer anyway).
21
+ *
22
+ * @module
23
+ */
24
+ import type { ExecOptions, ExecResult } from '@molecule/api-code-sandbox';
25
+ import type { FlyExecResponse } from './types.js';
26
+ /**
27
+ * Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
28
+ * Verified against Fly staff guidance (see the module docs); a larger value is
29
+ * rejected by the API rather than clamped.
30
+ */
31
+ export declare const FLY_EXEC_MAX_TIMEOUT_SECONDS = 60;
32
+ /**
33
+ * Largest budget still run as a single direct exec, in seconds. Kept below
34
+ * {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
35
+ * thing that fires first — the caller's timeout should be.
36
+ */
37
+ export declare const DIRECT_EXEC_BUDGET_SECONDS = 55;
38
+ /**
39
+ * Cap on captured stdout/stderr per detached command, in bytes. The exec
40
+ * response carries output inline as JSON, so an unbounded `cat` of a build log
41
+ * would try to materialize the whole thing in one response body.
42
+ */
43
+ export declare const MAX_CAPTURED_OUTPUT_BYTES: number;
44
+ /**
45
+ * Exit code reported when a command's outcome is genuinely unknown — it was
46
+ * still running when the caller's budget expired, or Fly returned no status.
47
+ * Distinct from every real shell exit status (0-255), matching the Docker
48
+ * provider's convention so callers can tell "unfinished" from "failed".
49
+ */
50
+ export declare const INDETERMINATE_EXIT_CODE = -1;
51
+ /** Runs one exec call against a Machine. Injected so this module stays transport-free. */
52
+ export type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>;
53
+ /**
54
+ * Normalizes a Fly exec response into the core `ExecResult`.
55
+ *
56
+ * Fly reports `exit_signal` separately from `exit_code`; a signalled process has
57
+ * no meaningful exit code, so it is rendered the way a POSIX shell does, as
58
+ * `128 + signal`.
59
+ * @param response - The raw Fly exec response.
60
+ * @returns The normalized result.
61
+ */
62
+ export declare function toExecResult(response: FlyExecResponse): ExecResult;
63
+ /**
64
+ * Builds the shell script body for a command: change directory, export the
65
+ * requested environment, then run the command.
66
+ * @param command - The shell command to run.
67
+ * @param opts - Optional cwd and environment.
68
+ * @param defaultCwd - Directory used when `opts.cwd` is unset.
69
+ * @returns The script source, newline separated.
70
+ */
71
+ export declare function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string;
72
+ /**
73
+ * Executes a command on a Machine, choosing the direct or detached strategy
74
+ * based on the caller's time budget.
75
+ *
76
+ * @param rawExec - Transport callback issuing one Fly exec call.
77
+ * @param command - The shell command to run.
78
+ * @param opts - Core exec options (cwd, env, timeout in ms).
79
+ * @param defaultCwd - Working directory used when `opts.cwd` is unset.
80
+ * @returns The command's stdout, stderr and exit code.
81
+ * @throws {Error} When the command is too large to launch (see
82
+ * {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
83
+ */
84
+ export declare function execCommand(rawExec: RawExec, command: string, opts: ExecOptions | undefined, defaultCwd: string): Promise<ExecResult>;
85
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAGzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAW5C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAkB,CAAA;AAWxD;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,UAAU,CAYlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAaD;;;;;;;;;;;GAWG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAUrB"}
package/dist/exec.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Command execution on a Fly Machine, including the detach-and-poll path that
3
+ * works around Fly's 60-second exec ceiling.
4
+ *
5
+ * **The constraint.** `POST /v1/apps/{app}/machines/{id}/exec` is a
6
+ * request/response call whose `timeout` field is capped by Fly at 60 seconds —
7
+ * a longer value is rejected outright with
8
+ * `{"error":"timeout must not exceed 60 seconds"}`
9
+ * (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
10
+ * A sandbox routinely runs commands far past that (`npm install`, a production
11
+ * build), so a provider that only ever calls exec directly would fail every one
12
+ * of them. That is not an acceptable gap — it is the common case.
13
+ *
14
+ * **The mapping.** Commands whose budget fits inside the ceiling run directly.
15
+ * Anything longer is written to a script file inside the Machine, launched
16
+ * detached with its stdout/stderr redirected to files and its exit status
17
+ * written to a third file, and then POLLED with short direct execs until the
18
+ * status file appears. The caller sees one `ExecResult` either way; the only
19
+ * observable difference is that output arrives at the end rather than streaming
20
+ * (which the core `exec` contract does not offer anyway).
21
+ *
22
+ * @module
23
+ */
24
+ import { getLogger } from '@molecule/api-bond';
25
+ import { t } from '@molecule/api-i18n';
26
+ import { renderEnvExports, shellQuote } from './utilities.js';
27
+ const logger = getLogger();
28
+ /**
29
+ * Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
30
+ * Verified against Fly staff guidance (see the module docs); a larger value is
31
+ * rejected by the API rather than clamped.
32
+ */
33
+ export const FLY_EXEC_MAX_TIMEOUT_SECONDS = 60;
34
+ /**
35
+ * Largest budget still run as a single direct exec, in seconds. Kept below
36
+ * {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
37
+ * thing that fires first — the caller's timeout should be.
38
+ */
39
+ export const DIRECT_EXEC_BUDGET_SECONDS = 55;
40
+ /** Timeout applied to the short bookkeeping execs (launch, poll, collect), in seconds. */
41
+ const CONTROL_EXEC_TIMEOUT_SECONDS = 30;
42
+ /** Interval between status-file polls for a detached command, in ms. */
43
+ const POLL_INTERVAL_MS = 2000;
44
+ /** Default wall-clock budget for a command with no `opts.timeout`, in ms. */
45
+ const DEFAULT_TIMEOUT_MS = 600_000;
46
+ /**
47
+ * Cap on captured stdout/stderr per detached command, in bytes. The exec
48
+ * response carries output inline as JSON, so an unbounded `cat` of a build log
49
+ * would try to materialize the whole thing in one response body.
50
+ */
51
+ export const MAX_CAPTURED_OUTPUT_BYTES = 5 * 1024 * 1024;
52
+ /**
53
+ * Cap on the base64-encoded launcher script. `sh -c` receives it as a single
54
+ * argument, which Linux limits to MAX_ARG_STRLEN (128 KB).
55
+ */
56
+ const MAX_SCRIPT_BASE64_BYTES = 60_000;
57
+ /** Directory inside the Machine holding detached-command bookkeeping files. */
58
+ const WORK_DIR = '/tmp/.mol-exec';
59
+ /**
60
+ * Exit code reported when a command's outcome is genuinely unknown — it was
61
+ * still running when the caller's budget expired, or Fly returned no status.
62
+ * Distinct from every real shell exit status (0-255), matching the Docker
63
+ * provider's convention so callers can tell "unfinished" from "failed".
64
+ */
65
+ export const INDETERMINATE_EXIT_CODE = -1;
66
+ /**
67
+ * Normalizes a Fly exec response into the core `ExecResult`.
68
+ *
69
+ * Fly reports `exit_signal` separately from `exit_code`; a signalled process has
70
+ * no meaningful exit code, so it is rendered the way a POSIX shell does, as
71
+ * `128 + signal`.
72
+ * @param response - The raw Fly exec response.
73
+ * @returns The normalized result.
74
+ */
75
+ export function toExecResult(response) {
76
+ const exitCode = typeof response.exit_code === 'number'
77
+ ? response.exit_code
78
+ : typeof response.exit_signal === 'number' && response.exit_signal > 0
79
+ ? 128 + response.exit_signal
80
+ : INDETERMINATE_EXIT_CODE;
81
+ return {
82
+ stdout: response.stdout ?? '',
83
+ stderr: response.stderr ?? '',
84
+ exitCode,
85
+ };
86
+ }
87
+ /**
88
+ * Builds the shell script body for a command: change directory, export the
89
+ * requested environment, then run the command.
90
+ * @param command - The shell command to run.
91
+ * @param opts - Optional cwd and environment.
92
+ * @param defaultCwd - Directory used when `opts.cwd` is unset.
93
+ * @returns The script source, newline separated.
94
+ */
95
+ export function buildScript(command, opts, defaultCwd) {
96
+ const cwd = opts?.cwd ?? defaultCwd;
97
+ return [`cd ${shellQuote(cwd)} || exit 127`, ...renderEnvExports(opts?.env), command].join('\n');
98
+ }
99
+ /**
100
+ * Generates an id for one detached run's bookkeeping files. Hex only, so it is
101
+ * always safe to interpolate into a shell command unquoted.
102
+ * @returns A 16-character hex id.
103
+ */
104
+ function newRunId() {
105
+ let id = '';
106
+ while (id.length < 16)
107
+ id += Math.floor(Math.random() * 0xffffffff).toString(16);
108
+ return id.slice(0, 16);
109
+ }
110
+ /**
111
+ * Executes a command on a Machine, choosing the direct or detached strategy
112
+ * based on the caller's time budget.
113
+ *
114
+ * @param rawExec - Transport callback issuing one Fly exec call.
115
+ * @param command - The shell command to run.
116
+ * @param opts - Core exec options (cwd, env, timeout in ms).
117
+ * @param defaultCwd - Working directory used when `opts.cwd` is unset.
118
+ * @returns The command's stdout, stderr and exit code.
119
+ * @throws {Error} When the command is too large to launch (see
120
+ * {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
121
+ */
122
+ export async function execCommand(rawExec, command, opts, defaultCwd) {
123
+ const budgetMs = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
124
+ const script = buildScript(command, opts, defaultCwd);
125
+ if (budgetMs <= DIRECT_EXEC_BUDGET_SECONDS * 1000) {
126
+ const seconds = Math.max(1, Math.min(Math.ceil(budgetMs / 1000), FLY_EXEC_MAX_TIMEOUT_SECONDS));
127
+ return toExecResult(await rawExec(['sh', '-c', script], seconds));
128
+ }
129
+ return await execDetached(rawExec, script, budgetMs);
130
+ }
131
+ /**
132
+ * Runs a script detached inside the Machine and polls for its completion.
133
+ *
134
+ * The script is delivered base64-encoded so that no amount of quoting in the
135
+ * caller's command can break out of the launcher; the launcher itself only ever
136
+ * interpolates a hex run id.
137
+ * @param rawExec - Transport callback issuing one Fly exec call.
138
+ * @param script - The script body to run.
139
+ * @param budgetMs - Wall-clock budget before the result is reported indeterminate.
140
+ * @returns The command's captured output and exit code.
141
+ * @throws {Error} When the encoded script exceeds the single-argument limit, or
142
+ * when the launcher itself fails to start.
143
+ */
144
+ async function execDetached(rawExec, script, budgetMs) {
145
+ const runId = newRunId();
146
+ const base64 = Buffer.from(script, 'utf8').toString('base64');
147
+ if (base64.length > MAX_SCRIPT_BASE64_BYTES) {
148
+ throw new Error(t('codeSandbox.flyio.error.commandTooLarge', { limit: String(MAX_SCRIPT_BASE64_BYTES) }, {
149
+ defaultValue: `Command is too large to launch on a Fly Machine (encoded ${base64.length} bytes, limit ${MAX_SCRIPT_BASE64_BYTES}). ` +
150
+ 'Write it to a file with writeFile() and exec that file instead.',
151
+ }));
152
+ }
153
+ const scriptPath = `${WORK_DIR}/${runId}.sh`;
154
+ const outPath = `${WORK_DIR}/${runId}.out`;
155
+ const errPath = `${WORK_DIR}/${runId}.err`;
156
+ const rcPath = `${WORK_DIR}/${runId}.rc`;
157
+ // The `&` must apply to the nohup line ALONE. In POSIX sh, `a && b &`
158
+ // backgrounds the whole AND-list, which would race the script write against
159
+ // the launch, so these are separate statements rather than one chain.
160
+ const launcher = [
161
+ `mkdir -p ${WORK_DIR} || exit 1`,
162
+ `printf %s ${shellQuote(base64)} | base64 -d > ${scriptPath} || exit 1`,
163
+ `nohup sh -c 'sh ${scriptPath} > ${outPath} 2> ${errPath}; printf %s "$?" > ${rcPath}' > /dev/null 2>&1 < /dev/null &`,
164
+ `printf %s ${runId}`,
165
+ ].join('\n');
166
+ const launch = toExecResult(await rawExec(['sh', '-c', launcher], CONTROL_EXEC_TIMEOUT_SECONDS));
167
+ if (launch.exitCode !== 0) {
168
+ throw new Error(t('codeSandbox.flyio.error.launchFailed', { error: launch.stderr || launch.stdout }, {
169
+ defaultValue: `Failed to launch detached command on Fly Machine: ${launch.stderr || launch.stdout}`,
170
+ }));
171
+ }
172
+ const deadline = Date.now() + budgetMs;
173
+ let rc = null;
174
+ while (Date.now() < deadline) {
175
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
176
+ const poll = toExecResult(await rawExec(['sh', '-c', `cat ${rcPath} 2>/dev/null`], Math.max(1, Math.min(CONTROL_EXEC_TIMEOUT_SECONDS, FLY_EXEC_MAX_TIMEOUT_SECONDS))));
177
+ const value = poll.stdout.trim();
178
+ if (value) {
179
+ rc = value;
180
+ break;
181
+ }
182
+ }
183
+ const collect = toExecResult(await rawExec([
184
+ 'sh',
185
+ '-c',
186
+ `head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${outPath} 2>/dev/null; printf '\\n__MOL_STDERR__\\n'; head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${errPath} 2>/dev/null`,
187
+ ], CONTROL_EXEC_TIMEOUT_SECONDS));
188
+ const separator = collect.stdout.indexOf('\n__MOL_STDERR__\n');
189
+ const stdout = separator === -1 ? collect.stdout : collect.stdout.slice(0, separator);
190
+ const stderr = separator === -1 ? '' : collect.stdout.slice(separator + '\n__MOL_STDERR__\n'.length);
191
+ if (rc === null) {
192
+ // Still running at the deadline. The bookkeeping files are deliberately NOT
193
+ // removed — the process owns them and will keep writing.
194
+ logger.warn('Fly detached command exceeded its budget and is still running', {
195
+ runId,
196
+ budgetMs,
197
+ });
198
+ return { stdout, stderr, exitCode: INDETERMINATE_EXIT_CODE };
199
+ }
200
+ try {
201
+ await rawExec(['sh', '-c', `rm -f ${WORK_DIR}/${runId}.*`], CONTROL_EXEC_TIMEOUT_SECONDS);
202
+ }
203
+ catch (error) {
204
+ // Best-effort cleanup of four files under /tmp. The command itself already
205
+ // completed and its result is in hand; failing the call over leftover
206
+ // scratch files would turn a success into an error.
207
+ logger.debug('Failed to clean up Fly detached command scratch files', { runId, error });
208
+ }
209
+ const parsed = Number.parseInt(rc, 10);
210
+ return {
211
+ stdout,
212
+ stderr,
213
+ exitCode: Number.isFinite(parsed) ? parsed : INDETERMINATE_EXIT_CODE,
214
+ };
215
+ }