@molecule/api-code-sandbox-flyio 1.0.9 → 1.0.11

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/exec.d.ts CHANGED
@@ -41,6 +41,16 @@ export declare const DIRECT_EXEC_BUDGET_SECONDS = 55;
41
41
  * would try to materialize the whole thing in one response body.
42
42
  */
43
43
  export declare const MAX_CAPTURED_OUTPUT_BYTES: number;
44
+ /**
45
+ * Hard cap on the `sh -c <script>` argument SENT to Fly's exec endpoint. This is
46
+ * NOT the OS `MAX_ARG_STRLEN` (128 KB) — the Fly Machines exec API rejects a
47
+ * command well below that, and does so INVISIBLY: it answers `200` with
48
+ * `exit_code: 0` and an `Unhandled rejection: Rejection([PayloadTooLarge…])`
49
+ * body, so the command never runs yet reads as success. Measured against the
50
+ * live API: ~15.8 KB scripts run, ~23.7 KB are rejected. 15 KB is the safe
51
+ * ceiling; callers that move bulk data (writeFile, importFiles) chunk beneath it.
52
+ */
53
+ export declare const FLY_EXEC_MAX_ARG_BYTES = 15000;
44
54
  /**
45
55
  * Exit code reported when a command's outcome is genuinely unknown — it was
46
56
  * still running when the caller's budget expired, or Fly returned no status.
@@ -73,13 +83,15 @@ export declare function buildScript(command: string, opts: ExecOptions | undefin
73
83
  * Executes a command on a Machine, choosing the direct or detached strategy
74
84
  * based on the caller's time budget.
75
85
  *
86
+ * A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
87
+ * spilled to a file via chunked writes and run from there, so there is no size
88
+ * ceiling the caller must respect.
76
89
  * @param rawExec - Transport callback issuing one Fly exec call.
77
90
  * @param command - The shell command to run.
78
91
  * @param opts - Core exec options (cwd, env, timeout in ms).
79
92
  * @param defaultCwd - Working directory used when `opts.cwd` is unset.
80
93
  * @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.
94
+ * @throws {Error} When staging a large command or the transport itself fails.
83
95
  */
84
96
  export declare function execCommand(rawExec: RawExec, command: string, opts: ExecOptions | undefined, defaultCwd: string): Promise<ExecResult>;
85
97
  //# sourceMappingURL=exec.d.ts.map
@@ -1 +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"}
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;AAQxD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAY5C;;;;;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,CAiBlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAmDD;;;;;;;;;;;;;GAaG;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,CAqBrB"}
package/dist/exec.js CHANGED
@@ -50,10 +50,26 @@ const DEFAULT_TIMEOUT_MS = 600_000;
50
50
  */
51
51
  export const MAX_CAPTURED_OUTPUT_BYTES = 5 * 1024 * 1024;
52
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).
53
+ * Base64 chunk size for spilling a large script to a file, comfortably under
54
+ * {@link FLY_EXEC_MAX_ARG_BYTES} once the `printf … | base64 -d` wrapper is added.
55
55
  */
56
- const MAX_SCRIPT_BASE64_BYTES = 60_000;
56
+ const SCRIPT_SPILL_CHUNK_BASE64 = 12_000;
57
+ /**
58
+ * Hard cap on the `sh -c <script>` argument SENT to Fly's exec endpoint. This is
59
+ * NOT the OS `MAX_ARG_STRLEN` (128 KB) — the Fly Machines exec API rejects a
60
+ * command well below that, and does so INVISIBLY: it answers `200` with
61
+ * `exit_code: 0` and an `Unhandled rejection: Rejection([PayloadTooLarge…])`
62
+ * body, so the command never runs yet reads as success. Measured against the
63
+ * live API: ~15.8 KB scripts run, ~23.7 KB are rejected. 15 KB is the safe
64
+ * ceiling; callers that move bulk data (writeFile, importFiles) chunk beneath it.
65
+ */
66
+ export const FLY_EXEC_MAX_ARG_BYTES = 15_000;
67
+ /**
68
+ * Marker Fly puts in an exec response body when it REJECTS the command instead
69
+ * of running it (most often {@link FLY_EXEC_MAX_ARG_BYTES} exceeded). Detected so
70
+ * a rejection becomes a loud failure instead of a silent `exit_code: 0`.
71
+ */
72
+ const FLY_EXEC_REJECTION = 'Rejection([';
57
73
  /** Directory inside the Machine holding detached-command bookkeeping files. */
58
74
  const WORK_DIR = '/tmp/.mol-exec';
59
75
  /**
@@ -73,16 +89,21 @@ export const INDETERMINATE_EXIT_CODE = -1;
73
89
  * @returns The normalized result.
74
90
  */
75
91
  export function toExecResult(response) {
92
+ const stdout = response.stdout ?? '';
93
+ const stderr = response.stderr ?? '';
94
+ // Fly answers a REJECTED command (over-limit payload, bad method) with a 200,
95
+ // `exit_code: 0`, and a rejection body — the command never ran. Trusting that
96
+ // 0 silently dropped every oversized write. Surface it as a failure so
97
+ // exit-code checks catch it instead of reporting phantom success.
98
+ if (stderr.includes(FLY_EXEC_REJECTION) || stdout.includes(FLY_EXEC_REJECTION)) {
99
+ return { stdout, stderr, exitCode: INDETERMINATE_EXIT_CODE };
100
+ }
76
101
  const exitCode = typeof response.exit_code === 'number'
77
102
  ? response.exit_code
78
103
  : typeof response.exit_signal === 'number' && response.exit_signal > 0
79
104
  ? 128 + response.exit_signal
80
105
  : INDETERMINATE_EXIT_CODE;
81
- return {
82
- stdout: response.stdout ?? '',
83
- stderr: response.stderr ?? '',
84
- exitCode,
85
- };
106
+ return { stdout, stderr, exitCode };
86
107
  }
87
108
  /**
88
109
  * Builds the shell script body for a command: change directory, export the
@@ -107,23 +128,62 @@ function newRunId() {
107
128
  id += Math.floor(Math.random() * 0xffffffff).toString(16);
108
129
  return id.slice(0, 16);
109
130
  }
131
+ /**
132
+ * Writes `script` to `path` on the Machine via chunked base64 execs, each under
133
+ * {@link FLY_EXEC_MAX_ARG_BYTES}.
134
+ *
135
+ * Fly's exec API silently drops a single over-limit `printf %s <base64>`, so a
136
+ * script too large to pass inline (a big `node -e`, a bulk file write) must be
137
+ * delivered in pieces. This is the transport-level equivalent of `docker cp` for
138
+ * a command body — callers never see the limit.
139
+ * @param rawExec - Transport callback issuing one Fly exec call.
140
+ * @param path - Absolute destination path on the Machine.
141
+ * @param script - The script body to stage.
142
+ * @throws {Error} When the work dir or any chunk write fails.
143
+ */
144
+ async function writeScriptChunked(rawExec, path, script) {
145
+ const base64 = Buffer.from(script, 'utf8').toString('base64');
146
+ const ensure = toExecResult(await rawExec(['sh', '-c', `mkdir -p ${WORK_DIR}`], CONTROL_EXEC_TIMEOUT_SECONDS));
147
+ if (ensure.exitCode !== 0) {
148
+ throw new Error(`Failed to create the Fly exec work dir: ${ensure.stderr || ensure.stdout}`);
149
+ }
150
+ for (let i = 0; i < base64.length; i += SCRIPT_SPILL_CHUNK_BASE64) {
151
+ const op = i === 0 ? '>' : '>>';
152
+ const chunk = base64.slice(i, i + SCRIPT_SPILL_CHUNK_BASE64);
153
+ const written = toExecResult(await rawExec(['sh', '-c', `printf %s ${shellQuote(chunk)} | base64 -d ${op} ${path}`], CONTROL_EXEC_TIMEOUT_SECONDS));
154
+ if (written.exitCode !== 0) {
155
+ throw new Error(`Failed to stage a Fly exec script chunk: ${written.stderr || written.stdout}`);
156
+ }
157
+ }
158
+ }
110
159
  /**
111
160
  * Executes a command on a Machine, choosing the direct or detached strategy
112
161
  * based on the caller's time budget.
113
162
  *
163
+ * A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
164
+ * spilled to a file via chunked writes and run from there, so there is no size
165
+ * ceiling the caller must respect.
114
166
  * @param rawExec - Transport callback issuing one Fly exec call.
115
167
  * @param command - The shell command to run.
116
168
  * @param opts - Core exec options (cwd, env, timeout in ms).
117
169
  * @param defaultCwd - Working directory used when `opts.cwd` is unset.
118
170
  * @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.
171
+ * @throws {Error} When staging a large command or the transport itself fails.
121
172
  */
122
173
  export async function execCommand(rawExec, command, opts, defaultCwd) {
123
174
  const budgetMs = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
124
175
  const script = buildScript(command, opts, defaultCwd);
125
176
  if (budgetMs <= DIRECT_EXEC_BUDGET_SECONDS * 1000) {
126
177
  const seconds = Math.max(1, Math.min(Math.ceil(budgetMs / 1000), FLY_EXEC_MAX_TIMEOUT_SECONDS));
178
+ // Fly's exec API silently drops a command over FLY_EXEC_MAX_ARG_BYTES. A
179
+ // script that big cannot go inline, so spill it to a file (chunked writes,
180
+ // each under the limit) and run that — matching docker exec, which has no
181
+ // such ceiling. The caller stays unaware of the transport's limit.
182
+ if (script.length > FLY_EXEC_MAX_ARG_BYTES) {
183
+ const path = `${WORK_DIR}/direct-${newRunId()}.sh`;
184
+ await writeScriptChunked(rawExec, path, script);
185
+ return toExecResult(await rawExec(['sh', '-c', `sh ${path}; ec=$?; rm -f ${path}; exit $ec`], seconds));
186
+ }
127
187
  return toExecResult(await rawExec(['sh', '-c', script], seconds));
128
188
  }
129
189
  return await execDetached(rawExec, script, budgetMs);
@@ -143,23 +203,18 @@ export async function execCommand(rawExec, command, opts, defaultCwd) {
143
203
  */
144
204
  async function execDetached(rawExec, script, budgetMs) {
145
205
  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
206
  const scriptPath = `${WORK_DIR}/${runId}.sh`;
154
207
  const outPath = `${WORK_DIR}/${runId}.out`;
155
208
  const errPath = `${WORK_DIR}/${runId}.err`;
156
209
  const rcPath = `${WORK_DIR}/${runId}.rc`;
210
+ // Stage the script via chunked writes (each under the Fly exec arg limit),
211
+ // then launch it. A single `printf %s <base64>` would exceed the limit for a
212
+ // large script and Fly would drop it silently. writeScriptChunked also creates
213
+ // WORK_DIR, so the launcher below only has to background the run.
214
+ await writeScriptChunked(rawExec, scriptPath, script);
157
215
  // 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.
216
+ // backgrounds the whole AND-list, so these are separate statements.
160
217
  const launcher = [
161
- `mkdir -p ${WORK_DIR} || exit 1`,
162
- `printf %s ${shellQuote(base64)} | base64 -d > ${scriptPath} || exit 1`,
163
218
  `nohup sh -c 'sh ${scriptPath} > ${outPath} 2> ${errPath}; printf %s "$?" > ${rcPath}' > /dev/null 2>&1 < /dev/null &`,
164
219
  `printf %s ${runId}`,
165
220
  ].join('\n');
package/dist/provider.js CHANGED
@@ -42,11 +42,13 @@ const LIST_PAGE_SIZE = 200;
42
42
  /** Hard cap on pages fetched while listing, so a bad cursor cannot loop forever. */
43
43
  const LIST_MAX_PAGES = 50;
44
44
  /**
45
- * Chunk size for base64-encoded `writeFile` payloads. `sh -c` receives the whole
46
- * command as ONE argument, which Linux caps at MAX_ARG_STRLEN (128 KB), so a
47
- * file over ~45 KB has to be written across several appends.
45
+ * Chunk size for base64-encoded `writeFile` payloads. The binding limit is NOT
46
+ * the OS `MAX_ARG_STRLEN` (128 KB) but Fly's exec API, which silently rejects a
47
+ * command over ~15 KB (`FLY_EXEC_MAX_ARG_BYTES`) with a phantom `exit_code: 0` —
48
+ * so this stays well under it, leaving room for the `printf … | base64 -d`
49
+ * wrapper. A file over ~9 KB is written across several appends.
48
50
  */
49
- const WRITE_CHUNK_BASE64 = 60_000;
51
+ const WRITE_CHUNK_BASE64 = 12_000;
50
52
  /**
51
53
  * Budget for the file-operation execs (`cat`, `ls`, `rm`, chunked writes), in ms.
52
54
  *
@@ -81,15 +83,13 @@ const DEFAULT_PROBE_TIMEOUT_MS = 3000;
81
83
  */
82
84
  const IMPORT_EXTRACT_TIMEOUT_MS = 55_000;
83
85
  /**
84
- * Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Larger
85
- * than `WRITE_CHUNK_BASE64` (which is tuned for small single files) so a
86
- * multi-megabyte scaffold moves in fewer exec calls — each exec spends Fly's
87
- * per-action rate-limit budget, and a burst of small chunks trips 429s (and, on
88
- * a busy API, transient 404s). Kept well under the 128 KB `MAX_ARG_STRLEN`
89
- * ceiling once the `printf … | base64 -d` wrapper and `buildScript` prefix are
90
- * added.
86
+ * Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Bounded by
87
+ * the same Fly exec payload limit as `WRITE_CHUNK_BASE64` (~15 KB, silently
88
+ * enforced) — a larger chunk is rejected with a phantom `exit_code: 0` and the
89
+ * bytes vanish. A multi-megabyte scaffold therefore moves in many small appends;
90
+ * the gzip pass before transfer is what keeps that count reasonable.
91
91
  */
92
- const IMPORT_WRITE_CHUNK_BASE64 = 100_000;
92
+ const IMPORT_WRITE_CHUNK_BASE64 = 12_000;
93
93
  /** Extra seconds allowed for the probe exec beyond its connection budget. */
94
94
  const PROBE_EXEC_HEADROOM_SECONDS = 10;
95
95
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",