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.
|
|
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(
|
|
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
|
+
});
|