@parall/daemon 1.35.0 → 1.36.0

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.
@@ -11,12 +11,40 @@ const BB_VIEWER_COMMAND_TIMEOUT_MS = 15_000;
11
11
  const BB_VIEWER_UNANSWERED_REAP_MS = 90_000;
12
12
  // Grace between SIGTERM and the SIGKILL escalation in killChild.
13
13
  const BB_VIEWER_TERM_GRACE_MS = 5_000;
14
+ /**
15
+ * bb-viewer's /command control plane is UNAUTHENTICATED, so on the shared agents
16
+ * cluster its bind host MUST be loopback — a 0.0.0.0 / pod-IP / typo'd value would
17
+ * silently re-open it cross-tenant. Only these exact loopback forms are accepted;
18
+ * anything else is rejected (fail fast) at spawn.
19
+ */
20
+ export function isLoopbackBindHost(host) {
21
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
22
+ }
23
+ /**
24
+ * Whether a kick targets the CURRENT live streamer (so its process is torn down). A
25
+ * kick with no explicit target hits whoever is live; an explicit target tears down the
26
+ * current streamer ONLY when it matches — a stale/replayed session_id must not kill a
27
+ * newer live viewer (it is only recorded for straggler rejection). currentSessionId
28
+ * undefined (no live streamer) → never a current-kill.
29
+ */
30
+ export function kickHitsCurrentSession(requested, currentSessionId) {
31
+ if (currentSessionId === undefined)
32
+ return false;
33
+ return requested === undefined || requested === currentSessionId;
34
+ }
14
35
  export class BrowserViewerStreamer {
15
36
  host;
16
37
  // Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
17
38
  // see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
18
39
  // and stop().
19
40
  streamers = new Map();
41
+ // The most-recently server-side-kicked viewer session per profile (design §3.6
42
+ // PR7). After a kick the streamer is gone, so the session-staleness guards
43
+ // (which key off a live streamer) can no longer reject the kicked viewer's
44
+ // straggler nav commands — this map does. Reset whenever the profile's streamer
45
+ // is (re)killed via killProfileStreamer, so a fresh stream.start clears it. One
46
+ // entry per profile (overwritten per kick); bounded, no leak.
47
+ kickedSessions = new Map();
20
48
  // Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
21
49
  // shutdown during its health poll would otherwise let the bb-viewer child
22
50
  // survive daemon stop (on BYOC nothing else reaps it).
@@ -33,6 +61,16 @@ export class BrowserViewerStreamer {
33
61
  async handleViewerCommand(profileId, sessionId, command, input, turn) {
34
62
  if (!profileId)
35
63
  throw new Error('browser profile id is required');
64
+ // Reject a kicked viewer's straggler commands (design §3.6 PR7). After a kick
65
+ // there is no streamer, so the per-command staleness guards (which compare
66
+ // against a live streamer's sessionId) would let a kicked viewer keep driving
67
+ // nav. A fresh stream.start re-establishes a session (killProfileStreamer
68
+ // clears the marker), so it is exempt.
69
+ if (command !== 'stream.start' &&
70
+ sessionId &&
71
+ this.kickedSessions.get(profileId) === sessionId) {
72
+ throw new Error('viewer session was terminated');
73
+ }
36
74
  switch (command) {
37
75
  case 'stream.start':
38
76
  return this.viewerStreamStart(profileId, sessionId, turn);
@@ -42,6 +80,8 @@ export class BrowserViewerStreamer {
42
80
  return this.viewerStreamClose(profileId, sessionId, input);
43
81
  case 'stream.switch':
44
82
  return this.viewerStreamSwitch(profileId, sessionId, input);
83
+ case 'kick':
84
+ return this.viewerKick(profileId, sessionId, input);
45
85
  case 'close':
46
86
  return this.viewerCloseTab(profileId, sessionId, input);
47
87
  case 'tab_list':
@@ -55,6 +95,36 @@ export class BrowserViewerStreamer {
55
95
  throw new Error(`unknown viewer command: ${command}`);
56
96
  }
57
97
  }
98
+ /**
99
+ * kick — server-side per-viewer disconnect (design §3.6 PR7). Terminates ONE
100
+ * viewer's WebRTC session: stop its bb-viewer streamer (killing the process
101
+ * tears down the peer connection + datachannel) and reject the kicked session's
102
+ * subsequent commands — while bb-browser + Chromium (the agent's live browser)
103
+ * keep running. This is explicitly DISTINCT from stopping the pod / profile,
104
+ * which would kill the agent's browser too; a viewer session is a sub-session of
105
+ * the profile lease, so a kick does NOT release the lease.
106
+ *
107
+ * Targets `input.session_id` if given, else the current streamer's session.
108
+ * Idempotent: kicking with no live streamer still records the kicked session.
109
+ */
110
+ viewerKick(profileId, sessionId, input) {
111
+ const streamer = this.streamers.get(profileId);
112
+ const requested = typeof input?.session_id === 'string' ? input.session_id : undefined;
113
+ const target = requested || streamer?.sessionId || sessionId;
114
+ // Only tear down the LIVE streamer when the kick targets the current session — a
115
+ // replayed/delayed stale session_id must not kill a newer live viewer. A kick with
116
+ // no explicit target (kick "whoever is live") also tears it down. A stale target is
117
+ // still recorded so its straggler commands are rejected by the staleness guard.
118
+ const killsCurrent = kickHitsCurrentSession(requested, streamer?.sessionId);
119
+ if (killsCurrent) {
120
+ // killProfileStreamer clears the kick marker, so record the target AFTER it.
121
+ this.killProfileStreamer(profileId);
122
+ }
123
+ if (target)
124
+ this.kickedSessions.set(profileId, target);
125
+ this.host.log.info(`[bb-viewer] kicked viewer session ${target || '(none)'} for profile ${profileId}; pod + agent browser keep running`);
126
+ return target ? { ok: true, kicked: true, session_id: target } : { ok: true, kicked: true };
127
+ }
58
128
  /**
59
129
  * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
60
130
  * one), resolve the profile's account-scoped page-target CDP ws URL, and run
@@ -394,6 +464,26 @@ export class BrowserViewerStreamer {
394
464
  const bin = process.env.PRLL_BB_VIEWER_BIN ?? 'bb-viewer';
395
465
  const port = await findFreePort();
396
466
  const args = ['--api-only', '--port', String(port)];
467
+ // Bind bb-viewer's HTTP control surface (/command + /health) to a specific
468
+ // host when the image opts in via PRLL_BB_VIEWER_HOST (the hosted browser pod
469
+ // sets it to 127.0.0.1; design §3.6 PR7). The streamer ALWAYS reaches
470
+ // bb-viewer over loopback (streamerCommand + the health poll below both use
471
+ // 127.0.0.1), so a loopback bind never breaks the in-pod path — it only
472
+ // removes the 0.0.0.0 exposure that, on the shared agents cluster, would let
473
+ // another tenant's pod re-point this streamer or start its own stream. Left
474
+ // unset (no --host) for BYOC and the current pinned bb-viewer, which has no
475
+ // --host flag — see deploy/browser-docker/AGENTS.md "bb-viewer loopback bind".
476
+ const bindHost = process.env.PRLL_BB_VIEWER_HOST?.trim();
477
+ if (bindHost) {
478
+ // SECURITY: bb-viewer's /command is unauthenticated, so on the shared agents
479
+ // cluster its bind host MUST be loopback — a 0.0.0.0 / pod-IP / typo'd value would
480
+ // silently re-open the cross-tenant control plane this env exists to close. Fail
481
+ // fast on anything non-loopback rather than pass it through to --host.
482
+ if (!isLoopbackBindHost(bindHost)) {
483
+ throw new Error(`PRLL_BB_VIEWER_HOST must be loopback (127.0.0.1, localhost, ::1, [::1]) — refusing to bind bb-viewer's unauthenticated /command to ${bindHost}`);
484
+ }
485
+ args.push('--host', bindHost);
486
+ }
397
487
  if (turn?.url) {
398
488
  args.push('--turn-url', turn.url, '--turn-user', turn.username ?? '', '--turn-cred', turn.credential ?? '');
399
489
  }
@@ -528,8 +618,14 @@ export class BrowserViewerStreamer {
528
618
  clearTimeout(timer);
529
619
  }
530
620
  }
531
- /** Kill + remove the profile's bb-viewer streamer, if any. Idempotent. */
621
+ /**
622
+ * Kill + remove the profile's bb-viewer streamer, if any. Idempotent. Also
623
+ * clears any kick marker for the profile: a (re)kill establishes a clean
624
+ * streamer slot, so a subsequent stream.start starts un-kicked. viewerKick
625
+ * re-records the kicked session AFTER calling this.
626
+ */
532
627
  killProfileStreamer(profileId) {
628
+ this.kickedSessions.delete(profileId);
533
629
  const streamer = this.streamers.get(profileId);
534
630
  if (!streamer)
535
631
  return;
@@ -39,6 +39,26 @@ export interface ClipProviderOptions {
39
39
  * re-trigger machine.hello. Optional.
40
40
  */
41
41
  onPersistentFailure?: () => void;
42
+ /**
43
+ * Hosted-browser live-viewer handler. Set ONLY for a hosted browser pod
44
+ * (browser-pod.ts): clip-service relays viewer commands down the
45
+ * ProviderStream and the pod replies over it (design §3.4). Returns the
46
+ * streamer result on success; throws to signal a command-level error (its
47
+ * message is forwarded to the viewer). Absent for ordinary BYOC providers,
48
+ * which never receive ViewerCommands. Callers should serialize their own
49
+ * streamer state if needed — the provider invokes this per inbound command.
50
+ */
51
+ onViewerCommand?: (cmd: {
52
+ profileId: string;
53
+ sessionId: string;
54
+ command: string;
55
+ input?: Record<string, unknown>;
56
+ turn?: {
57
+ url: string;
58
+ username?: string;
59
+ credential?: string;
60
+ };
61
+ }) => Promise<Record<string, unknown>>;
42
62
  }
43
63
  export declare class ClipProvider {
44
64
  private readonly opts;
@@ -52,6 +72,7 @@ export declare class ClipProvider {
52
72
  private statusUnsubscribe;
53
73
  private manifestUnsubscribe;
54
74
  private needsReregister;
75
+ private lastRegisteredKey;
55
76
  private streamGeneration;
56
77
  private static RECONNECT_BASE_MS;
57
78
  private static RECONNECT_MAX_MS;
@@ -69,6 +90,31 @@ export declare class ClipProvider {
69
90
  private clearReconnectTimer;
70
91
  private clearHeartbeatTimer;
71
92
  private startHeartbeat;
93
+ /**
94
+ * Re-register the current clip set on the OPEN stream — the daemon's response
95
+ * to a manifest/clip-set change. The hub overwrites its stored set from this
96
+ * full register (clip-provider-sharing-design §11), so a clip install / wake /
97
+ * sleep no longer requires tearing down and reopening the ProviderStream,
98
+ * which used to drop any invoke in flight on that stream (in-flight requests
99
+ * are keyed by request_id on the hub and survive a clip-set swap).
100
+ *
101
+ * No-op when the stream is not currently writable; needsReregister stays set
102
+ * so the next heartbeat tick (or the post-connect flush) retries.
103
+ *
104
+ * Against an older hub that still rejects an in-stream duplicate register, the
105
+ * write reaches a stream the hub then resets; the resulting stream error runs
106
+ * the normal reconnect path, which re-registers as the first frame — i.e. it
107
+ * degrades to the previous reconnect-to-re-register behavior, so no capability
108
+ * negotiation is needed (deploy the hub first).
109
+ */
110
+ private flushReregister;
111
+ /**
112
+ * Re-register the current clip set on the open stream — public entry for the
113
+ * supervisor when machine clip assignments change. Same in-stream path as a
114
+ * manifest update (no reconnect, in-flight invokes survive). Safe when
115
+ * disconnected: the flag is flushed on the next heartbeat / post-connect.
116
+ */
117
+ reregister(): void;
72
118
  private closeStream;
73
119
  private closeSession;
74
120
  private subscribeStatusChanges;
@@ -76,6 +122,16 @@ export declare class ClipProvider {
76
122
  private handleHubMessage;
77
123
  private handleRegistered;
78
124
  private handleInvokeCommand;
125
+ /**
126
+ * Handle a hosted-browser live-viewer command relayed by clip-service over the
127
+ * stream and reply on the same stream (design §3.4). Always answers the
128
+ * request/reply bridge exactly once — `{result}` on success, `{error:{message}}`
129
+ * on failure — and never throws into the message loop, mirroring the BYOC
130
+ * daemon's handleBrowserProfileViewer (which replies over HTTP instead).
131
+ */
132
+ private handleViewerCommand;
133
+ /** Send a hosted-viewer reply (base64-encoded JSON body) on the stream. */
134
+ private sendViewerReply;
79
135
  private sendRegister;
80
136
  private sendProviderMessage;
81
137
  private buildClipRegistrations;
@@ -1 +1 @@
1
- {"version":3,"file":"clip-provider.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/clip-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA+I/D,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,YAAY,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,WAAW,EAAE,kBAAkB,CAAC;IAChC,mBAAmB;IACnB,GAAG,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACpF;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;CAClC;AAED,qBAAa,YAAY;IAwBX,OAAO,CAAC,QAAQ,CAAC,IAAI;IAvBjC,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,MAAM,CAAwC;IACtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,mBAAmB,CAA6B;IACxD,OAAO,CAAC,eAAe,CAAS;IAIhC,OAAO,CAAC,gBAAgB,CAAK;IAE7B,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAS;IACzC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAU;IACzC,OAAO,CAAC,MAAM,CAAC,qBAAqB,CAAU;IAI9C,OAAO,CAAC,MAAM,CAAC,0BAA0B,CAAK;gBAEjB,IAAI,EAAE,mBAAmB;IAEtD,mFAAmF;IAC7E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAS9B,wCAAwC;IAClC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAUjC,WAAW,IAAI,OAAO;YAQR,UAAU;IAuExB,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,iBAAiB;IA6BzB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,cAAc;IAuBtB,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,sBAAsB;IAyB9B,OAAO,CAAC,wBAAwB;IAWhC,OAAO,CAAC,gBAAgB;IAaxB,OAAO,CAAC,gBAAgB;YAeV,mBAAmB;YA0EnB,YAAY;YAUZ,mBAAmB;IAiBjC,OAAO,CAAC,sBAAsB;IAQ9B,OAAO,CAAC,wBAAwB;IAuBhC,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,cAAc;CAUvB"}
1
+ {"version":3,"file":"clip-provider.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/clip-provider.ts"],"names":[],"mappings":"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"}
@@ -28,6 +28,17 @@ function encodeEnvelope(msg) {
28
28
  header.writeUInt32BE(json.length, 1);
29
29
  return Buffer.concat([header, json]);
30
30
  }
31
+ /**
32
+ * Canonical key for a clip-registration set — used by flushReregister to skip a
33
+ * re-register that would not change the hub's view. Sorted by alias so set
34
+ * order is irrelevant; each registration's shape is fixed by
35
+ * clipConfigToRegistration, so its JSON is a stable digest of the hub-facing
36
+ * content (commands, version, dependencies, …).
37
+ */
38
+ function registrationKey(clips) {
39
+ const sorted = [...clips].sort((a, b) => (a.alias ?? '').localeCompare(b.alias ?? ''));
40
+ return JSON.stringify(sorted);
41
+ }
31
42
  /**
32
43
  * Incremental envelope decoder. Feed chunks via push(), receive decoded
33
44
  * messages from flush(). Handles partial reads across chunk boundaries.
@@ -66,7 +77,15 @@ export class ClipProvider {
66
77
  heartbeatTimer = null;
67
78
  statusUnsubscribe = null;
68
79
  manifestUnsubscribe = null;
80
+ // Set when the clip manifest/set changed and the hub's copy is stale. Drained
81
+ // by flushReregister(), which re-registers IN-STREAM (no reconnect).
69
82
  needsReregister = false;
83
+ // Canonical key of the clip-registration set last sent to the hub on the
84
+ // CURRENT stream. flushReregister diff-gates against it so a cold-start that
85
+ // re-derives an identical set (the common case — a clip's command surface is
86
+ // stable across spawns) does not emit a redundant re-register. Reset per
87
+ // stream; the first-frame register in openStream re-seeds it.
88
+ lastRegisteredKey = null;
70
89
  // Monotonic stream id, bumped by openStream(). Handlers from a stream that a
71
90
  // newer openStream() has superseded bail in handleDisconnect(gen), so their
72
91
  // late close/end/error can't reconnect against the healthy replacement.
@@ -109,6 +128,9 @@ export class ClipProvider {
109
128
  return;
110
129
  // Bind this stream's handlers to a fresh generation; older streams go stale.
111
130
  const gen = ++this.streamGeneration;
131
+ // New stream → the hub holds nothing until the first-frame register below
132
+ // re-seeds this; clear so a stale key can't suppress that register.
133
+ this.lastRegisteredKey = null;
112
134
  try {
113
135
  this.closeStream();
114
136
  this.closeSession();
@@ -218,25 +240,68 @@ export class ClipProvider {
218
240
  startHeartbeat() {
219
241
  this.clearHeartbeatTimer();
220
242
  this.heartbeatTimer = setInterval(() => {
221
- // If a clip manifest was populated since last heartbeat, reconnect to
222
- // re-register all clips with their now-available commands.
223
- if (this.needsReregister) {
224
- this.needsReregister = false;
225
- this.opts.log.info('[clip-provider] manifest updated reconnecting to re-register clips');
226
- this.connected = false;
227
- this.clearHeartbeatTimer();
228
- this.clearReconnectTimer();
229
- // openStream() bumps the generation before closing the old stream, so its
230
- // late close events fail the gen check (closing inline would re-arm the flap).
231
- void this.openStream();
232
- return;
233
- }
243
+ // A pending manifest/clip-set change re-registers IN-STREAM (no
244
+ // reconnect), so in-flight invokes on this stream survive. This is the
245
+ // fallback for when the immediate flush on the change itself could not
246
+ // write (e.g. it fired mid-(re)connect); the common case re-registers the
247
+ // instant the manifest changes via the manifest listener.
248
+ this.flushReregister();
234
249
  this.sendProviderMessage({ ping: { sentAtUnixMs: Date.now() } }).catch((err) => {
235
250
  this.opts.log.warn(`[clip-provider] heartbeat send failed: ${String(err)}`);
236
251
  });
237
252
  }, ClipProvider.HEARTBEAT_INTERVAL_MS);
238
253
  this.heartbeatTimer.unref?.();
239
254
  }
255
+ /**
256
+ * Re-register the current clip set on the OPEN stream — the daemon's response
257
+ * to a manifest/clip-set change. The hub overwrites its stored set from this
258
+ * full register (clip-provider-sharing-design §11), so a clip install / wake /
259
+ * sleep no longer requires tearing down and reopening the ProviderStream,
260
+ * which used to drop any invoke in flight on that stream (in-flight requests
261
+ * are keyed by request_id on the hub and survive a clip-set swap).
262
+ *
263
+ * No-op when the stream is not currently writable; needsReregister stays set
264
+ * so the next heartbeat tick (or the post-connect flush) retries.
265
+ *
266
+ * Against an older hub that still rejects an in-stream duplicate register, the
267
+ * write reaches a stream the hub then resets; the resulting stream error runs
268
+ * the normal reconnect path, which re-registers as the first frame — i.e. it
269
+ * degrades to the previous reconnect-to-re-register behavior, so no capability
270
+ * negotiation is needed (deploy the hub first).
271
+ */
272
+ flushReregister() {
273
+ if (!this.needsReregister)
274
+ return;
275
+ if (!this.connected || !this.stream || this.stream.closed || this.stream.destroyed)
276
+ return;
277
+ // Diff-gate: a cold-start re-derives the clip set on every wake, but the
278
+ // hub-facing registration is usually identical (a clip's command surface is
279
+ // stable across spawns). Skip the redundant re-register so the hot path
280
+ // (invoke → wake clip → manifest re-read) does no hub work when nothing
281
+ // actually changed. Compare the canonical registration set against what this
282
+ // stream last sent.
283
+ const clips = this.buildClipRegistrations();
284
+ if (registrationKey(clips) === this.lastRegisteredKey) {
285
+ this.needsReregister = false;
286
+ return;
287
+ }
288
+ this.needsReregister = false;
289
+ this.opts.log.info('[clip-provider] re-registering clips in-stream');
290
+ this.sendRegister(clips).catch((err) => {
291
+ this.opts.log.warn(`[clip-provider] in-stream re-register failed: ${String(err)}`);
292
+ this.needsReregister = true; // retry on the next heartbeat tick
293
+ });
294
+ }
295
+ /**
296
+ * Re-register the current clip set on the open stream — public entry for the
297
+ * supervisor when machine clip assignments change. Same in-stream path as a
298
+ * manifest update (no reconnect, in-flight invokes survive). Safe when
299
+ * disconnected: the flag is flushed on the next heartbeat / post-connect.
300
+ */
301
+ reregister() {
302
+ this.needsReregister = true;
303
+ this.flushReregister();
304
+ }
240
305
  closeStream() {
241
306
  if (this.stream) {
242
307
  try {
@@ -279,9 +344,11 @@ export class ClipProvider {
279
344
  this.opts.log.warn(`[clip-provider] status change send failed: ${String(err)}`);
280
345
  });
281
346
  });
282
- // Listen for manifest updates so we can re-register with populated commands
347
+ // Listen for manifest updates so we can re-register with populated commands.
348
+ // Re-register immediately on the open stream; the heartbeat retries if the
349
+ // stream is momentarily not writable.
283
350
  this.manifestUnsubscribe = mgr.addManifestListener(() => {
284
- this.needsReregister = true;
351
+ this.reregister();
285
352
  });
286
353
  }
287
354
  unsubscribeStatusChanges() {
@@ -300,6 +367,9 @@ export class ClipProvider {
300
367
  else if (msg.invokeCommand) {
301
368
  void this.handleInvokeCommand(msg.invokeCommand);
302
369
  }
370
+ else if (msg.viewerCommand) {
371
+ void this.handleViewerCommand(msg.viewerCommand);
372
+ }
303
373
  else if (msg.pong !== undefined) {
304
374
  // Pong is the hub's keepalive ack. Do NOT echo a ping here — the
305
375
  // heartbeat timer (startHeartbeat) is the sole ping driver. Replying to
@@ -313,6 +383,9 @@ export class ClipProvider {
313
383
  this.connected = true;
314
384
  this.reconnectAttempt = 0;
315
385
  this.startHeartbeat();
386
+ // A manifest change that landed during the connect handshake (after the
387
+ // initial register was sent) is flushed now that the stream is writable.
388
+ this.flushReregister();
316
389
  }
317
390
  else {
318
391
  this.opts.log.error(`[clip-provider] registration rejected: ${reg.message}`);
@@ -387,17 +460,68 @@ export class ClipProvider {
387
460
  }).catch(() => { }); // best-effort error response
388
461
  }
389
462
  }
463
+ /**
464
+ * Handle a hosted-browser live-viewer command relayed by clip-service over the
465
+ * stream and reply on the same stream (design §3.4). Always answers the
466
+ * request/reply bridge exactly once — `{result}` on success, `{error:{message}}`
467
+ * on failure — and never throws into the message loop, mirroring the BYOC
468
+ * daemon's handleBrowserProfileViewer (which replies over HTTP instead).
469
+ */
470
+ async handleViewerCommand(cmd) {
471
+ const { requestId } = cmd;
472
+ if (!this.opts.onViewerCommand) {
473
+ // A provider with no live-viewer handler received a viewer command (should
474
+ // not happen — only hosted pods are sent these). Fail the bridge fast.
475
+ await this.sendViewerReply(requestId, {
476
+ error: { message: 'live viewer is not supported by this provider' },
477
+ }).catch(() => { });
478
+ return;
479
+ }
480
+ try {
481
+ let input;
482
+ if (cmd.input) {
483
+ const decoded = Buffer.from(cmd.input, 'base64').toString('utf-8');
484
+ try {
485
+ input = JSON.parse(decoded);
486
+ }
487
+ catch {
488
+ input = undefined;
489
+ }
490
+ }
491
+ const result = await this.opts.onViewerCommand({
492
+ profileId: cmd.profileId,
493
+ sessionId: cmd.sessionId,
494
+ command: cmd.command,
495
+ input,
496
+ turn: cmd.turn,
497
+ });
498
+ await this.sendViewerReply(requestId, { result });
499
+ }
500
+ catch (err) {
501
+ const message = err instanceof Error ? err.message : String(err);
502
+ this.opts.log.warn(`[clip-provider] viewer command failed (profile=${cmd.profileId}, command=${cmd.command}): ${message}`);
503
+ await this.sendViewerReply(requestId, { error: { message } }).catch(() => { });
504
+ }
505
+ }
506
+ /** Send a hosted-viewer reply (base64-encoded JSON body) on the stream. */
507
+ async sendViewerReply(requestId, reply) {
508
+ const payload = Buffer.from(JSON.stringify(reply), 'utf-8').toString('base64');
509
+ await this.sendProviderMessage({ viewerResult: { requestId, payload } });
510
+ }
390
511
  // ---------------------------------------------------------------------------
391
512
  // Sending
392
513
  // ---------------------------------------------------------------------------
393
- async sendRegister() {
394
- const clips = this.buildClipRegistrations();
514
+ async sendRegister(prebuilt) {
515
+ const clips = prebuilt ?? this.buildClipRegistrations();
395
516
  await this.sendProviderMessage({
396
517
  register: {
397
518
  providerName: this.opts.providerName,
398
519
  clips,
399
520
  },
400
521
  });
522
+ // Record what the hub now holds on this stream so flushReregister can
523
+ // diff-gate subsequent cold-start re-registers.
524
+ this.lastRegisteredKey = registrationKey(clips);
401
525
  }
402
526
  async sendProviderMessage(msg) {
403
527
  if (!this.stream || this.stream.closed || this.stream.destroyed) {
@@ -89,7 +89,6 @@ export declare class DaemonSupervisor {
89
89
  private clipConfigSignature;
90
90
  private ensureClipInstalled;
91
91
  private readInstalledClipVersion;
92
- private reconnectClipProvider;
93
92
  private handleWorkspaceSetupRequested;
94
93
  private scheduleWorkspaceSetupRetry;
95
94
  private clearWorkspaceSetupRetry;
@@ -1 +1 @@
1
- {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../src/supervisor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../src/supervisor.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,EAeL,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AASrB,OAAO,EAKL,KAAK,kBAAkB,EAExB,MAAM,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAiBlD;;;;GAIG;AACH,iBAAS,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAa3E;AAED,OAAO,EAAE,gBAAgB,EAAE,CAAC;AAyB5B;;;;;;;;;;GAUG;AACH,qBAAa,gBAAgB;IA0CzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;IA3CtB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IACpD,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAM3D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAoC;IAC3E,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAAqC;IAC/E,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,gBAAgB,CAAoB;IAG5C,OAAO,CAAC,sBAAsB,CAAQ;IAItC,OAAO,CAAC,sBAAsB,CAAuB;IAGrD,OAAO,CAAC,uBAAuB,CAAuB;IACtD,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,OAAO,CAA8B;IAC7C,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,qBAAqB,CAAsC;IACnE,OAAO,CAAC,WAAW,CAAmC;IACtD,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,kBAAkB,CAA+B;IACzD,OAAO,CAAC,qBAAqB,CAA8B;IAI3D,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,YAAY,CAAuB;gBAGxB,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,aAAa;IAGrC,UAAU,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI;IAIxC,yEAAyE;IACnE,GAAG,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IA0L7C,sEAAsE;IAChE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YA8Cb,kBAAkB;YAwClB,aAAa;YA0Db,wBAAwB;IAoCtC;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;YA6CX,kBAAkB;YAqBlB,mBAAmB;YAgBnB,mBAAmB;YAmBnB,sBAAsB;YAwBtB,6BAA6B;IAoC3C,OAAO,CAAC,8BAA8B;IAMtC,OAAO,CAAC,uBAAuB;IAgB/B;;;;;OAKG;YACW,0BAA0B;IAiCxC,OAAO,CAAC,2BAA2B;YAIrB,cAAc;YAId,qBAAqB;YAgBrB,wBAAwB;IAoDtC,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,sBAAsB;IAM9B,OAAO,CAAC,mBAAmB;IAe3B,OAAO,CAAC,uBAAuB;IAyB/B,OAAO,CAAC,aAAa;IAgBrB,OAAO,CAAC,mBAAmB;YAab,mBAAmB;IAyCjC,OAAO,CAAC,wBAAwB;YAelB,6BAA6B;IAqC3C,OAAO,CAAC,2BAA2B;IAYnC,OAAO,CAAC,wBAAwB;YAUlB,oBAAoB;IAmClC;;;;;;;OAOG;IACH,OAAO,CAAC,qBAAqB;IAQ7B;;;;;;OAMG;IACH,OAAO,CAAC,YAAY;IASpB;;;;;;;;OAQG;YACW,sBAAsB;YA4CtB,kBAAkB;YAYlB,eAAe;YAcf,UAAU;YAoBV,cAAc;IAkE5B,OAAO,CAAC,UAAU;YA4GJ,cAAc;IAyB5B,OAAO,CAAC,0BAA0B;CAkCnC"}
@@ -400,10 +400,16 @@ export class DaemonSupervisor {
400
400
  for (const profile of profiles) {
401
401
  if (profile.status !== 'running' && profile.status !== 'pending')
402
402
  continue;
403
+ // The daemon supervises only machine-bound (byoc) profiles; hosted
404
+ // profiles (null machine_id) run on the platform pool and are never owned
405
+ // by a daemon machine.
406
+ if (!profile.machine_id)
407
+ continue;
408
+ const machineId = profile.machine_id;
403
409
  try {
404
410
  if (profile.status === 'pending') {
405
411
  await this.enqueueBrowserProfileLifecycle({
406
- machine_id: profile.machine_id,
412
+ machine_id: machineId,
407
413
  profile_id: profile.id,
408
414
  action: 'open',
409
415
  });
@@ -681,7 +687,12 @@ export class DaemonSupervisor {
681
687
  }
682
688
  if (changed) {
683
689
  this.log.info(`machine clips synced — count=${desired.length}`);
684
- await this.reconnectClipProvider();
690
+ // Re-register the new clip set IN-STREAM rather than reconnecting the
691
+ // provider — a clip assign/unassign must not tear down the stream and drop
692
+ // in-flight invokes (the hub overwrites its set from the register; see
693
+ // clip-provider-sharing-design §11). Endpoint changes still rebuild via
694
+ // applyClipProviderState.
695
+ this.clipProvider?.reregister();
685
696
  }
686
697
  }
687
698
  startClipReconcileTimer() {
@@ -816,17 +827,6 @@ export class DaemonSupervisor {
816
827
  }
817
828
  return null;
818
829
  }
819
- async reconnectClipProvider() {
820
- if (!this.clipProvider)
821
- return;
822
- const provider = this.clipProvider;
823
- this.clipProvider = null;
824
- this.connectedClipServiceUrl = null;
825
- await provider
826
- .disconnect()
827
- .catch((err) => this.log.warn(`clip provider reconnect disconnect failed: ${String(err)}`));
828
- await this.applyClipProviderState();
829
- }
830
830
  async handleWorkspaceSetupRequested(agentId) {
831
831
  if (this.spawningAgents.has(agentId)) {
832
832
  this.log.info(`agent ${agentId}: workspace setup already in progress; queueing one restart`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/daemon",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Parall local agent runtime — daemon supervisor + bridge runtimes, bundled as standalone JS files",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,7 +16,8 @@
16
16
  "parall-daemon": "./bundle/parall-daemon.js",
17
17
  "parall-claude-agent": "./bundle/parall-claude-agent.js",
18
18
  "parall-codex-agent": "./bundle/parall-codex-agent.js",
19
- "parall-openclaw-agent": "./bundle/parall-openclaw-agent.js"
19
+ "parall-openclaw-agent": "./bundle/parall-openclaw-agent.js",
20
+ "parall-browser-pod": "./bundle/parall-browser-pod.js"
20
21
  },
21
22
  "exports": {
22
23
  ".": {
@@ -29,12 +30,13 @@
29
30
  "dist"
30
31
  ],
31
32
  "dependencies": {
33
+ "@aws-sdk/client-s3": "3.984.0",
32
34
  "@pinixai/bb-browser-pro": "0.15.0",
33
- "@parall/agent-core": "1.35.0",
34
- "@parall/sdk": "1.35.0",
35
- "@parall/claude-agent": "1.35.0",
36
- "@parall/codex-agent": "1.35.0",
37
- "@parall/openclaw-agent": "1.35.0"
35
+ "@parall/sdk": "1.36.0",
36
+ "@parall/claude-agent": "1.36.0",
37
+ "@parall/openclaw-agent": "1.36.0",
38
+ "@parall/agent-core": "1.36.0",
39
+ "@parall/codex-agent": "1.36.0"
38
40
  },
39
41
  "devDependencies": {
40
42
  "@types/node": "^22.0.0",
@@ -44,6 +46,7 @@
44
46
  "scripts": {
45
47
  "build": "tsc -b",
46
48
  "bundle": "node ../../scripts/bundle-daemon.mjs",
47
- "start": "node bundle/parall-daemon.js"
49
+ "start": "node bundle/parall-daemon.js",
50
+ "test": "pnpm run build && node --test test/*.test.mjs"
48
51
  }
49
52
  }