@blaxel/core 0.3.13 → 0.3.18-dev.283

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.
Files changed (45) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/client/sdk.gen.js +105 -3
  3. package/dist/cjs/common/settings.js +28 -3
  4. package/dist/cjs/common/settings.test.js +50 -0
  5. package/dist/cjs/common/shell.js +18 -0
  6. package/dist/cjs/sandbox/filesystem/filesystem.js +31 -11
  7. package/dist/cjs/sandbox/filesystem/filesystem.test.js +207 -2
  8. package/dist/cjs/sandbox/sandbox.js +124 -0
  9. package/dist/cjs/types/client/sdk.gen.d.ts +31 -1
  10. package/dist/cjs/types/client/types.gen.d.ts +368 -7
  11. package/dist/cjs/types/common/settings.d.ts +15 -0
  12. package/dist/cjs/types/common/shell.d.ts +13 -0
  13. package/dist/cjs/types/sandbox/sandbox.d.ts +56 -1
  14. package/dist/cjs/types/sandbox/schedule.d.ts +2 -2
  15. package/dist/cjs-browser/.tsbuildinfo +1 -1
  16. package/dist/cjs-browser/client/sdk.gen.js +105 -3
  17. package/dist/cjs-browser/common/settings.js +28 -3
  18. package/dist/cjs-browser/common/settings.test.js +50 -0
  19. package/dist/cjs-browser/common/shell.js +18 -0
  20. package/dist/cjs-browser/sandbox/filesystem/filesystem.js +31 -11
  21. package/dist/cjs-browser/sandbox/filesystem/filesystem.test.js +207 -2
  22. package/dist/cjs-browser/sandbox/sandbox.js +124 -0
  23. package/dist/cjs-browser/types/client/sdk.gen.d.ts +31 -1
  24. package/dist/cjs-browser/types/client/types.gen.d.ts +368 -7
  25. package/dist/cjs-browser/types/common/settings.d.ts +15 -0
  26. package/dist/cjs-browser/types/common/shell.d.ts +13 -0
  27. package/dist/cjs-browser/types/sandbox/sandbox.d.ts +56 -1
  28. package/dist/cjs-browser/types/sandbox/schedule.d.ts +2 -2
  29. package/dist/esm/.tsbuildinfo +1 -1
  30. package/dist/esm/client/sdk.gen.js +96 -0
  31. package/dist/esm/common/settings.js +28 -3
  32. package/dist/esm/common/settings.test.js +50 -0
  33. package/dist/esm/common/shell.js +15 -0
  34. package/dist/esm/sandbox/filesystem/filesystem.js +31 -11
  35. package/dist/esm/sandbox/filesystem/filesystem.test.js +208 -3
  36. package/dist/esm/sandbox/sandbox.js +125 -1
  37. package/dist/esm-browser/.tsbuildinfo +1 -1
  38. package/dist/esm-browser/client/sdk.gen.js +96 -0
  39. package/dist/esm-browser/common/settings.js +28 -3
  40. package/dist/esm-browser/common/settings.test.js +50 -0
  41. package/dist/esm-browser/common/shell.js +15 -0
  42. package/dist/esm-browser/sandbox/filesystem/filesystem.js +31 -11
  43. package/dist/esm-browser/sandbox/filesystem/filesystem.test.js +208 -3
  44. package/dist/esm-browser/sandbox/sandbox.js +125 -1
  45. package/package.json +1 -1
@@ -2,9 +2,9 @@
2
2
  // This file is auto-generated by @hey-api/openapi-ts
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.shareImage = exports.listImageShares = exports.getImage = exports.deleteImage = exports.createImage = exports.listImages = exports.cleanupImages = exports.listFunctionRevisions = exports.updateFunction = exports.getFunction = exports.deleteFunction = exports.createFunction = exports.listFunctions = exports.testFeatureFlag = exports.getWorkspaceFeatures = exports.listAllEgressIps = exports.getEgressGatewayUsage = exports.listAllEgressGateways = exports.getDriveJwks = exports.getDriveByExternalId = exports.createDriveAccessToken = exports.updateDrive = exports.getDrive = exports.deleteDrive = exports.createDrive = exports.listDrives = exports.verifyCustomDomain = exports.unshareCustomDomain = exports.shareCustomDomain = exports.listCustomDomainShares = exports.updateCustomDomain = exports.getCustomDomain = exports.deleteCustomDomain = exports.createCustomDomain = exports.listCustomDomains = exports.getConfiguration = exports.getChangelog = exports.listApplicationRevisions = exports.createApplicationCustomDomain = exports.updateApplication = exports.getApplication = exports.deleteApplication = exports.createApplication = exports.listApplications = exports.listAgentRevisions = exports.updateAgent = exports.getAgent = exports.deleteAgent = exports.createAgent = exports.listAgents = void 0;
5
- exports.deleteSandboxPreview = exports.createSandboxPreview = exports.listSandboxPreviews = exports.forkSandbox = exports.updateSandbox = exports.getSandbox = exports.deleteSandbox = exports.createSandbox = exports.listSandboxes = exports.listSandboxHubDefinitions = exports.listPublicIps = exports.getPolicyUsages = exports.updatePolicy = exports.getPolicy = exports.deletePolicy = exports.createPolicy = exports.listPolicies = exports.declineImageShare = exports.acceptImageShare = exports.listPendingImageShares = exports.listModelRevisions = exports.updateModel = exports.getModel = exports.deleteModel = exports.createModel = exports.listModels = exports.listMcpHubDefinitions = exports.listLocations = exports.listJobRevisions = exports.listJobExecutionTasks = exports.getJobExecution = exports.deleteJobExecution = exports.createJobExecution = exports.listJobExecutions = exports.updateJob = exports.getJob = exports.deleteJob = exports.createJob = exports.listJobs = exports.getIntegrationConnectionModel = exports.listIntegrationConnectionModels = exports.getIntegrationConnectionModelEndpointConfigurations = exports.updateIntegrationConnection = exports.getIntegrationConnection = exports.deleteIntegrationConnection = exports.createIntegrationConnection = exports.listIntegrationConnections = exports.getIntegration = exports.deleteImageTag = exports.unshareImage = void 0;
6
- exports.createEgressIp = exports.listEgressIps = exports.getEgressGateway = exports.deleteEgressGateway = exports.createEgressGateway = exports.listEgressGateways = exports.getVpc = exports.deleteVpc = exports.createVpc = exports.listVpcs = exports.getVolumeByExternalId = exports.updateVolume = exports.getVolume = exports.deleteVolume = exports.createVolume = exports.listVolumes = exports.deleteVolumeTemplateVersion = exports.updateVolumeTemplate = exports.getVolumeTemplate = exports.deleteVolumeTemplate = exports.createVolumeTemplate = exports.listVolumeTemplates = exports.updateWorkspaceUserRole = exports.removeWorkspaceUser = exports.inviteWorkspaceUser = exports.listWorkspaceUsers = exports.getTemplate = exports.listTemplates = exports.deleteApiKeyForServiceAccount = exports.createApiKeyForServiceAccount = exports.listApiKeysForServiceAccount = exports.updateWorkspaceServiceAccount = exports.deleteWorkspaceServiceAccount = exports.createWorkspaceServiceAccount = exports.getWorkspaceServiceAccounts = exports.getSandboxByExternalId = exports.deleteSandboxSnapshot = exports.createSandboxSnapshot = exports.listSandboxSnapshots = exports.updateSandboxSchedule = exports.getSandboxSchedule = exports.deleteSandboxSchedule = exports.createSandboxSchedule = exports.listSandboxSchedules = exports.listSandboxScheduleExecutions = exports.deleteSandboxPreviewToken = exports.createSandboxPreviewToken = exports.listSandboxPreviewTokens = exports.updateSandboxPreview = exports.getSandboxPreview = void 0;
7
- exports.checkWorkspaceAvailability = exports.leaveWorkspace = exports.updateWorkspace = exports.getWorkspace = exports.deleteWorkspace = exports.createWorkspace = exports.listWorkspaces = exports.getEgressIp = exports.deleteEgressIp = void 0;
5
+ exports.createSandboxPreview = exports.listSandboxPreviews = exports.forkSandbox = exports.archiveSandbox = exports.updateSandbox = exports.getSandbox = exports.deleteSandbox = exports.createSandbox = exports.listSandboxes = exports.listSandboxHubDefinitions = exports.listPublicIps = exports.getPolicyUsages = exports.updatePolicy = exports.getPolicy = exports.deletePolicy = exports.createPolicy = exports.listPolicies = exports.declineImageShare = exports.acceptImageShare = exports.listPendingImageShares = exports.listModelRevisions = exports.updateModel = exports.getModel = exports.deleteModel = exports.createModel = exports.listModels = exports.listMcpHubDefinitions = exports.listLocations = exports.listJobRevisions = exports.listJobExecutionTasks = exports.getJobExecution = exports.deleteJobExecution = exports.createJobExecution = exports.listJobExecutions = exports.updateJob = exports.getJob = exports.deleteJob = exports.createJob = exports.listJobs = exports.getIntegrationConnectionModel = exports.listIntegrationConnectionModels = exports.getIntegrationConnectionModelEndpointConfigurations = exports.updateIntegrationConnection = exports.getIntegrationConnection = exports.deleteIntegrationConnection = exports.createIntegrationConnection = exports.listIntegrationConnections = exports.getIntegration = exports.deleteImageTag = exports.unshareImage = void 0;
6
+ exports.getVpc = exports.deleteVpc = exports.createVpc = exports.listVpcs = exports.getVolumeByExternalId = exports.updateVolume = exports.getVolume = exports.deleteVolume = exports.createVolume = exports.listVolumes = exports.deleteVolumeTemplateVersion = exports.updateVolumeTemplate = exports.getVolumeTemplate = exports.deleteVolumeTemplate = exports.createVolumeTemplate = exports.listVolumeTemplates = exports.updateWorkspaceUserRole = exports.removeWorkspaceUser = exports.inviteWorkspaceUser = exports.listWorkspaceUsers = exports.getTemplate = exports.listTemplates = exports.deleteApiKeyForServiceAccount = exports.createApiKeyForServiceAccount = exports.listApiKeysForServiceAccount = exports.updateWorkspaceServiceAccount = exports.deleteWorkspaceServiceAccount = exports.createWorkspaceServiceAccount = exports.getWorkspaceServiceAccounts = exports.getSandboxScheduleMetrics = exports.listSchedules = exports.listScheduleExecutions = exports.getSandboxByExternalId = exports.unarchiveSandbox = exports.restoreSandboxSnapshot = exports.deleteSandboxSnapshot = exports.createSandboxSnapshot = exports.listSandboxSnapshots = exports.updateSandboxSchedule = exports.getSandboxSchedule = exports.deleteSandboxSchedule = exports.createSandboxSchedule = exports.listSandboxSchedules = exports.listSandboxScheduleExecutions = exports.deleteSandboxPreviewToken = exports.createSandboxPreviewToken = exports.listSandboxPreviewTokens = exports.updateSandboxPreview = exports.getSandboxPreview = exports.deleteSandboxPreview = void 0;
7
+ exports.checkWorkspaceAvailability = exports.leaveWorkspace = exports.updateWorkspace = exports.getWorkspace = exports.deleteWorkspace = exports.createWorkspace = exports.listWorkspaces = exports.getEgressIp = exports.deleteEgressIp = exports.createEgressIp = exports.listEgressIps = exports.getEgressGateway = exports.deleteEgressGateway = exports.createEgressGateway = exports.listEgressGateways = void 0;
8
8
  const client_gen_1 = require("./client.gen");
9
9
  /**
10
10
  * List all agents
@@ -1718,6 +1718,23 @@ const updateSandbox = (options) => {
1718
1718
  });
1719
1719
  };
1720
1720
  exports.updateSandbox = updateSandbox;
1721
+ /**
1722
+ * Archive sandbox
1723
+ * Archives a sandbox. The changes its filesystem accumulated over its image are exported to the archive store, then the sandbox is shut down while its definition is kept, so it can be recreated later with the same disk. Memory and running processes are not preserved, processes are started again from their configuration when the sandbox is unarchived.
1724
+ */
1725
+ const archiveSandbox = (options) => {
1726
+ return (options.client ?? client_gen_1.client).post({
1727
+ security: [
1728
+ {
1729
+ scheme: 'bearer',
1730
+ type: 'http'
1731
+ }
1732
+ ],
1733
+ url: '/sandboxes/{sandboxName}/archive',
1734
+ ...options
1735
+ });
1736
+ };
1737
+ exports.archiveSandbox = archiveSandbox;
1721
1738
  /**
1722
1739
  * Fork sandbox
1723
1740
  * Forks a sandbox into a new sandbox or application. When forking to a sandbox, the target must not already exist (409 if it does). When forking to an application, a new revision is added if the app already exists, or a new application is created. This is a WIP endpoint — the full implementation depends on the execution plane.
@@ -2052,6 +2069,40 @@ const deleteSandboxSnapshot = (options) => {
2052
2069
  });
2053
2070
  };
2054
2071
  exports.deleteSandboxSnapshot = deleteSandboxSnapshot;
2072
+ /**
2073
+ * Restore sandbox from snapshot
2074
+ * Restores a sandbox to one of its own snapshots. The running sandbox is torn down and rebuilt from the snapshot under the same name and URLs, so everything it held since the snapshot was taken is lost unless it was itself snapshotted.
2075
+ */
2076
+ const restoreSandboxSnapshot = (options) => {
2077
+ return (options.client ?? client_gen_1.client).post({
2078
+ security: [
2079
+ {
2080
+ scheme: 'bearer',
2081
+ type: 'http'
2082
+ }
2083
+ ],
2084
+ url: '/sandboxes/{sandboxName}/snapshots/{snapshotId}/restore',
2085
+ ...options
2086
+ });
2087
+ };
2088
+ exports.restoreSandboxSnapshot = restoreSandboxSnapshot;
2089
+ /**
2090
+ * Unarchive sandbox
2091
+ * Recreates an archived sandbox from its archive. The recreated sandbox runs the current generation whatever generation it was archived from, restores the archived filesystem before anything starts, and starts the saved processes again with new process identifiers.
2092
+ */
2093
+ const unarchiveSandbox = (options) => {
2094
+ return (options.client ?? client_gen_1.client).post({
2095
+ security: [
2096
+ {
2097
+ scheme: 'bearer',
2098
+ type: 'http'
2099
+ }
2100
+ ],
2101
+ url: '/sandboxes/{sandboxName}/unarchive',
2102
+ ...options
2103
+ });
2104
+ };
2105
+ exports.unarchiveSandbox = unarchiveSandbox;
2055
2106
  /**
2056
2107
  * Get sandbox by external ID
2057
2108
  * Returns the most recent non-terminated sandbox matching the given external ID. If no active sandbox is found, returns 404.
@@ -2069,6 +2120,57 @@ const getSandboxByExternalId = (options) => {
2069
2120
  });
2070
2121
  };
2071
2122
  exports.getSandboxByExternalId = getSandboxByExternalId;
2123
+ /**
2124
+ * List Workspace Schedule Executions
2125
+ * Returns schedule execution submissions across the workspace, newest first by default. Status describes submission acceptance, not process completion. since and until are inclusive RFC 3339 bounds on creation time.
2126
+ */
2127
+ const listScheduleExecutions = (options) => {
2128
+ return (options?.client ?? client_gen_1.client).get({
2129
+ security: [
2130
+ {
2131
+ scheme: 'bearer',
2132
+ type: 'http'
2133
+ }
2134
+ ],
2135
+ url: '/schedule-executions',
2136
+ ...options
2137
+ });
2138
+ };
2139
+ exports.listScheduleExecutions = listScheduleExecutions;
2140
+ /**
2141
+ * List Workspace Schedules
2142
+ * Returns schedule definitions across the workspace, newest first by default.
2143
+ */
2144
+ const listSchedules = (options) => {
2145
+ return (options?.client ?? client_gen_1.client).get({
2146
+ security: [
2147
+ {
2148
+ scheme: 'bearer',
2149
+ type: 'http'
2150
+ }
2151
+ ],
2152
+ url: '/schedules',
2153
+ ...options
2154
+ });
2155
+ };
2156
+ exports.listSchedules = listSchedules;
2157
+ /**
2158
+ * Get Sandbox Scheduling Metrics
2159
+ * Returns active sandbox and scheduling metrics for a UTC minute window. since is inclusive and until is exclusive. The default window is the last 24 hours and the maximum is 7 days. Execution status describes submission acceptance, not process completion. Execution totals begin accumulating when metrics collection is enabled and can lag recent firings briefly.
2160
+ */
2161
+ const getSandboxScheduleMetrics = (options) => {
2162
+ return (options?.client ?? client_gen_1.client).get({
2163
+ security: [
2164
+ {
2165
+ scheme: 'bearer',
2166
+ type: 'http'
2167
+ }
2168
+ ],
2169
+ url: '/schedules/metrics',
2170
+ ...options
2171
+ });
2172
+ };
2173
+ exports.getSandboxScheduleMetrics = getSandboxScheduleMetrics;
2072
2174
  /**
2073
2175
  * List service accounts
2074
2176
  * Returns all service accounts in the workspace. Service accounts are machine identities for external systems to authenticate with Blaxel via OAuth or API keys.
@@ -30,8 +30,8 @@ function missingCredentialsMessage() {
30
30
  return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
31
31
  }
32
32
  // Build info - these placeholders are replaced at build time by build:replace-imports
33
- const BUILD_VERSION = "0.3.13";
34
- const BUILD_COMMIT = "615fb5551f22447c530bcaef346d68dc79590758";
33
+ const BUILD_VERSION = "0.3.18-dev.283";
34
+ const BUILD_COMMIT = "fed607fe45d4a15407e77f7c40f31815d763df5a";
35
35
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
36
36
  const BLAXEL_API_VERSION = "2026-04-28";
37
37
  // Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
@@ -100,6 +100,11 @@ function getOsArch() {
100
100
  }
101
101
  return "browser/unknown";
102
102
  }
103
+ /**
104
+ * An integration built on this SDK identifies itself with one User-Agent
105
+ * product token: `<name>/<semver>`, lowercase name.
106
+ */
107
+ const INTEGRATION_TOKEN_PATTERN = /^[a-z0-9][a-z0-9._-]*\/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
103
108
  class Settings {
104
109
  _credentials;
105
110
  config;
@@ -196,14 +201,34 @@ class Settings {
196
201
  get apiVersion() {
197
202
  return env_js_1.env.BL_API_VERSION || BLAXEL_API_VERSION;
198
203
  }
204
+ /**
205
+ * Product token identifying the integration built on this SDK, or `""`.
206
+ * Resolved from `config.integration`, then `BL_INTEGRATION`. See `Config.integration`.
207
+ */
208
+ get integration() {
209
+ const value = this.config.integration ?? env_js_1.env.BL_INTEGRATION ?? "";
210
+ if (!value) {
211
+ return "";
212
+ }
213
+ if (!INTEGRATION_TOKEN_PATTERN.test(value)) {
214
+ logger_js_1.logger.warn(`Ignoring invalid Blaxel integration token "${value}": expected <name>/<semver> with a lowercase name`);
215
+ return "";
216
+ }
217
+ return value;
218
+ }
219
+ set integration(value) {
220
+ this.config.integration = value;
221
+ }
199
222
  get headers() {
200
223
  this.assertCredentials();
201
224
  const osArch = getOsArch();
225
+ const sdkUserAgent = `blaxel/sdk/typescript/${this.version} (${osArch}) blaxel/${this.commit}`;
226
+ const integration = this.integration;
202
227
  return {
203
228
  "x-blaxel-authorization": this.authorization,
204
229
  "x-blaxel-workspace": this.workspace || "",
205
230
  "Blaxel-Version": this.apiVersion,
206
- "User-Agent": `blaxel/sdk/typescript/${this.version} (${osArch}) blaxel/${this.commit}`,
231
+ "User-Agent": integration ? `${sdkUserAgent} ${integration}` : sdkUserAgent,
207
232
  };
208
233
  }
209
234
  get name() {
@@ -251,3 +251,53 @@ const h2_runtime_js_1 = require("./h2-runtime.js");
251
251
  });
252
252
  });
253
253
  });
254
+ (0, vitest_1.describe)('Settings.integration', () => {
255
+ const sdkUserAgent = /^blaxel\/sdk\/typescript\/\S+ \([^)]+\) blaxel\/\S+/;
256
+ async function authenticated() {
257
+ const { settings } = await Promise.resolve().then(() => __importStar(require('./settings.js')));
258
+ settings.credentials = new apikey_js_1.ApiKey({ apiKey: 'test-key', workspace: 'test-ws' });
259
+ delete settings.config.integration;
260
+ delete process.env.BL_INTEGRATION;
261
+ return settings;
262
+ }
263
+ (0, vitest_1.afterEach)(async () => {
264
+ const { settings } = await Promise.resolve().then(() => __importStar(require('./settings.js')));
265
+ delete settings.config.integration;
266
+ delete process.env.BL_INTEGRATION;
267
+ });
268
+ (0, vitest_1.it)('sends the plain SDK User-Agent by default', async () => {
269
+ const settings = await authenticated();
270
+ (0, vitest_1.expect)(settings.integration).toBe('');
271
+ (0, vitest_1.expect)(settings.headers['User-Agent']).toMatch(new RegExp(sdkUserAgent.source + '$'));
272
+ });
273
+ (0, vitest_1.it)('appends the token set programmatically, once', async () => {
274
+ const settings = await authenticated();
275
+ settings.integration = 'deepseek-harness-blaxel-sandbox/0.1.2';
276
+ const userAgent = settings.headers['User-Agent'];
277
+ (0, vitest_1.expect)(userAgent).toMatch(sdkUserAgent);
278
+ (0, vitest_1.expect)(userAgent.endsWith(' deepseek-harness-blaxel-sandbox/0.1.2')).toBe(true);
279
+ (0, vitest_1.expect)(userAgent.split('deepseek-harness-blaxel-sandbox').length).toBe(2);
280
+ });
281
+ (0, vitest_1.it)('reads BL_INTEGRATION when nothing is set programmatically, and config wins', async () => {
282
+ const settings = await authenticated();
283
+ process.env.BL_INTEGRATION = 'herdr/1.4.0-rc.1';
284
+ (0, vitest_1.expect)(settings.headers['User-Agent'].endsWith(' herdr/1.4.0-rc.1')).toBe(true);
285
+ settings.setConfig({ ...settings.config, integration: 'deepseek-harness-blaxel-sandbox/0.1.2' });
286
+ (0, vitest_1.expect)(settings.headers['User-Agent'].endsWith(' deepseek-harness-blaxel-sandbox/0.1.2')).toBe(true);
287
+ });
288
+ vitest_1.it.each(['Deepseek/0.1.2', 'deepseek-harness', 'deepseek/0.1', 'a/1.0.0 b/1.0.0', 'deepseek/0.1.2 (extra)', ' x/1.0.0'])('ignores the invalid token %j', async (value) => {
289
+ const settings = await authenticated();
290
+ settings.integration = value;
291
+ (0, vitest_1.expect)(settings.integration).toBe('');
292
+ (0, vitest_1.expect)(settings.headers['User-Agent']).toMatch(new RegExp(sdkUserAgent.source + '$'));
293
+ });
294
+ (0, vitest_1.it)('changes no header other than User-Agent', async () => {
295
+ const settings = await authenticated();
296
+ const plain = { ...settings.headers };
297
+ settings.integration = 'deepseek-harness-blaxel-sandbox/0.1.2';
298
+ const tagged = { ...settings.headers };
299
+ delete plain['User-Agent'];
300
+ delete tagged['User-Agent'];
301
+ (0, vitest_1.expect)(tagged).toEqual(plain);
302
+ });
303
+ });
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shellQuote = shellQuote;
4
+ /**
5
+ * Quote a string so a POSIX shell reads it as one literal argument.
6
+ *
7
+ * The sandbox process API accepts a single command string, which the server
8
+ * runs as `sh -c <command>`. Any value interpolated into that string is parsed
9
+ * by the shell, so an unquoted path containing `;`, `|`, `&&`, `$()`, backticks
10
+ * or `>` would execute as its own command. Single quotes suppress every form of
11
+ * shell expansion; the only character they cannot contain is a single quote
12
+ * itself, which is closed, escaped, and reopened.
13
+ *
14
+ * This is the TypeScript equivalent of Python's `shlex.quote`.
15
+ */
16
+ function shellQuote(value) {
17
+ return `'${value.replace(/'/g, `'\\''`)}'`;
18
+ }
@@ -6,12 +6,13 @@ const node_js_1 = require("../../common/node.js");
6
6
  const settings_js_1 = require("../../common/settings.js");
7
7
  const h2fetch_js_1 = require("../../common/h2fetch.js");
8
8
  const transient_retry_js_1 = require("../../common/transient-retry.js");
9
+ const shell_js_1 = require("../../common/shell.js");
9
10
  const action_js_1 = require("../action.js");
10
11
  const index_js_1 = require("../client/index.js");
11
12
  // Multipart upload constants
12
13
  const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB
13
14
  const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per part
14
- const MAX_PARALLEL_UPLOADS = 3; // Number of parallel part uploads
15
+ const MAX_PARALLEL_UPLOADS = 3; // Maximum workers when the H2 upload cap is disabled
15
16
  // The transient-reset classifier and retry loop live in
16
17
  // common/transient-retry.ts, shared with the idempotent sandbox-op retry
17
18
  // (read/list/etc.) so every path judges "transient" identically. These aliases
@@ -347,8 +348,10 @@ class SandboxFileSystem extends action_js_1.SandboxAction {
347
348
  });
348
349
  }
349
350
  async cp(source, destination, { maxWait = 180000 } = {}) {
351
+ // Quote both paths so the shell that runs this command treats them as
352
+ // single literal arguments instead of interpreting metacharacters in them.
350
353
  let process = await this.process.exec({
351
- command: `cp -r ${source} ${destination}`,
354
+ command: `cp -r ${(0, shell_js_1.shellQuote)(source)} ${(0, shell_js_1.shellQuote)(destination)}`,
352
355
  });
353
356
  process = await this.process.wait(process.pid, { maxWait, interval: 100 });
354
357
  if (process.status === "failed") {
@@ -502,11 +505,19 @@ class SandboxFileSystem extends action_js_1.SandboxAction {
502
505
  // no h2Domain the parts go over globalThis.fetch on separate connections,
503
506
  // so the shared-connection cap does not apply.
504
507
  const h2Domain = this.sandbox?.h2Domain;
505
- // Upload parts in batches for parallel processing
506
- for (let i = 0; i < numParts; i += MAX_PARALLEL_UPLOADS) {
507
- const batch = [];
508
- for (let j = 0; j < MAX_PARALLEL_UPLOADS && i + j < numParts; j++) {
509
- const partNumber = i + j + 1;
508
+ // Keep a bounded worker queue full instead of waiting at fixed batch
509
+ // boundaries. The upload gate remains authoritative across files; its
510
+ // default of 2 also bounds this queue on H2.
511
+ const h2UploadLimit = h2Domain ? settings_js_1.settings.maxConcurrentUploadH2Requests : 0;
512
+ const workerCount = Math.min(numParts, h2UploadLimit > 0 ? Math.min(MAX_PARALLEL_UPLOADS, h2UploadLimit) : MAX_PARALLEL_UPLOADS);
513
+ let nextPartNumber = 1;
514
+ let stopped = false;
515
+ let uploadError;
516
+ const uploadNextPart = async () => {
517
+ while (!stopped) {
518
+ const partNumber = nextPartNumber++;
519
+ if (partNumber > numParts)
520
+ return;
510
521
  const start = (partNumber - 1) * CHUNK_SIZE;
511
522
  const end = Math.min(start + CHUNK_SIZE, size);
512
523
  const chunk = blob.slice(start, end);
@@ -514,11 +525,20 @@ class SandboxFileSystem extends action_js_1.SandboxAction {
514
525
  // concurrent part streams, not retry sequences; freed during backoff.
515
526
  const doPart = () => this.uploadPart(uploadId, partNumber, chunk);
516
527
  const partWithSlot = h2Domain ? () => (0, h2fetch_js_1.withUploadSlot)(h2Domain, doPart) : doPart;
517
- batch.push(retryOnTransient(partWithSlot));
528
+ try {
529
+ parts.push(await retryOnTransient(partWithSlot));
530
+ }
531
+ catch (error) {
532
+ if (!stopped) {
533
+ stopped = true;
534
+ uploadError = error;
535
+ }
536
+ }
518
537
  }
519
- // Wait for batch to complete
520
- const batchResults = await Promise.all(batch);
521
- parts.push(...batchResults);
538
+ };
539
+ await Promise.all(Array.from({ length: workerCount }, () => uploadNextPart()));
540
+ if (stopped) {
541
+ throw uploadError;
522
542
  }
523
543
  // Sort parts by partNumber to ensure correct order
524
544
  parts.sort((a, b) => (a.partNumber ?? 0) - (b.partNumber ?? 0));
@@ -1,11 +1,63 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const vitest_1 = require("vitest");
4
+ const settings_js_1 = require("../../common/settings.js");
4
5
  const filesystem_js_1 = require("./filesystem.js");
5
6
  const waitForPartUpload = () => new Promise((resolve) => setTimeout(resolve, 5));
7
+ function deferred() {
8
+ let resolve;
9
+ const promise = new Promise((resolvePromise) => {
10
+ resolve = resolvePromise;
11
+ });
12
+ return { promise, resolve };
13
+ }
6
14
  (0, vitest_1.describe)("SandboxFileSystem multipart upload", () => {
7
- (0, vitest_1.it)("limits concurrent part uploads", async () => {
15
+ (0, vitest_1.afterEach)(() => {
16
+ delete settings_js_1.settings.config.maxConcurrentUploadH2Requests;
17
+ delete settings_js_1.settings.config.fsPartRetries;
18
+ vitest_1.vi.useRealTimers();
19
+ vitest_1.vi.restoreAllMocks();
20
+ });
21
+ (0, vitest_1.it)("keeps an H2 upload slot busy across former batch boundaries for 100 MiB", async () => {
22
+ settings_js_1.settings.config.maxConcurrentUploadH2Requests = 2;
23
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
24
+ filesystem.sandbox = { h2Domain: "eng-3585.test" };
25
+ const started = [deferred(), deferred(), deferred(), deferred()];
26
+ const release = [deferred(), deferred(), deferred(), deferred()];
27
+ let completedParts = [];
28
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-1" });
29
+ filesystem.uploadPart = async (_uploadId, partNumber) => {
30
+ if (partNumber <= 4) {
31
+ started[partNumber - 1].resolve();
32
+ await release[partNumber - 1].promise;
33
+ }
34
+ return { partNumber, etag: `etag-${partNumber}` };
35
+ };
36
+ filesystem.completeMultipartUpload = (_uploadId, parts) => {
37
+ completedParts = parts;
38
+ return Promise.resolve({ message: "ok" });
39
+ };
40
+ filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
41
+ const upload = filesystem.uploadWithMultipart("/tmp/large-file.bin", { size: 100 * 1024 * 1024, slice: () => ({}) });
42
+ await Promise.all([started[0].promise, started[1].promise]);
43
+ release[0].resolve();
44
+ await started[2].promise;
45
+ release[2].resolve();
46
+ const crossedBatchBoundary = await Promise.race([
47
+ started[3].promise.then(() => true),
48
+ new Promise((resolve) => setTimeout(() => resolve(false), 25)),
49
+ ]);
50
+ release[1].resolve();
51
+ await started[3].promise;
52
+ release[3].resolve();
53
+ await upload;
54
+ (0, vitest_1.expect)(crossedBatchBoundary).toBe(true);
55
+ (0, vitest_1.expect)(completedParts).toHaveLength(20);
56
+ });
57
+ (0, vitest_1.it)("limits concurrent H2 part uploads to the existing cap", async () => {
58
+ settings_js_1.settings.config.maxConcurrentUploadH2Requests = 2;
8
59
  const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
60
+ filesystem.sandbox = { h2Domain: "concurrency.test" };
9
61
  let inFlight = 0;
10
62
  let maxInFlight = 0;
11
63
  const uploadedParts = [];
@@ -26,8 +78,161 @@ const waitForPartUpload = () => new Promise((resolve) => setTimeout(resolve, 5))
26
78
  filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
27
79
  const blob = new Blob([new Uint8Array(16 * 1024 * 1024)]);
28
80
  await filesystem.uploadWithMultipart("/tmp/large-file.bin", blob);
29
- (0, vitest_1.expect)(maxInFlight).toBeLessThanOrEqual(3);
81
+ (0, vitest_1.expect)(maxInFlight).toBe(2);
30
82
  (0, vitest_1.expect)(uploadedParts.sort((a, b) => a - b)).toEqual([1, 2, 3, 4]);
31
83
  (0, vitest_1.expect)(completedParts.map((part) => part.partNumber)).toEqual([1, 2, 3, 4]);
32
84
  });
85
+ (0, vitest_1.it)("uploads every byte exactly once across chunk boundaries", async () => {
86
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
87
+ const partSize = 5 * 1024 * 1024;
88
+ const source = new Uint8Array(partSize + 17);
89
+ for (let index = 0; index < source.length; index++) {
90
+ source[index] = index % 251;
91
+ }
92
+ const uploadedChunks = new Map();
93
+ let completedParts = [];
94
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-bytes" });
95
+ filesystem.uploadPart = async (_uploadId, partNumber, fileBlob) => {
96
+ uploadedChunks.set(partNumber, new Uint8Array(await fileBlob.arrayBuffer()));
97
+ return { partNumber, etag: `etag-${partNumber}` };
98
+ };
99
+ filesystem.completeMultipartUpload = (_uploadId, parts) => {
100
+ completedParts = parts;
101
+ return Promise.resolve({ message: "ok" });
102
+ };
103
+ filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
104
+ await filesystem.uploadWithMultipart("/tmp/bytes.bin", new Blob([source]));
105
+ const received = new Uint8Array(source.length);
106
+ let offset = 0;
107
+ for (const partNumber of [1, 2]) {
108
+ const chunk = uploadedChunks.get(partNumber);
109
+ (0, vitest_1.expect)(chunk).toBeDefined();
110
+ received.set(chunk, offset);
111
+ offset += chunk.length;
112
+ }
113
+ let mismatch = -1;
114
+ for (let index = 0; index < source.length; index++) {
115
+ if (received[index] !== source[index]) {
116
+ mismatch = index;
117
+ break;
118
+ }
119
+ }
120
+ (0, vitest_1.expect)([...uploadedChunks.values()].map((chunk) => chunk.length)).toEqual([partSize, 17]);
121
+ (0, vitest_1.expect)(offset).toBe(source.length);
122
+ (0, vitest_1.expect)(mismatch).toBe(-1);
123
+ (0, vitest_1.expect)(completedParts.map((part) => part.partNumber)).toEqual([1, 2]);
124
+ });
125
+ (0, vitest_1.it)("retries a transient part failure without duplicating completed parts", async () => {
126
+ vitest_1.vi.useFakeTimers();
127
+ vitest_1.vi.spyOn(Math, "random").mockReturnValue(0);
128
+ settings_js_1.settings.config.fsPartRetries = 1;
129
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
130
+ const attempts = new Map();
131
+ let completedParts = [];
132
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-retry" });
133
+ filesystem.uploadPart = (_uploadId, partNumber) => {
134
+ const attempt = (attempts.get(partNumber) ?? 0) + 1;
135
+ attempts.set(partNumber, attempt);
136
+ if (partNumber === 1 && attempt === 1) {
137
+ return Promise.reject(Object.assign(new Error("connection reset"), { code: "ECONNRESET" }));
138
+ }
139
+ return Promise.resolve({ partNumber, etag: `etag-${partNumber}` });
140
+ };
141
+ filesystem.completeMultipartUpload = (_uploadId, parts) => {
142
+ completedParts = parts;
143
+ return Promise.resolve({ message: "ok" });
144
+ };
145
+ filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
146
+ const upload = filesystem.uploadWithMultipart("/tmp/retry.bin", new Blob([new Uint8Array(6 * 1024 * 1024)]));
147
+ await vitest_1.vi.runAllTimersAsync();
148
+ await upload;
149
+ (0, vitest_1.expect)(Object.fromEntries(attempts)).toEqual({ 1: 2, 2: 1 });
150
+ (0, vitest_1.expect)(completedParts.map((part) => part.partNumber)).toEqual([1, 2]);
151
+ });
152
+ (0, vitest_1.it)("stops assigning queued parts after a part failure", async () => {
153
+ settings_js_1.settings.config.maxConcurrentUploadH2Requests = 2;
154
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
155
+ filesystem.sandbox = { h2Domain: "cancellation.test" };
156
+ const releaseFirstPart = deferred();
157
+ const secondPartStarted = deferred();
158
+ const failure = new Error("part 2 failed");
159
+ const startedParts = [];
160
+ const events = [];
161
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-cancel" });
162
+ filesystem.uploadPart = async (_uploadId, partNumber) => {
163
+ startedParts.push(partNumber);
164
+ if (partNumber === 1) {
165
+ await releaseFirstPart.promise;
166
+ events.push("part-1-finished");
167
+ return { partNumber, etag: "etag-1" };
168
+ }
169
+ secondPartStarted.resolve();
170
+ events.push("part-2-failed");
171
+ throw failure;
172
+ };
173
+ filesystem.completeMultipartUpload = () => {
174
+ throw new Error("completion must not run");
175
+ };
176
+ filesystem.abortMultipartUpload = () => {
177
+ events.push("aborted");
178
+ return Promise.resolve({ message: "aborted" });
179
+ };
180
+ const upload = filesystem.uploadWithMultipart("/tmp/cancel.bin", new Blob([new Uint8Array(16 * 1024 * 1024)]));
181
+ await secondPartStarted.promise;
182
+ await new Promise((resolve) => setTimeout(resolve, 0));
183
+ releaseFirstPart.resolve();
184
+ await (0, vitest_1.expect)(upload).rejects.toBe(failure);
185
+ (0, vitest_1.expect)(startedParts).toEqual([1, 2]);
186
+ (0, vitest_1.expect)(events).toEqual(["part-2-failed", "part-1-finished", "aborted"]);
187
+ });
188
+ (0, vitest_1.it)("keeps sliced part data bounded by the active worker count", async () => {
189
+ settings_js_1.settings.config.maxConcurrentUploadH2Requests = 2;
190
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
191
+ filesystem.sandbox = { h2Domain: "memory.test" };
192
+ let unsettledSlices = 0;
193
+ let maxUnsettledSlices = 0;
194
+ let totalSlices = 0;
195
+ const fakeBlob = {
196
+ size: 100 * 1024 * 1024,
197
+ slice(start, end) {
198
+ totalSlices += 1;
199
+ unsettledSlices += 1;
200
+ maxUnsettledSlices = Math.max(maxUnsettledSlices, unsettledSlices);
201
+ return { size: end - start };
202
+ },
203
+ };
204
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-memory" });
205
+ filesystem.uploadPart = async (_uploadId, partNumber) => {
206
+ await waitForPartUpload();
207
+ unsettledSlices -= 1;
208
+ return { partNumber, etag: `etag-${partNumber}` };
209
+ };
210
+ filesystem.completeMultipartUpload = () => Promise.resolve({ message: "ok" });
211
+ filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
212
+ await filesystem.uploadWithMultipart("/tmp/100mb.bin", fakeBlob);
213
+ (0, vitest_1.expect)(totalSlices).toBe(20);
214
+ (0, vitest_1.expect)(maxUnsettledSlices).toBe(2);
215
+ (0, vitest_1.expect)(unsettledSlices).toBe(0);
216
+ });
217
+ (0, vitest_1.it)("aborts an abandoned multipart upload and preserves the part failure", async () => {
218
+ settings_js_1.settings.config.maxConcurrentUploadH2Requests = 1;
219
+ const filesystem = Object.create(filesystem_js_1.SandboxFileSystem.prototype);
220
+ filesystem.sandbox = { h2Domain: "cleanup.test" };
221
+ const partFailure = new Error("part failed");
222
+ const abortFailure = new Error("abort failed");
223
+ const aborts = [];
224
+ const consoleError = vitest_1.vi.spyOn(console, "error").mockImplementation(() => undefined);
225
+ filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-cleanup" });
226
+ filesystem.uploadPart = () => Promise.reject(partFailure);
227
+ filesystem.completeMultipartUpload = () => {
228
+ throw new Error("completion must not run");
229
+ };
230
+ filesystem.abortMultipartUpload = (uploadId) => {
231
+ aborts.push(uploadId);
232
+ return Promise.reject(abortFailure);
233
+ };
234
+ await (0, vitest_1.expect)(filesystem.uploadWithMultipart("/tmp/cleanup.bin", new Blob([new Uint8Array(6 * 1024 * 1024)]))).rejects.toBe(partFailure);
235
+ (0, vitest_1.expect)(aborts).toEqual(["upload-cleanup"]);
236
+ (0, vitest_1.expect)(consoleError).toHaveBeenCalledWith("Failed to abort multipart upload:", abortFailure);
237
+ });
33
238
  });