amalgm 0.1.128 → 0.1.129

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/lib/updater.js CHANGED
@@ -84,13 +84,19 @@ function npmEnv(env = process.env) {
84
84
  };
85
85
  }
86
86
 
87
+ function npmSpawnOptions(options = {}) {
88
+ return {
89
+ env: npmEnv(options.env),
90
+ windowsHide: true,
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ // Node rejects direct .cmd spawning on Windows, so npm.cmd must run via cmd.exe there.
93
+ shell: process.platform === 'win32',
94
+ };
95
+ }
96
+
87
97
  function runNpm(args, options = {}) {
88
98
  return new Promise((resolve, reject) => {
89
- const child = spawn(npmCommand(), args, {
90
- env: npmEnv(options.env),
91
- windowsHide: true,
92
- stdio: ['ignore', 'pipe', 'pipe'],
93
- });
99
+ const child = spawn(npmCommand(), args, npmSpawnOptions(options));
94
100
  let stdout = '';
95
101
  let stderr = '';
96
102
  let timedOut = false;
@@ -202,6 +208,11 @@ function shouldAutoUpdate(options = {}) {
202
208
  }
203
209
 
204
210
  module.exports = {
211
+ _test: {
212
+ npmCommand,
213
+ npmEnv,
214
+ npmSpawnOptions,
215
+ },
205
216
  checkForUpdate,
206
217
  compareVersions,
207
218
  envDisablesAutoUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.128",
3
+ "version": "0.1.129",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -383,14 +383,12 @@ function collectToolRecords(toolIds, requires) {
383
383
 
384
384
  if (tool.origin === 'system') {
385
385
  addUnique(requires.systemTools, tool.id);
386
- continue;
387
386
  }
388
387
 
389
388
  if (!tools.has(tool.id)) tools.set(tool.id, portableTool(tool, requires));
390
389
 
391
390
  if (selectedAction) {
392
391
  actions.set(selectedAction.id, portableAction(selectedAction, requires));
393
- continue;
394
392
  }
395
393
 
396
394
  for (const action of Object.values(catalog.toolActions || {})) {
@@ -64,6 +64,8 @@ function ffmpegBinary() {
64
64
  if (process.env.AMALGM_FFMPEG) return process.env.AMALGM_FFMPEG;
65
65
  try {
66
66
  const resolved = require('ffmpeg-static');
67
+ const unpacked = asarUnpackedPath(resolved);
68
+ if (unpacked && fs.existsSync(unpacked)) return unpacked;
67
69
  if (resolved && fs.existsSync(resolved)) return resolved;
68
70
  } catch {}
69
71
  return 'ffmpeg';
@@ -90,7 +92,17 @@ function nativeAgentBrowser(wrapperPath) {
90
92
  && (fs.existsSync('/lib/ld-musl-x86_64.so.1') || fs.existsSync('/lib/ld-musl-aarch64.so.1'));
91
93
  const name = `agent-browser-${platform}${musl ? '-musl' : ''}-${arch}${platform === 'win32' ? '.exe' : ''}`;
92
94
  const candidate = path.join(path.dirname(wrapperPath), name);
93
- return fs.existsSync(candidate) ? candidate : null;
95
+ if (fs.existsSync(candidate)) return candidate;
96
+ const unpacked = asarUnpackedPath(candidate);
97
+ return unpacked && fs.existsSync(unpacked) ? unpacked : null;
98
+ }
99
+
100
+ function asarUnpackedPath(candidate) {
101
+ if (!candidate) return null;
102
+ const marker = `${path.sep}app.asar${path.sep}`;
103
+ const index = String(candidate).indexOf(marker);
104
+ if (index === -1) return null;
105
+ return `${String(candidate).slice(0, index)}${path.sep}app.asar.unpacked${path.sep}${String(candidate).slice(index + marker.length)}`;
94
106
  }
95
107
 
96
108
  function agentBrowserCommand() {
@@ -238,11 +250,13 @@ module.exports = {
238
250
  DEFAULT_SESSION,
239
251
  agentBrowserCommand,
240
252
  amalgmDir,
253
+ asarUnpackedPath,
241
254
  asText,
242
255
  browserRoot,
243
256
  ensureDir,
244
257
  ffmpegBinary,
245
258
  ffmpegDir,
259
+ nativeAgentBrowser,
246
260
  profileDir,
247
261
  readText,
248
262
  runCli,
@@ -1069,6 +1069,27 @@ function uploadRequestWithRetry(params, attempts = Number(params.env?.AMALGM_DES
1069
1069
  throw lastError;
1070
1070
  }
1071
1071
 
1072
+ function parseUploadOffsetMismatch(error) {
1073
+ const message = cleanString(error?.message || error);
1074
+ const match = /Offset mismatch: expected (\d+), received (\d+)/i.exec(message);
1075
+ if (!match) return null;
1076
+ return {
1077
+ expected: Number(match[1]),
1078
+ received: Number(match[2]),
1079
+ };
1080
+ }
1081
+
1082
+ function recoverableUploadOffset(error, offset, bytesRead, size) {
1083
+ const mismatch = parseUploadOffsetMismatch(error);
1084
+ if (!mismatch) return null;
1085
+ if (!Number.isFinite(mismatch.expected) || !Number.isFinite(mismatch.received)) return null;
1086
+ if (mismatch.received !== offset) return null;
1087
+ if (mismatch.expected <= offset) return null;
1088
+ const maxExpected = Math.min(size, offset + bytesRead);
1089
+ if (mismatch.expected > maxExpected) return null;
1090
+ return mismatch.expected;
1091
+ }
1092
+
1072
1093
  function uploadHttpReleaseFile({ filePath, cwd, env, publishTarget }) {
1073
1094
  const token = cleanString(env.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN);
1074
1095
  if (!token) throw new Error('Missing AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN for desktop release HTTP upload.');
@@ -1102,15 +1123,22 @@ function uploadHttpReleaseFile({ filePath, cwd, env, publishTarget }) {
1102
1123
  fs.writeFileSync(chunkPath, buffer.subarray(0, bytesRead));
1103
1124
  const chunkUrl = new URL(uploadUrl);
1104
1125
  chunkUrl.searchParams.set('offset', String(offset));
1105
- uploadRequestWithRetry({
1106
- method: 'PUT',
1107
- url: chunkUrl.toString(),
1108
- token,
1109
- cwd,
1110
- env,
1111
- dataFile: chunkPath,
1112
- });
1113
- offset += bytesRead;
1126
+ try {
1127
+ uploadRequestWithRetry({
1128
+ method: 'PUT',
1129
+ url: chunkUrl.toString(),
1130
+ token,
1131
+ cwd,
1132
+ env,
1133
+ dataFile: chunkPath,
1134
+ });
1135
+ offset += bytesRead;
1136
+ } catch (error) {
1137
+ const resumedOffset = recoverableUploadOffset(error, offset, bytesRead, size);
1138
+ if (resumedOffset == null) throw error;
1139
+ console.warn(`desktop release upload resumed ${assetName} at server offset ${resumedOffset}.`);
1140
+ offset = resumedOffset;
1141
+ }
1114
1142
  }
1115
1143
 
1116
1144
  uploadRequestWithRetry({
@@ -1372,8 +1400,10 @@ module.exports = {
1372
1400
  desktopReleaseArtifactPaths,
1373
1401
  envForBuild,
1374
1402
  parseEnvFile,
1403
+ parseUploadOffsetMismatch,
1375
1404
  publishLegacyGithubBridgeRelease,
1376
1405
  publishDesktopRelease,
1406
+ recoverableUploadOffset,
1377
1407
  requestFromEnv,
1378
1408
  shouldHandleRequest,
1379
1409
  };
@@ -12,7 +12,7 @@ const { upsertAgentConfig, getAgentConfig } = require('../agent-config/store');
12
12
  const { defaultToolboxService } = require('../toolbox/service');
13
13
  const { createAgentBundle, installAgentBundle } = require('../agent-bundles/bundles');
14
14
 
15
- test('agent bundle includes recursive subagents, selected user tools, and warnings', () => {
15
+ test('agent bundle includes recursive subagents, selected user tools, system tools, and warnings', () => {
16
16
  defaultToolboxService.registerTool({
17
17
  id: 'acme.cli',
18
18
  name: 'Acme CLI',
@@ -81,8 +81,9 @@ test('agent bundle includes recursive subagents, selected user tools, and warnin
81
81
  assert.equal(bundle.kind, 'amalgm.agent.bundle');
82
82
  assert.equal(bundle.rootAgentId, root.id);
83
83
  assert.deepEqual(bundle.agents.map((entry) => entry.sourceAgentId), [root.id, child.id]);
84
- assert.deepEqual(bundle.tools.map((tool) => tool.id), ['acme.cli']);
85
- assert.deepEqual(bundle.toolActions.map((action) => action.id), ['acme.cli.run']);
84
+ assert.deepEqual(bundle.tools.map((tool) => tool.id), ['acme.cli', 'browser']);
85
+ assert.equal(bundle.toolActions.some((action) => action.id === 'acme.cli.run'), true);
86
+ assert.equal(bundle.toolActions.some((action) => action.toolId === 'browser'), true);
86
87
  assert.deepEqual(bundle.headIds, ['agent:bundle-root']);
87
88
  assert.equal(bundle.elements.some((element) => element.id === 'agent:bundle-root' && element.type === 'agent'), true);
88
89
  assert.equal(bundle.elements.some((element) => element.id === 'agent:bundle-child' && element.type === 'agent'), true);
@@ -110,10 +111,37 @@ test('agent bundle includes recursive subagents, selected user tools, and warnin
110
111
  assert.equal(bundle.requires.bindings.includes('tool:acme.cli:cwd'), true);
111
112
  assert.equal(bundle.requires.bindings.includes('tool:acme.cli:source.command'), true);
112
113
  assert.equal(preview.summary.agents, 2);
113
- assert.equal(preview.summary.tools, 1);
114
+ assert.equal(preview.summary.tools, 2);
115
+ assert.equal(preview.tools.some((tool) => tool.id === 'browser' && tool.actionCount > 0), true);
114
116
  assert.equal(preview.warnings.some((warning) => warning.includes('free-text')), true);
115
117
  });
116
118
 
119
+ test('agent bundle includes every tool action when the loadout references a single action id', () => {
120
+ const catalog = defaultToolboxService.readCatalog();
121
+ const browserActions = Object.values(catalog.toolActions || {}).filter((action) => action?.toolId === 'browser');
122
+ assert.ok(browserActions.length > 1);
123
+
124
+ const root = createAgent({
125
+ id: 'bundle-browser-action-root',
126
+ name: 'Bundle Browser Action Root',
127
+ baseHarnessId: 'codex',
128
+ baseModelId: 'openai/gpt-5.5',
129
+ systemPrompt: 'Browser action root',
130
+ loadout: { toolIds: [browserActions[0].id] },
131
+ authMethod: 'amalgm',
132
+ });
133
+ upsertAgentConfig(root.id, {
134
+ instructions: 'Browser action root',
135
+ loadout: { toolIds: [browserActions[0].id] },
136
+ });
137
+
138
+ const { bundle, preview } = createAgentBundle(root.id, { bundleId: 'agent-bundle-browser-action-test' });
139
+
140
+ assert.deepEqual(bundle.tools.map((tool) => tool.id), ['browser']);
141
+ assert.equal(bundle.toolActions.filter((action) => action.toolId === 'browser').length, browserActions.length);
142
+ assert.equal(preview.tools.some((tool) => tool.id === 'browser' && tool.actionCount === browserActions.length), true);
143
+ });
144
+
117
145
  test('installing an agent bundle creates local copies and rewrites subagent refs', () => {
118
146
  const source = createAgent({
119
147
  id: 'install-source-root',
@@ -64,6 +64,20 @@ test('agent-browser spawns the native binary directly when it exists', () => {
64
64
  }
65
65
  });
66
66
 
67
+ test('agent-browser native path resolves from packaged app.asar to app.asar.unpacked', () => {
68
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-asar-browser-test-'));
69
+ const wrapper = path.join(root, 'app.asar', 'node_modules', 'agent-browser', 'bin', 'agent-browser.js');
70
+ const native = path.join(root, 'app.asar.unpacked', 'node_modules', 'agent-browser', 'bin', `agent-browser-${os.platform()}-${os.arch()}`);
71
+ fs.mkdirSync(path.dirname(native), { recursive: true });
72
+ fs.writeFileSync(native, '');
73
+ try {
74
+ assert.equal(cli.asarUnpackedPath(wrapper), path.join(root, 'app.asar.unpacked', 'node_modules', 'agent-browser', 'bin', 'agent-browser.js'));
75
+ assert.equal(cli.nativeAgentBrowser(wrapper), native);
76
+ } finally {
77
+ fs.rmSync(root, { recursive: true, force: true });
78
+ }
79
+ });
80
+
67
81
  test('CLI browser sessions stay headless unless headed mode is explicit', async () => {
68
82
  await withBrowserEnv({}, () => {
69
83
  assert.equal(cli.wantsHeaded(), false);
@@ -22,7 +22,9 @@ const {
22
22
  desktopVersion,
23
23
  envForBuild,
24
24
  parseEnvFile,
25
+ parseUploadOffsetMismatch,
25
26
  publishDesktopRelease,
27
+ recoverableUploadOffset,
26
28
  requestFromEnv,
27
29
  shouldHandleRequest,
28
30
  } = require('../events/desktop-release-runner');
@@ -101,6 +103,35 @@ test('desktop versions stay compatible with existing release ordering', () => {
101
103
  assert.equal(desktopVersion('0.1.124-preview.1', 'preview', 45), '0.1.124000045-preview.1');
102
104
  });
103
105
 
106
+ test('desktop release upload parser extracts expected and received offsets', () => {
107
+ assert.deepEqual(
108
+ parseUploadOffsetMismatch(new Error('Desktop release upload PUT returned HTTP 409: {"ok":false,"error":"Offset mismatch: expected 352452608, received 352321536"}')),
109
+ { expected: 352452608, received: 352321536 },
110
+ );
111
+ assert.equal(parseUploadOffsetMismatch(new Error('something else')), null);
112
+ });
113
+
114
+ test('desktop release upload can resume from recoverable offset mismatches', () => {
115
+ const error = new Error('Desktop release upload PUT returned HTTP 409: {"ok":false,"error":"Offset mismatch: expected 352452608, received 352321536"}');
116
+ assert.equal(
117
+ recoverableUploadOffset(error, 352321536, 2097152, 369814043),
118
+ 352452608,
119
+ );
120
+ assert.equal(
121
+ recoverableUploadOffset(error, 352190464, 2097152, 369814043),
122
+ null,
123
+ );
124
+ assert.equal(
125
+ recoverableUploadOffset(
126
+ new Error('Desktop release upload PUT returned HTTP 409: {"ok":false,"error":"Offset mismatch: expected 354500000, received 352321536"}'),
127
+ 352321536,
128
+ 2097152,
129
+ 369814043,
130
+ ),
131
+ null,
132
+ );
133
+ });
134
+
104
135
  test('desktop release runner loads env-file fallbacks without overriding explicit env', () => {
105
136
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-env-test-'));
106
137
  const envFile = path.join(tempRoot, 'desktop-release.env');