@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 +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +10 -6
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +51 -0
- package/src/__tests__/host-bash-proxy.test.ts +26 -45
- package/src/__tests__/host-browser-proxy.test.ts +6 -17
- package/src/__tests__/host-file-proxy-targeted.test.ts +23 -0
- package/src/__tests__/host-file-proxy.test.ts +11 -23
- package/src/__tests__/host-transfer-proxy-targeted.test.ts +30 -9
- package/src/__tests__/subagent-tool-gate-mode.test.ts +11 -4
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +34 -93
- package/src/daemon/conversation-tool-setup.ts +33 -35
- package/src/daemon/host-bash-proxy.ts +8 -12
- package/src/daemon/host-browser-proxy.ts +15 -1
- package/src/daemon/host-file-proxy.ts +8 -10
- package/src/daemon/host-transfer-proxy.ts +14 -18
- package/src/runtime/auth/same-actor.ts +38 -3
- package/src/tools/host-terminal/host-shell.ts +2 -18
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ afterAll(() => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
describe("always-loaded tool count", () => {
|
|
29
|
-
test("should be exactly
|
|
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(
|
|
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("
|
|
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
|
|
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
|
-
|
|
686
|
-
expect(
|
|
687
|
-
|
|
688
|
-
|
|
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("
|
|
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
|
|
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(
|
|
847
|
-
|
|
848
|
-
expect(
|
|
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("
|
|
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
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
expect(sentMessages).toHaveLength(
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
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
|
|
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
|
-
|
|
745
|
-
|
|
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("
|
|
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
|
|
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",
|
|
764
|
+
"user-B",
|
|
766
765
|
);
|
|
767
766
|
|
|
768
|
-
expect(
|
|
769
|
-
|
|
770
|
-
expect(
|
|
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("
|
|
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
|
|
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
|
-
|
|
829
|
-
expect(
|
|
830
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
expect(
|
|
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
|
|
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
|
-
//
|
|
260
|
-
//
|
|
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
|
-
//
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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}
|
|
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(
|
|
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}
|
|
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(
|
|
533
|
+
).toBe(true);
|
|
585
534
|
});
|
|
586
535
|
}
|
|
587
536
|
|
|
588
|
-
test("
|
|
589
|
-
mockClientCountByCapability.set("
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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
|
-
//
|
|
868
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
107
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
174
|
-
|
|
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
|
-
|
|
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 (
|
|
376
|
-
|
|
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.
|
|
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 (
|