amalgm 0.1.90 → 0.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -34,6 +34,7 @@
34
34
  "ai": "^6.0.116",
35
35
  "better-sqlite3": "^12.10.0",
36
36
  "cron-parser": "^5.5.0",
37
+ "ffmpeg-static": "^5.3.0",
37
38
  "opencode-ai": "^1.14.35",
38
39
  "tsx": "^4.21.0",
39
40
  "ws": "^8.18.3",
@@ -28,6 +28,7 @@ const engine = require('./engine');
28
28
  const active = new Map(); // session -> { ws, ffmpeg, file, frames, startedAt }
29
29
 
30
30
  function ffmpegBinary() {
31
+ if (process.env.AMALGM_FFMPEG) return process.env.AMALGM_FFMPEG;
31
32
  try {
32
33
  const resolved = require('ffmpeg-static');
33
34
  if (resolved && fs.existsSync(resolved)) return resolved;
@@ -35,6 +36,32 @@ function ffmpegBinary() {
35
36
  return 'ffmpeg';
36
37
  }
37
38
 
39
+ let verifiedBinary = null;
40
+
41
+ /**
42
+ * Resolve and verify ffmpeg before any recording work starts. ffmpeg-static
43
+ * ships as a dependency of the published package; AMALGM_FFMPEG overrides
44
+ * it; bare PATH ffmpeg is the last resort for checkouts without either. An
45
+ * unrunnable encoder must surface as a normal tool error — never as an
46
+ * unhandled ChildProcess 'error' event, which would take down the whole MCP
47
+ * server and every session's tools with it.
48
+ */
49
+ function ensureFfmpeg() {
50
+ const binary = ffmpegBinary();
51
+ if (binary === verifiedBinary) return binary;
52
+ const probe = require('child_process').spawnSync(binary, ['-version'], { stdio: 'ignore', timeout: 10_000 });
53
+ if (probe.error || probe.status !== 0) {
54
+ const detail = probe.error ? (probe.error.code || probe.error.message) : `exit ${probe.status}`;
55
+ throw new Error(
56
+ `Recording requires ffmpeg, and "${binary}" is not runnable (${detail}). `
57
+ + 'Reinstall the amalgm package (it bundles ffmpeg-static), set AMALGM_FFMPEG, '
58
+ + 'or install ffmpeg on PATH.',
59
+ );
60
+ }
61
+ verifiedBinary = binary;
62
+ return binary;
63
+ }
64
+
38
65
  function resolveProjectPath(args = {}, context = {}) {
39
66
  const candidate = args.projectPath
40
67
  || context?.sessionMetadata?.projectPath
@@ -93,6 +120,10 @@ async function start(args = {}, context = {}) {
93
120
  throw new Error(`Recording already active for session "${session}". Call record_stop first.`);
94
121
  }
95
122
 
123
+ // Verify the encoder before touching the browser: cheapest check first,
124
+ // and a clean refusal beats a half-started recording.
125
+ const binary = ensureFfmpeg();
126
+
96
127
  const rawUrl = await engine.cdpUrlForSession(session);
97
128
  if (!rawUrl) throw new Error('Could not resolve a CDP endpoint for this browser session.');
98
129
  const wsUrl = await pageWsUrl(rawUrl);
@@ -101,7 +132,7 @@ async function start(args = {}, context = {}) {
101
132
  const file = recordingFile(args, context, session);
102
133
  const fps = Math.min(Math.max(Number(args.fps) || 10, 1), 30);
103
134
 
104
- const ffmpeg = spawn(ffmpegBinary(), [
135
+ const ffmpeg = spawn(binary, [
105
136
  '-y', '-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(fps), '-i', 'pipe:0',
106
137
  '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-c:v', 'libvpx', '-pix_fmt', 'yuv420p', file,
107
138
  ], { stdio: ['pipe', 'ignore', 'pipe'] });
@@ -110,6 +141,18 @@ async function start(args = {}, context = {}) {
110
141
 
111
142
  const ws = new WebSocket(wsUrl, { perMessageDeflate: false });
112
143
  const recording = { ws, ffmpeg, file, frames: 0, startedAt: Date.now(), ffmpegErr: () => ffmpegErr };
144
+
145
+ // Failures after the preflight (binary swapped out, EMFILE, ffmpeg dying
146
+ // mid-recording and EPIPE-ing stdin) end this recording, not the server.
147
+ ffmpeg.on('error', (err) => {
148
+ ffmpegErr = `${err.message}${ffmpegErr ? ` ${ffmpegErr}` : ''}`;
149
+ if (active.get(session) === recording) active.delete(session);
150
+ try { ws.close(); } catch {}
151
+ });
152
+ ffmpeg.stdin.on('error', () => {
153
+ // Swallow stream errors; stop() reports the failure via exit code + stderr.
154
+ });
155
+
113
156
  active.set(session, recording);
114
157
 
115
158
  await new Promise((resolve, reject) => {
@@ -24,6 +24,18 @@
24
24
  * control-plane, MCP adapter, and worker
25
25
  */
26
26
 
27
+ // A tool host must degrade, not die. Without these guards, one buggy handler
28
+ // or a dependency emitting an unguarded 'error' (e.g. a ChildProcess spawn
29
+ // failure) tears down every session's tools at once — the exact blast radius
30
+ // a multi-session server exists to avoid. Log loudly and keep serving; the
31
+ // supervisor still restarts genuinely wedged processes.
32
+ process.on('uncaughtException', (err) => {
33
+ console.error('[AmalgmMCP] Uncaught exception (continuing):', err && err.stack ? err.stack : err);
34
+ });
35
+ process.on('unhandledRejection', (reason) => {
36
+ console.error('[AmalgmMCP] Unhandled rejection (continuing):', reason && reason.stack ? reason.stack : reason);
37
+ });
38
+
27
39
  async function ensureProxyToken() {
28
40
  if (process.env.AMALGM_PROXY_TOKEN) return;
29
41
  const { DEFAULT_PROXY_BASE_URL, ensureFreshProxyToken } = require('../proxy-token-store');
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { test } = require('node:test');
7
+
8
+ // Regression coverage for the 2026-06-10 incident: record_start with no
9
+ // runnable ffmpeg crashed the whole MCP server via an unhandled ChildProcess
10
+ // 'error' event (spawn ffmpeg ENOENT), deregistering every session's tools.
11
+ // A missing encoder must be a refused tool call, nothing more.
12
+
13
+ test('record_start rejects cleanly when ffmpeg is not runnable', async () => {
14
+ process.env.AMALGM_FFMPEG = path.join(__dirname, 'definitely-not-ffmpeg');
15
+ try {
16
+ const recorder = require('../browser/recorder');
17
+ await assert.rejects(
18
+ () => recorder.start({}, {}),
19
+ /Recording requires ffmpeg/,
20
+ 'missing ffmpeg should surface as a tool error before any browser work',
21
+ );
22
+ } finally {
23
+ delete process.env.AMALGM_FFMPEG;
24
+ }
25
+ });
26
+
27
+ test('ffmpeg ships with the published package', (t) => {
28
+ // Resolves to <engine-root>/packages/amalgm in the engine repo; the
29
+ // amalgm-ui mirror has no packages/ tree, so the check skips there.
30
+ const pkgPath = path.join(__dirname, '..', '..', '..', '..', 'packages', 'amalgm', 'package.json');
31
+ if (!fs.existsSync(pkgPath)) {
32
+ t.skip('engine-only check');
33
+ return;
34
+ }
35
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
36
+ assert.ok(
37
+ pkg.dependencies && pkg.dependencies['ffmpeg-static'],
38
+ 'packages/amalgm must depend on ffmpeg-static — PATH ffmpeg does not exist on fresh machines',
39
+ );
40
+ });