@vellumai/assistant 0.12.0-dev.202609122019.43571ed → 0.12.0-dev.202609122213.71fcf45

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": "@vellumai/assistant",
3
- "version": "0.12.0-dev.202609122019.43571ed",
3
+ "version": "0.12.0-dev.202609122213.71fcf45",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,7 +26,7 @@ afterAll(() => {
26
26
  });
27
27
 
28
28
  describe("always-loaded tool count", () => {
29
- test("should be exactly 14 with recall occupying the existing slot", async () => {
29
+ test("should be exactly 19 with recall occupying the existing slot", async () => {
30
30
  await initializeTools();
31
31
  const allDefs = getAllToolDefinitions();
32
32
 
@@ -44,20 +44,24 @@ describe("always-loaded tool count", () => {
44
44
  );
45
45
  const activeNames = activeTools.map((t) => t.name).sort();
46
46
 
47
- // Host tools (host_bash, host_file_*) are excluded when no client is
48
- // connected — without a human in the loop, the guardian auto-approve
49
- // path would allow unchecked host command execution.
50
- //
51
47
  // `watch_retro_report` survives this baseline for the same reason the
52
48
  // ui_* tools do: a watch retrospective runs clientless, so it can only
53
49
  // report through a tool that survives this baseline. The ui_* tools are
54
50
  // here because background UI surfaces persist and return instead of
55
51
  // awaiting action, so they no longer need a connected client.
52
+ // Host tool definitions stay on the wire for background turns: schemas
53
+ // are cache-stable, and the shared execution gate rejects host tools on
54
+ // explicitly non-interactive turns before any proxy dispatch.
56
55
  const expectedNames = [
57
56
  "bash",
58
57
  "file_edit",
59
58
  "file_read",
60
59
  "file_write",
60
+ "host_bash",
61
+ "host_file_edit",
62
+ "host_file_read",
63
+ "host_file_transfer",
64
+ "host_file_write",
61
65
  "recall",
62
66
  "remember",
63
67
  "skill_execute",
@@ -72,6 +76,6 @@ describe("always-loaded tool count", () => {
72
76
 
73
77
  expect(activeNames).toEqual(expectedNames);
74
78
  expect(activeNames.filter((name) => name === "recall")).toHaveLength(1);
75
- expect(activeTools.length).toBe(14);
79
+ expect(activeTools.length).toBe(19);
76
80
  });
77
81
  });
@@ -354,6 +354,57 @@ describe("createToolExecutor isInteractive threading", () => {
354
354
  });
355
355
  });
356
356
 
357
+ describe("createToolExecutor unattended host-tool gate", () => {
358
+ test("rejects a direct host tool in a background turn before dispatch", async () => {
359
+ const { executor, calls } = makeCapturingExecutor();
360
+ const toolFn = makeToolFn(
361
+ executor,
362
+ makeCtx({ currentTurnIsNonInteractive: true, transportInterface: "web" }),
363
+ );
364
+
365
+ const result = await toolFn("host_bash", { command: "pwd" });
366
+
367
+ expect(result).toMatchObject({ isError: true });
368
+ expect(result.content).toContain("requires an interactive user turn");
369
+ expect(calls).toHaveLength(0);
370
+ });
371
+
372
+ test("rejects a skill-dispatched host tool in a background turn before dispatch", async () => {
373
+ const { executor, calls } = makeCapturingExecutor();
374
+ const toolFn = makeToolFn(
375
+ executor,
376
+ makeCtx({ currentTurnIsNonInteractive: true, transportInterface: "web" }),
377
+ );
378
+
379
+ const result = await toolFn("skill_execute", {
380
+ tool: "host_file_read",
381
+ input: { path: "/tmp/a" },
382
+ activity: "testing",
383
+ });
384
+
385
+ expect(result).toMatchObject({ isError: true });
386
+ expect(result.content).toContain("requires an interactive user turn");
387
+ expect(calls).toHaveLength(0);
388
+ });
389
+
390
+ test("dispatches a host tool during an interactive turn", async () => {
391
+ const { executor, calls } = makeCapturingExecutor();
392
+ const toolFn = makeToolFn(
393
+ executor,
394
+ makeCtx({
395
+ currentTurnIsNonInteractive: false,
396
+ transportInterface: "web",
397
+ }),
398
+ );
399
+
400
+ const result = await toolFn("host_bash", { command: "pwd" });
401
+
402
+ expect(result).toMatchObject({ content: "ok", isError: false });
403
+ expect(calls).toHaveLength(1);
404
+ expect(calls[0].name).toBe("host_bash");
405
+ });
406
+ });
407
+
357
408
  describe("createToolExecutor channel-permission coordinate threading", () => {
358
409
  beforeEach(() => {
359
410
  mockBindingExternalChatId = null;
@@ -669,23 +669,31 @@ describe("HostBashProxy", () => {
669
669
  expect(sentMessages).toHaveLength(0);
670
670
  });
671
671
 
672
- test("rejects ambiguously when multiple same-user capable clients are connected and no targetClientId", async () => {
673
- // Regression: previously fell through to untargeted broadcast, fanning
674
- // a single targeted-style request out across every same-user machine.
672
+ test("selects the most recently active same-user client when several are connected", async () => {
675
673
  setup();
676
674
  setupMultipleClients(["client-1", "client-2", "client-3"]);
677
675
 
678
- const result = await proxy.request(
676
+ const resultPromise = proxy.request(
679
677
  { command: "echo hello" },
680
678
  "session-1",
681
679
  undefined,
682
680
  "user-A",
683
681
  );
684
682
 
685
- expect(result.isError).toBe(true);
686
- expect(result.content).toContain("target_client_id");
687
- // No broadcast happened
688
- expect(sentMessages).toHaveLength(0);
683
+ const sent = sentMessages[0] as Record<string, unknown>;
684
+ expect(sent.targetClientId).toBe("client-1");
685
+ const options = sentMessageOptions[0] as
686
+ | Record<string, unknown>
687
+ | undefined;
688
+ expect(options?.targetClientId).toBe("client-1");
689
+
690
+ proxy.resolveResult(sent.requestId as string, {
691
+ stdout: "hello\n",
692
+ stderr: "",
693
+ exitCode: 0,
694
+ timedOut: false,
695
+ });
696
+ expect((await resultPromise).isError).toBe(false);
689
697
  });
690
698
 
691
699
  test("falls through to broadcast when zero capable clients (existing timeout path)", async () => {
@@ -832,30 +840,19 @@ describe("HostBashProxy", () => {
832
840
  expect(sentMessages).toHaveLength(0);
833
841
  });
834
842
 
835
- test("untargeted local flow unchanged when no auto-resolve match", async () => {
843
+ test("rejects when the actor has no capable client", async () => {
836
844
  setup();
837
- // No capable clients connected — untargeted path runs.
838
845
 
839
- const resultPromise = proxy.request(
846
+ const result = await proxy.request(
840
847
  { command: "echo hello" },
841
848
  "session-1",
842
849
  undefined,
843
850
  "user-A",
844
851
  );
845
852
 
846
- expect(sentMessages).toHaveLength(1);
847
- const sent = sentMessages[0] as Record<string, unknown>;
848
- expect(sent.type).toBe("host_bash_request");
849
- expect(sent.targetClientId).toBeUndefined();
850
-
851
- const requestId = sent.requestId as string;
852
- proxy.resolveResult(requestId, {
853
- stdout: "hello\n",
854
- stderr: "",
855
- exitCode: 0,
856
- timedOut: false,
857
- });
858
- await resultPromise;
853
+ expect(result).toMatchObject({ isError: true });
854
+ expect(result.content).toContain("belongs to this user");
855
+ expect(sentMessages).toHaveLength(0);
859
856
  });
860
857
 
861
858
  test("auto-resolve to same-user client succeeds", async () => {
@@ -883,36 +880,20 @@ describe("HostBashProxy", () => {
883
880
  await resultPromise;
884
881
  });
885
882
 
886
- test("auto-resolve to different-user client falls through to untargeted", async () => {
883
+ test("rejects when only a different user has a capable client", async () => {
887
884
  setup();
888
- // Single capable client owned by user-B; caller is user-A.
889
885
  setupSingleClient("client-abc", "user-B");
890
886
 
891
- const resultPromise = proxy.request(
887
+ const result = await proxy.request(
892
888
  { command: "echo hello" },
893
889
  "session-1",
894
890
  undefined,
895
891
  "user-A",
896
892
  );
897
893
 
898
- // Auto-resolve must NOT pick the cross-user client; the untargeted
899
- // broadcast path runs instead.
900
- expect(sentMessages).toHaveLength(1);
901
- const sent = sentMessages[0] as Record<string, unknown>;
902
- expect(sent.type).toBe("host_bash_request");
903
- expect(sent.targetClientId).toBeUndefined();
904
-
905
- const opts = sentMessageOptions[0] as Record<string, unknown> | undefined;
906
- expect(opts?.targetClientId).toBeUndefined();
907
-
908
- const requestId = sent.requestId as string;
909
- proxy.resolveResult(requestId, {
910
- stdout: "hello\n",
911
- stderr: "",
912
- exitCode: 0,
913
- timedOut: false,
914
- });
915
- await resultPromise;
894
+ expect(result).toMatchObject({ isError: true });
895
+ expect(result.content).toContain("belongs to this user");
896
+ expect(sentMessages).toHaveLength(0);
916
897
  });
917
898
  });
918
899
  });
@@ -608,22 +608,17 @@ describe("HostBrowserProxy", () => {
608
608
  },
609
609
  ];
610
610
 
611
- const resultPromise = proxy.request(
611
+ const result = await proxy.request(
612
612
  { cdpMethod: "Page.navigate", cdpParams: { url: "https://a.test" } },
613
613
  "session-1",
614
614
  undefined,
615
615
  "user-1",
616
616
  );
617
617
 
618
- // Auto-resolution filters out the cross-user candidate, so the
619
- // proxy falls into the existing "no active extension connection"
620
- // rejection — we never broadcast to a different actor's client.
621
- await expect(resultPromise).rejects.toThrow(
622
- "no active extension connection",
623
- );
618
+ expect(result).toMatchObject({ isError: true });
619
+ expect(result.content).toContain("belongs to this user");
624
620
  expect(getPublishedMessages()).toHaveLength(0);
625
621
  });
626
-
627
622
  test("prefers the chrome-extension client over a more-recently-active macOS bridge", async () => {
628
623
  // Mock `listClientsByCapability` returns mockClients in array
629
624
  // order, which mirrors production's `lastActiveAt`-desc ordering.
@@ -720,11 +715,6 @@ describe("HostBrowserProxy", () => {
720
715
  });
721
716
 
722
717
  test("rejects when caller has actor but no host_browser-capable client is connected for that actor", async () => {
723
- // Same-user filter returns empty even though listClientsByCapability
724
- // would return a non-empty list (because that list is for a
725
- // different actor). The unfiltered fallback path runs only when
726
- // the caller has no actor — we don't silently broadcast to anyone
727
- // when the caller IS authenticated to a specific actor.
728
718
  mockClients = [
729
719
  {
730
720
  clientId: "other-user-ext",
@@ -734,16 +724,15 @@ describe("HostBrowserProxy", () => {
734
724
  },
735
725
  ];
736
726
 
737
- const resultPromise = proxy.request(
727
+ const result = await proxy.request(
738
728
  { cdpMethod: "Page.navigate", cdpParams: { url: "https://a.test" } },
739
729
  "session-1",
740
730
  undefined,
741
731
  "user-1",
742
732
  );
743
733
 
744
- await expect(resultPromise).rejects.toThrow(
745
- "no active extension connection",
746
- );
734
+ expect(result).toMatchObject({ isError: true });
735
+ expect(result.content).toContain("belongs to this user");
747
736
  expect(getPublishedMessages()).toHaveLength(0);
748
737
  });
749
738
  });
@@ -274,6 +274,29 @@ describe("HostFileProxy — targetClientId (Phase 2)", () => {
274
274
  });
275
275
  });
276
276
 
277
+ test("selects the most recently active same-user client when several are connected", async () => {
278
+ setup();
279
+ setupMultipleClients(["client-1", "client-2"]);
280
+
281
+ const resultPromise = proxy.request(
282
+ { operation: "read", path: "/tmp/file.txt" },
283
+ "session-1",
284
+ undefined,
285
+ undefined,
286
+ TEST_PRINCIPAL,
287
+ );
288
+
289
+ const sent = sentMessages[0] as Record<string, unknown>;
290
+ expect(sent.targetClientId).toBe("client-1");
291
+ const options = sentMessageOptions[0] as
292
+ | Record<string, unknown>
293
+ | undefined;
294
+ expect(options?.targetClientId).toBe("client-1");
295
+
296
+ proxy.resolve(sent.requestId as string, { content: "ok", isError: false });
297
+ expect((await resultPromise).isError).toBe(false);
298
+ });
299
+
277
300
  // ── targetClientId in cancel (abort signal) ──────────────────────────
278
301
 
279
302
  describe("targetClientId in cancel — abort signal", () => {
@@ -746,9 +746,8 @@ describe("HostFileProxy", () => {
746
746
  expect(sentMessages).toHaveLength(0);
747
747
  });
748
748
 
749
- test("untargeted request with no auto-resolve match still broadcasts (legacy path unchanged)", async () => {
749
+ test("rejects when the actor has no capable client", async () => {
750
750
  setup();
751
- // No matching same-user clients available.
752
751
  mockClients = [
753
752
  {
754
753
  clientId: "client-A",
@@ -757,23 +756,18 @@ describe("HostFileProxy", () => {
757
756
  },
758
757
  ];
759
758
 
760
- const resultPromise = proxy.request(
759
+ const result = await proxy.request(
761
760
  { operation: "read", path: "/tmp/test.txt" },
762
761
  "session-1",
763
762
  undefined,
764
763
  undefined,
765
- "user-B", // No same-user match → no auto-resolve, broadcast untargeted.
764
+ "user-B",
766
765
  );
767
766
 
768
- expect(sentMessages).toHaveLength(1);
769
- const sent = sentMessages[0] as Record<string, unknown>;
770
- expect(sent.targetClientId).toBeUndefined();
771
- const requestId = sent.requestId as string;
772
-
773
- proxy.resolve(requestId, { content: "ok", isError: false });
774
- await resultPromise;
767
+ expect(result).toMatchObject({ isError: true });
768
+ expect(result.content).toContain("belongs to this user");
769
+ expect(sentMessages).toHaveLength(0);
775
770
  });
776
-
777
771
  test("auto-resolve picks the same-user client when there's exactly one", async () => {
778
772
  setup();
779
773
  mockClients = [
@@ -807,7 +801,7 @@ describe("HostFileProxy", () => {
807
801
  await resultPromise;
808
802
  });
809
803
 
810
- test("auto-resolve falls through when no client matches the source user", async () => {
804
+ test("rejects when only a different user has a capable client", async () => {
811
805
  setup();
812
806
  mockClients = [
813
807
  {
@@ -817,7 +811,7 @@ describe("HostFileProxy", () => {
817
811
  },
818
812
  ];
819
813
 
820
- const resultPromise = proxy.request(
814
+ const result = await proxy.request(
821
815
  { operation: "read", path: "/tmp/test.txt" },
822
816
  "session-1",
823
817
  undefined,
@@ -825,16 +819,10 @@ describe("HostFileProxy", () => {
825
819
  "user-C",
826
820
  );
827
821
 
828
- // No same-user client → no auto-resolve, broadcast untargeted.
829
- expect(sentMessages).toHaveLength(1);
830
- const sent = sentMessages[0] as Record<string, unknown>;
831
- expect(sent.targetClientId).toBeUndefined();
832
- const requestId = sent.requestId as string;
833
-
834
- proxy.resolve(requestId, { content: "ok", isError: false });
835
- await resultPromise;
822
+ expect(result).toMatchObject({ isError: true });
823
+ expect(result.content).toContain("belongs to this user");
824
+ expect(sentMessages).toHaveLength(0);
836
825
  });
837
-
838
826
  test("legacy embedded targetClientId in input still goes through the same-user gate", async () => {
839
827
  setup();
840
828
  mockClients = [
@@ -812,7 +812,7 @@ describe("HostTransferProxy — targetClientId", () => {
812
812
  await resultPromise;
813
813
  });
814
814
 
815
- test("requestToHost: auto-resolve falls through when no client matches the source user", async () => {
815
+ test("requestToHost: rejects when no client matches the source user", async () => {
816
816
  setup();
817
817
  mockClientRegistry.set("client-A", {
818
818
  clientId: "client-A",
@@ -824,7 +824,7 @@ describe("HostTransferProxy — targetClientId", () => {
824
824
  const srcPath = `/tmp/htp-auto-no-match-${Date.now()}.txt`;
825
825
  await globalThis.Bun.write(srcPath, "data");
826
826
 
827
- const resultPromise = proxy.requestToHost(
827
+ const result = await proxy.requestToHost(
828
828
  {
829
829
  sourcePath: srcPath,
830
830
  destPath: "/host/dest.txt",
@@ -835,13 +835,9 @@ describe("HostTransferProxy — targetClientId", () => {
835
835
  "user-C",
836
836
  );
837
837
 
838
- await waitForMessages(sentMessages, 1);
839
- const sent = sentMessages[0] as Record<string, unknown>;
840
- expect(sent.targetClientId).toBeUndefined();
841
-
842
- const requestId = sent.requestId as string;
843
- proxy.resolveTransferResult(requestId, { isError: false });
844
- await resultPromise;
838
+ expect(result).toMatchObject({ isError: true });
839
+ expect(result.content).toContain("belongs to this user");
840
+ expect(sentMessages).toHaveLength(0);
845
841
  });
846
842
 
847
843
  test("requestToSandbox: targeted request from a different user is rejected", async () => {
@@ -897,6 +893,31 @@ describe("HostTransferProxy — targetClientId", () => {
897
893
  await resultPromise;
898
894
  });
899
895
 
896
+ test("requestToSandbox: rejects when no client matches the source user", async () => {
897
+ setup();
898
+ const a: MockClient = {
899
+ clientId: "client-A",
900
+ capabilities: ["host_file"],
901
+ actorPrincipalId: "user-A",
902
+ };
903
+ mockCapableClients = [a];
904
+ mockClientRegistry.set("client-A", a);
905
+
906
+ const result = await proxy.requestToSandbox(
907
+ {
908
+ sourcePath: "/host/source.txt",
909
+ destPath: "/sandbox/dest.txt",
910
+ conversationId: "conv-same-9-no-match",
911
+ },
912
+ undefined,
913
+ "user-C",
914
+ );
915
+
916
+ expect(result).toMatchObject({ isError: true });
917
+ expect(result.content).toContain("belongs to this user");
918
+ expect(sentMessages).toHaveLength(0);
919
+ });
920
+
900
921
  test("requestToSandbox: auto-resolve picks the same-user client when there's exactly one", async () => {
901
922
  setup();
902
923
  const a: MockClient = {
@@ -249,17 +249,21 @@ describe("createResolveToolsCallback — toolContextPin", () => {
249
249
  });
250
250
  }
251
251
 
252
- test("control: without a pin, a clientless fork drops every client-gated tool but ui_show", () => {
252
+ test("control: without a pin, a clientless fork drops client-gated tools but keeps host and UI tools", () => {
253
253
  projectedSkillToolNames = [];
254
254
  const resolve = createResolveToolsCallback(
255
255
  CLIENT_GATED_DEFS,
256
256
  clientlessExecutionCtx(),
257
257
  )!;
258
258
 
259
- // ui_show stays on the wire: background UI surfaces persist and return
260
- // instead of awaiting action, so they no longer need a connected client.
259
+ // Host tool definitions stay on the wire for background turns, and
260
+ // ui_show stays because background UI surfaces persist and return. The
261
+ // executor rejects host tools on explicitly non-interactive turns before
262
+ // dispatch; ask_question and request_system_permission remain
263
+ // client-gated at resolution.
261
264
  expect(resolve(EMPTY_HISTORY).map((t) => t.name)).toEqual([
262
265
  "remember",
266
+ "host_bash",
263
267
  "ui_show",
264
268
  ]);
265
269
  });
@@ -296,9 +300,12 @@ describe("createResolveToolsCallback — toolContextPin", () => {
296
300
  }),
297
301
  )!;
298
302
 
299
- // ui_show survives the clientless pin: it persists and returns.
303
+ // Host tool definitions stay on the wire for background turns, and
304
+ // ui_show survives the clientless pin: it persists and returns. The
305
+ // executor rejects host tools on explicitly non-interactive turns.
300
306
  expect(resolve(EMPTY_HISTORY).map((t) => t.name)).toEqual([
301
307
  "remember",
308
+ "host_bash",
302
309
  "ui_show",
303
310
  ]);
304
311
  });
@@ -1,30 +1,7 @@
1
1
  /**
2
- * Tests for `isToolActiveForContext` host-tool capability gating.
3
- *
4
- * Scenarios verified:
5
- * - chrome-extension is its own executor and is exempt from the hasNoClient
6
- * gate (the extension's own popup UI gates commands; there is no SSE
7
- * interactive approval channel, and chrome-extension turns intentionally
8
- * run with `hasNoClient: true` because chrome-extension is not in
9
- * `INTERACTIVE_INTERFACES`).
10
- * - macos requires a connected SSE client for host tools that flow through
11
- * the proxy (e.g. host_bash, host_file_*, host_browser), so
12
- * `hasNoClient: true` denies those on macos.
13
- * - host_browser IS in the macos capability set — the proxy routes
14
- * host_browser_request frames to the desktop client via SSE (or via the
15
- * Chrome extension registry when an extension connection is present).
16
- *
17
- * The per-capability check (`supportsHostProxy(transport, capability)`) runs
18
- * first and is authoritative for structural support, so host_bash and
19
- * host_file_* are filtered out for chrome-extension regardless of the
20
- * hasNoClient flag.
21
- *
22
- * Cross-client exception: tools whose capabilities are in
23
- * CROSS_CLIENT_EXPOSED_CAPABILITIES (host_bash, host_file, host_browser)
24
- * are allowed for non-host-proxy interactive interfaces ("web", "ios")
25
- * when at least one capable client is connected via the event hub.
26
- * chrome-extension is excluded as a security boundary, regardless of
27
- * whether the capability is technically supported elsewhere.
2
+ * Tests for host-tool schema visibility. Host tool definitions stay stable
3
+ * across interactive and background turns. Chrome extension remains limited
4
+ * to host_browser because it is the only host capability it implements.
28
5
  */
29
6
 
30
7
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -311,7 +288,6 @@ describe("isToolActiveForContext - Slack task_progress UI exception", () => {
311
288
  });
312
289
 
313
290
  describe("isToolActiveForContext — host tool capability gating", () => {
314
- // macOS transport: SSE-based interactive approval required.
315
291
  test("host_bash is active for macOS with a connected client", () => {
316
292
  expect(
317
293
  isToolActiveForContext(
@@ -321,27 +297,22 @@ describe("isToolActiveForContext — host tool capability gating", () => {
321
297
  ).toBe(true);
322
298
  });
323
299
 
324
- test("host_bash is NOT active for macOS when hasNoClient is true (security invariant)", () => {
325
- // macOS uses an SSE-based interactive approval channel. Without a
326
- // connected client the guardian auto-approve path could execute host
327
- // commands unattended, so host tools must be denied.
300
+ test("host_bash remains active for a background macOS turn", () => {
328
301
  expect(
329
302
  isToolActiveForContext(
330
303
  "host_bash",
331
304
  makeCtx({ hasNoClient: true, transportInterface: "macos" }),
332
305
  ),
333
- ).toBe(false);
306
+ ).toBe(true);
334
307
  });
335
-
336
- test("host_file_read is NOT active for macOS when hasNoClient is true", () => {
308
+ test("host_file_read remains active for a background macOS turn", () => {
337
309
  expect(
338
310
  isToolActiveForContext(
339
311
  "host_file_read",
340
312
  makeCtx({ hasNoClient: true, transportInterface: "macos" }),
341
313
  ),
342
- ).toBe(false);
314
+ ).toBe(true);
343
315
  });
344
-
345
316
  test("host_browser is active for macOS with a connected client", () => {
346
317
  // macOS supports host_browser — the proxy routes host_browser_request
347
318
  // frames to the desktop client via SSE (or via the Chrome extension
@@ -354,19 +325,14 @@ describe("isToolActiveForContext — host tool capability gating", () => {
354
325
  ).toBe(true);
355
326
  });
356
327
 
357
- test("host_browser is NOT active for macOS when hasNoClient is true", () => {
358
- // macOS supports host_browser structurally, but without a connected
359
- // client the host_browser_request frames have no consumer, so the tool
360
- // is denied.
328
+ test("host_browser remains active for a background macOS turn", () => {
361
329
  expect(
362
330
  isToolActiveForContext(
363
331
  "host_browser",
364
332
  makeCtx({ hasNoClient: true, transportInterface: "macos" }),
365
333
  ),
366
- ).toBe(false);
334
+ ).toBe(true);
367
335
  });
368
-
369
- // chrome-extension transport: the extension is its own executor.
370
336
  test("host_browser is active for chrome-extension even when hasNoClient is true", () => {
371
337
  // chrome-extension turns run with `hasNoClient: true` by design because
372
338
  // chrome-extension is not in `INTERACTIVE_INTERFACES` — it is not an
@@ -422,10 +388,7 @@ describe("isToolActiveForContext — host tool capability gating", () => {
422
388
  ).toBe(false);
423
389
  });
424
390
 
425
- // Backwards-compat fallback: no transport plumbed through.
426
- test("host_bash falls back to hasNoClient gate when transport is undefined (client connected)", () => {
427
- // Without a transport interface we cannot run the per-capability check,
428
- // so we fall back to the coarse-grained `hasNoClient` behavior.
391
+ test("host_bash remains active without transport metadata for a connected turn", () => {
429
392
  expect(
430
393
  isToolActiveForContext(
431
394
  "host_bash",
@@ -434,20 +397,18 @@ describe("isToolActiveForContext — host tool capability gating", () => {
434
397
  ).toBe(true);
435
398
  });
436
399
 
437
- test("host_bash falls back to hasNoClient gate when transport is undefined (no client)", () => {
400
+ test("host_bash remains active without transport metadata for a background turn", () => {
438
401
  expect(
439
402
  isToolActiveForContext(
440
403
  "host_bash",
441
404
  makeCtx({ hasNoClient: true, transportInterface: undefined }),
442
405
  ),
443
- ).toBe(false);
406
+ ).toBe(true);
444
407
  });
445
408
  });
446
409
 
447
410
  describe("isToolActiveForContext — cross-client exception (Phase 1: host_bash)", () => {
448
411
  test("host_bash is active for web transport when a host_bash-capable client is connected", () => {
449
- // Cross-client path: a web turn should see host_bash when a macOS client
450
- // with host_bash capability is connected via the event hub.
451
412
  mockClientCountByCapability.set("host_bash", 1);
452
413
  expect(
453
414
  isToolActiveForContext(
@@ -457,30 +418,24 @@ describe("isToolActiveForContext — cross-client exception (Phase 1: host_bash)
457
418
  ).toBe(true);
458
419
  });
459
420
 
460
- test("host_bash is NOT active for web transport when no capable client is connected", () => {
461
- // No cross-client fallback: hub has no host_bash-capable subscribers.
421
+ test("host_bash remains active for web when no capable client is connected", () => {
462
422
  mockClientCountByCapability.set("host_bash", 0);
463
423
  expect(
464
424
  isToolActiveForContext(
465
425
  "host_bash",
466
426
  makeCtx({ hasNoClient: false, transportInterface: "web" }),
467
427
  ),
468
- ).toBe(false);
428
+ ).toBe(true);
469
429
  });
470
-
471
- test("host_file_read is NOT active for web transport when only a host_bash client is connected", () => {
472
- // The cross-client exception is per-capability: a host_bash-capable
473
- // client in the hub does not satisfy host_file's exposure check, since
474
- // listClientsByCapability is queried with the tool's actual capability.
430
+ test("host_file_read remains active for web without host_file clients", () => {
475
431
  mockClientCountByCapability.set("host_bash", 1);
476
432
  expect(
477
433
  isToolActiveForContext(
478
434
  "host_file_read",
479
435
  makeCtx({ hasNoClient: false, transportInterface: "web" }),
480
436
  ),
481
- ).toBe(false);
437
+ ).toBe(true);
482
438
  });
483
-
484
439
  test("host_bash for macos transport is unaffected by the cross-client exception", () => {
485
440
  // macos natively supports host_bash via host proxy — the supportsHostProxy
486
441
  // check passes, so the cross-client branch is never reached.
@@ -493,19 +448,15 @@ describe("isToolActiveForContext — cross-client exception (Phase 1: host_bash)
493
448
  ).toBe(true);
494
449
  });
495
450
 
496
- test("host_bash for macos with no client is still denied (security invariant unaffected)", () => {
497
- // Even with a capable client in the hub, the macos SSE path takes
498
- // precedence — it passes the supportsHostProxy check, bypasses the
499
- // cross-client branch, and reaches the hasNoClient gate.
451
+ test("host_bash remains active for a background macOS turn", () => {
500
452
  mockClientCountByCapability.set("host_bash", 1);
501
453
  expect(
502
454
  isToolActiveForContext(
503
455
  "host_bash",
504
456
  makeCtx({ hasNoClient: true, transportInterface: "macos" }),
505
457
  ),
506
- ).toBe(false);
458
+ ).toBe(true);
507
459
  });
508
-
509
460
  test("host_bash is NOT active for chrome-extension even when a capable client is connected", () => {
510
461
  // Security boundary: chrome-extension only gets host_browser. The
511
462
  // cross-client exception explicitly excludes chrome-extension transport
@@ -519,16 +470,14 @@ describe("isToolActiveForContext — cross-client exception (Phase 1: host_bash)
519
470
  ).toBe(false);
520
471
  });
521
472
 
522
- test("host_bash is NOT active for web transport when hasNoClient is true (no approval UI)", () => {
523
- // hasNoClient gate: no interactive approval UI available for this turn.
524
- // Cross-client exception must not bypass this gate.
473
+ test("host_bash remains active for a background web turn", () => {
525
474
  mockClientCountByCapability.set("host_bash", 1);
526
475
  expect(
527
476
  isToolActiveForContext(
528
477
  "host_bash",
529
478
  makeCtx({ hasNoClient: true, transportInterface: "web" }),
530
479
  ),
531
- ).toBe(false);
480
+ ).toBe(true);
532
481
  });
533
482
  });
534
483
 
@@ -551,14 +500,14 @@ describe("isToolActiveForContext — cross-client exposure for host_file_*", ()
551
500
  ).toBe(true);
552
501
  });
553
502
 
554
- test(`${tool} is NOT exposed for web when no host_file client is connected`, () => {
503
+ test(`${tool} remains active for web when no host_file client is connected`, () => {
555
504
  mockClientCountByCapability.set("host_file", 0);
556
505
  expect(
557
506
  isToolActiveForContext(
558
507
  tool,
559
508
  makeCtx({ hasNoClient: false, transportInterface: "web" }),
560
509
  ),
561
- ).toBe(false);
510
+ ).toBe(true);
562
511
  });
563
512
 
564
513
  test(`${tool} is NOT exposed for chrome-extension (security boundary)`, () => {
@@ -574,20 +523,19 @@ describe("isToolActiveForContext — cross-client exposure for host_file_*", ()
574
523
  ).toBe(false);
575
524
  });
576
525
 
577
- test(`${tool} is NOT exposed when hasNoClient is true (no approval UI)`, () => {
526
+ test(`${tool} remains active for a background web turn`, () => {
578
527
  mockClientCountByCapability.set("host_file", 1);
579
528
  expect(
580
529
  isToolActiveForContext(
581
530
  tool,
582
531
  makeCtx({ hasNoClient: true, transportInterface: "web" }),
583
532
  ),
584
- ).toBe(false);
533
+ ).toBe(true);
585
534
  });
586
535
  }
587
536
 
588
- test("listClientsByCapability is queried with the actual capability, not host_bash (regression guard for D5 latent bug)", () => {
589
- mockClientCountByCapability.set("host_bash", 0);
590
- mockClientCountByCapability.set("host_file", 1);
537
+ test("host_file_transfer does not depend on the live host_file roster", () => {
538
+ mockClientCountByCapability.set("host_file", 0);
591
539
  expect(
592
540
  isToolActiveForContext(
593
541
  "host_file_transfer",
@@ -626,37 +574,33 @@ describe("isToolActiveForContext — cross-client exposure for host_browser", ()
626
574
  ).toBe(true);
627
575
  });
628
576
 
629
- test("host_browser is NOT exposed for web when no host_browser client is connected", () => {
577
+ test("host_browser remains active for web when no host_browser client is connected", () => {
630
578
  mockClientCountByCapability.set("host_browser", 0);
631
579
  expect(
632
580
  isToolActiveForContext(
633
581
  "host_browser",
634
582
  makeCtx({ hasNoClient: false, transportInterface: "web" }),
635
583
  ),
636
- ).toBe(false);
584
+ ).toBe(true);
637
585
  });
638
-
639
- test("host_browser is NOT exposed for ios when no host_browser client is connected", () => {
586
+ test("host_browser remains active for ios when no host_browser client is connected", () => {
640
587
  mockClientCountByCapability.set("host_browser", 0);
641
588
  expect(
642
589
  isToolActiveForContext(
643
590
  "host_browser",
644
591
  makeCtx({ hasNoClient: false, transportInterface: "ios" }),
645
592
  ),
646
- ).toBe(false);
593
+ ).toBe(true);
647
594
  });
648
-
649
- test("host_browser is NOT exposed when hasNoClient is true (no approval UI)", () => {
650
- // hasNoClient gate: cross-client exception must not bypass this.
595
+ test("host_browser remains active for a background web turn", () => {
651
596
  mockClientCountByCapability.set("host_browser", 1);
652
597
  expect(
653
598
  isToolActiveForContext(
654
599
  "host_browser",
655
600
  makeCtx({ hasNoClient: true, transportInterface: "web" }),
656
601
  ),
657
- ).toBe(false);
602
+ ).toBe(true);
658
603
  });
659
-
660
604
  test("host_browser for macos transport is unaffected by the cross-client exception", () => {
661
605
  // macos natively supports host_browser via host proxy — the
662
606
  // supportsHostProxy check passes, so the cross-client branch is never
@@ -685,10 +629,7 @@ describe("isToolActiveForContext — cross-client exposure for host_browser", ()
685
629
  ).toBe(true);
686
630
  });
687
631
 
688
- test("listClientsByCapability is queried with host_browser, not host_bash or host_file (per-capability invariant)", () => {
689
- // Defense against any future regression that hardcodes a different
690
- // capability in the cross-client check. Only host_browser-capable
691
- // clients should satisfy host_browser exposure.
632
+ test("host_browser does not depend on the live host_browser roster", () => {
692
633
  mockClientCountByCapability.set("host_bash", 1);
693
634
  mockClientCountByCapability.set("host_file", 1);
694
635
  mockClientCountByCapability.set("host_browser", 0);
@@ -697,7 +638,7 @@ describe("isToolActiveForContext — cross-client exposure for host_browser", ()
697
638
  "host_browser",
698
639
  makeCtx({ hasNoClient: false, transportInterface: "web" }),
699
640
  ),
700
- ).toBe(false);
641
+ ).toBe(true);
701
642
  });
702
643
  });
703
644
 
@@ -28,7 +28,6 @@ import { getAllDefaultPluginNames } from "../plugins/defaults/main.js";
28
28
  import { isActivationSession } from "../plugins/defaults/memory/activation-session-store.js";
29
29
  import { isPluginDisabled } from "../plugins/disabled-state.js";
30
30
  import type { Message, ToolDefinition } from "../providers/types.js";
31
- import { assistantEventHub } from "../runtime/assistant-event-hub.js";
32
31
  import { registerConversationSender } from "../tools/browser/browser-screencast.js";
33
32
  import { supportsClientOsForSkillTool } from "../tools/client-os.js";
34
33
  import type { ToolExecutor } from "../tools/executor.js";
@@ -260,6 +259,24 @@ export function createToolExecutor(
260
259
  // see {@link SubagentToolGateMode}): rejects non-allowlisted calls BEFORE
261
260
  // any executor dispatch, so a non-allowlisted tool's executor never runs.
262
261
  // The error tool_result lets the model continue or finish.
262
+ const rejectUnattendedHostTool = (
263
+ toolName: string,
264
+ ): ToolExecutionResult | null => {
265
+ const { transportInterface } = resolveTurnClientOs(ctx);
266
+ const isUnattended = ctx.currentTurnIsNonInteractive ?? ctx.hasNoClient;
267
+ if (
268
+ HOST_TOOL_NAMES.has(toolName) &&
269
+ isUnattended &&
270
+ transportInterface !== "chrome-extension"
271
+ ) {
272
+ return {
273
+ content: `The "${toolName}" tool requires an interactive user turn and cannot run in the background.`,
274
+ isError: true,
275
+ };
276
+ }
277
+ return null;
278
+ };
279
+
263
280
  const rejectNonAllowlistedTool = (
264
281
  toolName: string,
265
282
  ): ToolExecutionResult | null => {
@@ -387,6 +404,10 @@ export function createToolExecutor(
387
404
  if (rejection) {
388
405
  return rejection;
389
406
  }
407
+ const unattendedRejection = rejectUnattendedHostTool(executionName);
408
+ if (unattendedRejection) {
409
+ return unattendedRejection;
410
+ }
390
411
  }
391
412
 
392
413
  if (isDoordashCommand(executionName, executionInput)) {
@@ -550,6 +571,10 @@ export function createToolExecutor(
550
571
  if (innerRejection) {
551
572
  return innerRejection;
552
573
  }
574
+ const unattendedRejection = rejectUnattendedHostTool(toolName);
575
+ if (unattendedRejection) {
576
+ return unattendedRejection;
577
+ }
553
578
 
554
579
  // Per-chat plugin scope: reject the resolved inner tool when it belongs
555
580
  // to a plugin outside the conversation's effective set.
@@ -864,44 +889,17 @@ export function isToolActiveForContext(
864
889
  const capability = HOST_TOOL_TO_CAPABILITY.get(name);
865
890
  const transport = transportInterface;
866
891
 
867
- // Per-capability check is authoritative for structural support: if the
868
- // transport cannot service this capability, the tool is filtered out.
892
+ // A transport that does not implement a capability can invoke it through
893
+ // an eligible same-user client. Client selection happens when the call
894
+ // runs, so the wire schema stays stable across live and background turns.
869
895
  if (transport && capability && !supportsHostProxy(transport, capability)) {
870
- // Cross-client exception: allow host tools whose capabilities have
871
- // cross-client routing infrastructure (Phases 1–3 plus host_browser
872
- // via PR #27489) to be exposed for non-host-proxy transports (e.g.
873
- // "web", "ios") when at least one capable client is connected via
874
- // the event hub. Members of CROSS_CLIENT_EXPOSED_CAPABILITIES
875
- // (host_bash, host_file, host_browser) qualify.
876
- // chrome-extension transport is excluded as a security boundary
877
- // (extension only gets host_browser via its own executor path);
878
- // hasNoClient turns are excluded (no interactive approval UI
879
- // available).
880
- if (
881
- capability &&
896
+ return (
882
897
  CROSS_CLIENT_EXPOSED_CAPABILITIES.has(capability) &&
883
- transport !== "chrome-extension" &&
884
- !hasNoClient &&
885
- assistantEventHub.listClientsByCapability(capability).length > 0
886
- ) {
887
- return true;
888
- }
889
- return false;
890
- }
891
-
892
- // chrome-extension is its own executor — the extension's popup gates
893
- // commands via its own UI, and the transport does not use an SSE-level
894
- // interactive approval channel. hasNoClient is intentionally `true` for
895
- // chrome-extension turns (chrome-extension is not in INTERACTIVE_INTERFACES)
896
- // and must not gate host_browser. Trust the per-capability check.
897
- if (transport === "chrome-extension") {
898
- return true;
898
+ transport !== "chrome-extension"
899
+ );
899
900
  }
900
901
 
901
- // For transports that surface approvals over SSE (macos, backwards-compat
902
- // fallback), deny when no client is present so the guardian auto-approve
903
- // path cannot execute host commands unattended.
904
- return !hasNoClient;
902
+ return true;
905
903
  }
906
904
  if (CLIENT_CAPABILITY_TOOL_NAMES.has(name)) {
907
905
  if (name === "ask_question" && channelCapabilities?.clientOS === "macos") {
@@ -1,9 +1,9 @@
1
1
  import { getConfig } from "../config/loader.js";
2
2
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
3
3
  import {
4
- ambiguousSameUserError,
5
4
  enforceSameActorOrErrorResult,
6
- pickSameUserAutoResolve,
5
+ pickMostRecentSameUserClient,
6
+ unavailableSameUserClientError,
7
7
  } from "../runtime/auth/same-actor.js";
8
8
  import * as pendingInteractions from "../runtime/pending-interactions.js";
9
9
  import { formatShellOutput } from "../tools/shared/shell-output.js";
@@ -93,21 +93,17 @@ export class HostBashProxy extends HostProxyBase<
93
93
  }
94
94
  resolvedTargetClientId = input.targetClientId;
95
95
  } else {
96
- // Auto-resolve to the unique same-user client. Reject (rather than
97
- // broadcast) when multiple same-user clients are connected so that
98
- // a single targeted-style request cannot fan out across every one
99
- // of the user's machines. Zero same-user matches falls through to
100
- // the existing untargeted code path.
101
- const resolved = pickSameUserAutoResolve({
96
+ resolvedTargetClientId = pickMostRecentSameUserClient({
102
97
  hub: assistantEventHub,
103
98
  capability: "host_bash",
104
99
  sourceActorPrincipalId,
105
100
  });
106
- if (resolved.kind === "ambiguous") {
107
- return ambiguousSameUserError("host_bash");
101
+ if (
102
+ resolvedTargetClientId === undefined &&
103
+ sourceActorPrincipalId !== undefined
104
+ ) {
105
+ return unavailableSameUserClientError("host_bash");
108
106
  }
109
- resolvedTargetClientId =
110
- resolved.kind === "match" ? resolved.clientId : undefined;
111
107
  }
112
108
 
113
109
  // Targeted requests must be bound to the same authenticated user as the
@@ -5,7 +5,10 @@ import {
5
5
  assistantEventHub,
6
6
  broadcastMessage,
7
7
  } from "../runtime/assistant-event-hub.js";
8
- import { enforceSameActorOrErrorResult } from "../runtime/auth/same-actor.js";
8
+ import {
9
+ enforceSameActorOrErrorResult,
10
+ unavailableSameUserClientError,
11
+ } from "../runtime/auth/same-actor.js";
9
12
  import * as pendingInteractions from "../runtime/pending-interactions.js";
10
13
  import type { ToolExecutionResult } from "../tools/types.js";
11
14
  import { AssistantError, ErrorCode } from "../util/errors.js";
@@ -284,6 +287,17 @@ export class HostBrowserProxy {
284
287
  targetClientId,
285
288
  );
286
289
 
290
+ if (
291
+ preferredClient === undefined &&
292
+ sourceActorPrincipalId !== undefined &&
293
+ targetClientId === undefined
294
+ ) {
295
+ if (isExtensionOnlyMethod(input.cdpMethod)) {
296
+ return Promise.resolve(extensionRequiredResult(input.cdpMethod));
297
+ }
298
+ return Promise.resolve(unavailableSameUserClientError("host_browser"));
299
+ }
300
+
287
301
  // Same-user enforcement: when the caller's actor is known, refuse to
288
302
  // dispatch to a client owned by a different actor. This covers the
289
303
  // cross-client exposure case where a web/iOS turn for actor A would
@@ -6,10 +6,10 @@ import {
6
6
  broadcastMessage,
7
7
  } from "../runtime/assistant-event-hub.js";
8
8
  import {
9
- ambiguousSameUserError,
10
9
  enforceSameActorOrErrorResult,
11
- pickSameUserAutoResolve,
10
+ pickMostRecentSameUserClient,
12
11
  snapshotHostProxyActorPrincipalId,
12
+ unavailableSameUserClientError,
13
13
  } from "../runtime/auth/same-actor.js";
14
14
  import * as pendingInteractions from "../runtime/pending-interactions.js";
15
15
  import { readAudioBase64 } from "../tools/shared/filesystem/audio-read.js";
@@ -102,19 +102,17 @@ export class HostFileProxy {
102
102
  });
103
103
  }
104
104
  } else {
105
- // Auto-resolve to the unique same-user client. Reject ambiguous
106
- // (multi-machine) cases so a single targeted-style request cannot
107
- // fan out across the user's machines.
108
- const resolved = pickSameUserAutoResolve({
105
+ resolvedTargetClientId = pickMostRecentSameUserClient({
109
106
  hub: assistantEventHub,
110
107
  capability: "host_file",
111
108
  sourceActorPrincipalId,
112
109
  });
113
- if (resolved.kind === "ambiguous") {
114
- return Promise.resolve(ambiguousSameUserError("host_file"));
110
+ if (
111
+ resolvedTargetClientId === undefined &&
112
+ sourceActorPrincipalId !== undefined
113
+ ) {
114
+ return Promise.resolve(unavailableSameUserClientError("host_file"));
115
115
  }
116
- resolvedTargetClientId =
117
- resolved.kind === "match" ? resolved.clientId : undefined;
118
116
  }
119
117
 
120
118
  // Same-user check: targeted host_file requests must be bound to the same
@@ -10,10 +10,10 @@ import {
10
10
  broadcastMessage,
11
11
  } from "../runtime/assistant-event-hub.js";
12
12
  import {
13
- ambiguousSameUserError,
14
13
  enforceSameActorOrErrorResult,
15
- pickSameUserAutoResolve,
14
+ pickMostRecentSameUserClient,
16
15
  snapshotHostProxyActorPrincipalId,
16
+ unavailableSameUserClientError,
17
17
  } from "../runtime/auth/same-actor.js";
18
18
  import * as pendingInteractions from "../runtime/pending-interactions.js";
19
19
  import type { ToolExecutionResult } from "../tools/types.js";
@@ -162,19 +162,17 @@ export class HostTransferProxy {
162
162
  });
163
163
  }
164
164
  } else {
165
- // Auto-resolve to the unique same-user client; reject ambiguous
166
- // (multi-machine) cases so a single targeted-style transfer cannot
167
- // fan out across the user's machines.
168
- const resolved = pickSameUserAutoResolve({
165
+ resolvedTargetClientId = pickMostRecentSameUserClient({
169
166
  hub: assistantEventHub,
170
167
  capability: "host_file",
171
168
  sourceActorPrincipalId,
172
169
  });
173
- if (resolved.kind === "ambiguous") {
174
- return Promise.resolve(ambiguousSameUserError("host_file"));
170
+ if (
171
+ resolvedTargetClientId === undefined &&
172
+ sourceActorPrincipalId !== undefined
173
+ ) {
174
+ return Promise.resolve(unavailableSameUserClientError("host_file"));
175
175
  }
176
- resolvedTargetClientId =
177
- resolved.kind === "match" ? resolved.clientId : undefined;
178
176
  }
179
177
 
180
178
  if (resolvedTargetClientId != null) {
@@ -364,19 +362,17 @@ export class HostTransferProxy {
364
362
  });
365
363
  }
366
364
  } else {
367
- // Auto-resolve to the unique same-user client; reject ambiguous
368
- // (multi-machine) cases so a single targeted-style transfer cannot
369
- // fan out across the user's machines.
370
- const resolved = pickSameUserAutoResolve({
365
+ resolvedTargetClientId = pickMostRecentSameUserClient({
371
366
  hub: assistantEventHub,
372
367
  capability: "host_file",
373
368
  sourceActorPrincipalId,
374
369
  });
375
- if (resolved.kind === "ambiguous") {
376
- return Promise.resolve(ambiguousSameUserError("host_file"));
370
+ if (
371
+ resolvedTargetClientId === undefined &&
372
+ sourceActorPrincipalId !== undefined
373
+ ) {
374
+ return Promise.resolve(unavailableSameUserClientError("host_file"));
377
375
  }
378
- resolvedTargetClientId =
379
- resolved.kind === "match" ? resolved.clientId : undefined;
380
376
  }
381
377
 
382
378
  if (resolvedTargetClientId != null) {
@@ -142,9 +142,7 @@ function detectRejection(
142
142
  ? args.hub.getActorPrincipalIdForClient(args.targetClientId)
143
143
  : (args.targetActorPrincipalId ??
144
144
  (isHttpAuthDisabled() && targetClientId
145
- ? args.hubForMissingTarget?.getActorPrincipalIdForClient(
146
- targetClientId,
147
- )
145
+ ? args.hubForMissingTarget?.getActorPrincipalIdForClient(targetClientId)
148
146
  : undefined));
149
147
 
150
148
  let reason: RejectionReason | undefined;
@@ -264,6 +262,43 @@ export function pickSameUserAutoResolve(args: {
264
262
  return { kind: "ambiguous" };
265
263
  }
266
264
 
265
+ /**
266
+ * Pick the most recently active capable client owned by the turn actor.
267
+ * `listClientsByCapability` is ordered by recent activity, so the first
268
+ * same-user match provides deterministic routing without broadcasting a host
269
+ * request across multiple devices.
270
+ */
271
+ export function pickMostRecentSameUserClient(args: {
272
+ hub: Pick<AssistantEventHub, "listClientsByCapability">;
273
+ capability: HostProxyCapability;
274
+ sourceActorPrincipalId: string | undefined;
275
+ }): string | undefined {
276
+ const { hub, capability, sourceActorPrincipalId } = args;
277
+ if (sourceActorPrincipalId == null) {
278
+ return undefined;
279
+ }
280
+ return hub
281
+ .listClientsByCapability(capability)
282
+ .find((client) => client.actorPrincipalId === sourceActorPrincipalId)
283
+ ?.clientId;
284
+ }
285
+
286
+ /**
287
+ * Error result for an automatic host route with no capable client owned by the
288
+ * actor that initiated the turn.
289
+ */
290
+ export function unavailableSameUserClientError(
291
+ capability: HostProxyCapability,
292
+ ): {
293
+ content: string;
294
+ isError: true;
295
+ } {
296
+ return {
297
+ content: `No connected ${capability} client belongs to this user. Connect a capable client signed in as the same user, then retry.`,
298
+ isError: true,
299
+ };
300
+ }
301
+
267
302
  /**
268
303
  * Actor principal persisted on a host-proxy pending interaction.
269
304
  *
@@ -18,10 +18,7 @@ import { getConfig } from "../../config/loader.js";
18
18
  import { HostBashProxy } from "../../daemon/host-bash-proxy.js";
19
19
  import { RiskLevel } from "../../permissions/types.js";
20
20
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
21
- import {
22
- assistantEventHub,
23
- broadcastMessage,
24
- } from "../../runtime/assistant-event-hub.js";
21
+ import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
25
22
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
26
23
  import { redactSecrets } from "../../security/secret-scanner.js";
27
24
  import {
@@ -142,7 +139,7 @@ export const hostShellInputSchema = z.looseObject({
142
139
  target_client_id: z
143
140
  .string()
144
141
  .describe(
145
- "ID of the specific client to execute this command on. Required when multiple clients support host_bash; omit when only one client is connected. Obtain IDs from `assistant clients list --capability host_bash`.",
142
+ "Optional ID of the specific client to execute this command on. Without it, the most recently active eligible client is used. Obtain IDs from `assistant clients list --capability host_bash`.",
146
143
  )
147
144
  .optional()
148
145
  .catch(undefined),
@@ -203,20 +200,7 @@ export const hostShellTool = {
203
200
  const config = getConfig();
204
201
  const { shellDefaultTimeoutSec, shellMaxTimeoutSec } = config.timeouts;
205
202
 
206
- // Guard: non-host-proxy interfaces need an explicit target when multiple
207
- // capable clients are connected to avoid ambiguous untargeted broadcasts.
208
203
  const transportInterface = context.transportInterface;
209
- if (
210
- targetClientId == null &&
211
- transportInterface != null &&
212
- !supportsHostProxy(transportInterface) &&
213
- assistantEventHub.listClientsByCapability("host_bash").length > 1
214
- ) {
215
- return {
216
- content: `Error: multiple clients support host_bash. Specify which client to use with \`target_client_id\`. Run \`assistant clients list --capability host_bash\` to see client IDs and labels.`,
217
- isError: true,
218
- };
219
- }
220
204
 
221
205
  // Guard: non-host-proxy interfaces with no capable clients connected.
222
206
  if (