switchroom 0.10.0 → 0.11.1

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.
Files changed (52) hide show
  1. package/README.md +5 -4
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +125 -3
  4. package/dist/cli/drive-write-pretool.mjs +5436 -0
  5. package/dist/cli/switchroom.js +231 -29
  6. package/dist/host-control/main.js +2 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +1 -1
  10. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  11. package/telegram-plugin/admin-commands/index.ts +2 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  13. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  14. package/telegram-plugin/auto-fallback.ts +28 -301
  15. package/telegram-plugin/dist/gateway/gateway.js +4314 -2143
  16. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  17. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  18. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  19. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  20. package/telegram-plugin/gateway/auth-command.ts +131 -10
  21. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  22. package/telegram-plugin/gateway/boot-card.ts +1 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +6 -9
  24. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  25. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  26. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  27. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  28. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  29. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  30. package/telegram-plugin/gateway/gateway.ts +903 -173
  31. package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
  32. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  33. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  34. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  35. package/telegram-plugin/model-unavailable.ts +28 -12
  36. package/telegram-plugin/silence-poke.ts +153 -1
  37. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  38. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  39. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  40. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  41. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  42. package/telegram-plugin/tests/boot-probes.test.ts +16 -18
  43. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  44. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  45. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  46. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  47. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  48. package/telegram-plugin/turn-flush-safety.ts +55 -1
  49. package/telegram-plugin/uat/SETUP.md +16 -12
  50. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  51. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  52. package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
@@ -1,183 +0,0 @@
1
- /**
2
- * End-to-end tests for the auto-fallback notification dispatcher
3
- * (#11 / #420 / #421).
4
- *
5
- * `auto-fallback.ts` returns a `FallbackPlan` (pure). This test
6
- * exercises the side-effecting half: given a plan, does the
7
- * dispatcher emit the right Bot API call to the owner chat?
8
- *
9
- * The pure plan logic itself is covered by `auto-fallback.test.ts`.
10
- * This file locks in the wiring so a regression in dispatch (wrong
11
- * parse_mode, missing link-preview disable, swallowed errors) is
12
- * caught here instead of going silent in production.
13
- */
14
-
15
- import { describe, it, expect, beforeEach, vi } from 'vitest';
16
- import {
17
- dispatchFallbackNotification,
18
- type DispatchOutcome,
19
- } from '../auto-fallback-dispatcher.js';
20
- import type { FallbackPlan } from '../auto-fallback.js';
21
- import { createFakeBotApi, errors, type FakeBot } from './fake-bot-api.js';
22
-
23
- const OWNER = 'chat-owner';
24
-
25
- let bot: FakeBot;
26
-
27
- beforeEach(() => {
28
- bot = createFakeBotApi({ startMessageId: 200 });
29
- });
30
-
31
- function planExecuted(): FallbackPlan {
32
- return {
33
- kind: 'executed',
34
- previousSlot: 'default',
35
- newSlot: 'personal',
36
- resetAtMs: Date.now() + 60_000,
37
- notificationHtml:
38
- '⚠️ <b>Quota exhausted</b> on slot <code>default</code>. Switched to <code>personal</code>.',
39
- agentName: 'clerk',
40
- triggerReason: '429-response',
41
- };
42
- }
43
-
44
- function planExhaustedAll(): FallbackPlan {
45
- return {
46
- kind: 'exhausted-all',
47
- activeSlot: 'default',
48
- resetAtMs: Date.now() + 4 * 60 * 60_000,
49
- notificationHtml:
50
- '🚨 <b>All slots quota-exhausted</b> for clerk. Run /auth add to attach another subscription.',
51
- agentName: 'clerk',
52
- };
53
- }
54
-
55
- describe('dispatchFallbackNotification — happy path', () => {
56
- it('sends executed plan with HTML parse_mode + link preview disabled', async () => {
57
- const plan = planExecuted();
58
- const outcome = await dispatchFallbackNotification({
59
- bot,
60
- ownerChatId: OWNER,
61
- plan,
62
- });
63
- expect(outcome).toEqual<DispatchOutcome>({ kind: 'sent', messageId: 200 });
64
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
65
- const [chat, text, opts] = bot.api.sendMessage.mock.calls[0];
66
- expect(chat).toBe(OWNER);
67
- expect(text).toBe(plan.notificationHtml);
68
- expect(opts).toMatchObject({
69
- parse_mode: 'HTML',
70
- link_preview_options: { is_disabled: true },
71
- });
72
- });
73
-
74
- it('sends exhausted-all plan to owner chat', async () => {
75
- const plan = planExhaustedAll();
76
- const outcome = await dispatchFallbackNotification({
77
- bot,
78
- ownerChatId: OWNER,
79
- plan,
80
- });
81
- expect(outcome.kind).toBe('sent');
82
- const text = bot.api.sendMessage.mock.calls[0][1] as string;
83
- expect(text).toContain('All slots quota-exhausted');
84
- expect(text).toContain('clerk');
85
- });
86
-
87
- it('chat model reflects the sent message', async () => {
88
- await dispatchFallbackNotification({
89
- bot,
90
- ownerChatId: OWNER,
91
- plan: planExecuted(),
92
- });
93
- const sent = bot.messagesIn(OWNER);
94
- expect(sent).toHaveLength(1);
95
- expect(sent[0].text).toContain('Quota exhausted');
96
- expect(sent[0].parse_mode).toBe('HTML');
97
- });
98
- });
99
-
100
- describe('dispatchFallbackNotification — no chat', () => {
101
- it('returns no-chat when ownerChatId is null', async () => {
102
- const outcome = await dispatchFallbackNotification({
103
- bot,
104
- ownerChatId: null,
105
- plan: planExecuted(),
106
- });
107
- expect(outcome).toEqual({ kind: 'no-chat' });
108
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
109
- });
110
-
111
- it('returns no-chat when ownerChatId is undefined (access.allowFrom empty)', async () => {
112
- const outcome = await dispatchFallbackNotification({
113
- bot,
114
- ownerChatId: undefined,
115
- plan: planExecuted(),
116
- });
117
- expect(outcome).toEqual({ kind: 'no-chat' });
118
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
119
- });
120
-
121
- it('returns no-chat for empty string', async () => {
122
- const outcome = await dispatchFallbackNotification({
123
- bot,
124
- ownerChatId: '',
125
- plan: planExecuted(),
126
- });
127
- expect(outcome).toEqual({ kind: 'no-chat' });
128
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
129
- });
130
- });
131
-
132
- describe('dispatchFallbackNotification — error paths', () => {
133
- it('forbidden error (bot blocked) is reported via onError, never throws', async () => {
134
- bot.faults.next('sendMessage', errors.forbidden());
135
- const onError = vi.fn();
136
- const outcome = await dispatchFallbackNotification({
137
- bot,
138
- ownerChatId: OWNER,
139
- plan: planExecuted(),
140
- onError,
141
- });
142
- expect(outcome).toEqual({ kind: 'error' });
143
- expect(onError).toHaveBeenCalledTimes(1);
144
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
145
- });
146
-
147
- it('flood-wait error is reported via onError, never throws', async () => {
148
- bot.faults.next('sendMessage', errors.floodWait(15));
149
- const onError = vi.fn();
150
- const outcome = await dispatchFallbackNotification({
151
- bot,
152
- ownerChatId: OWNER,
153
- plan: planExhaustedAll(),
154
- onError,
155
- });
156
- expect(outcome).toEqual({ kind: 'error' });
157
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
158
- });
159
-
160
- it('network error is reported via onError, never throws', async () => {
161
- bot.faults.next('sendMessage', errors.networkError('ECONNRESET'));
162
- const onError = vi.fn();
163
- const outcome = await dispatchFallbackNotification({
164
- bot,
165
- ownerChatId: OWNER,
166
- plan: planExecuted(),
167
- onError,
168
- });
169
- expect(outcome).toEqual({ kind: 'error' });
170
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
171
- });
172
-
173
- it('omitted onError still resolves cleanly on failure', async () => {
174
- bot.faults.next('sendMessage', errors.forbidden());
175
- // No onError supplied — should not throw, just return error outcome.
176
- const outcome = await dispatchFallbackNotification({
177
- bot,
178
- ownerChatId: OWNER,
179
- plan: planExecuted(),
180
- });
181
- expect(outcome).toEqual({ kind: 'error' });
182
- });
183
- });
@@ -1,129 +0,0 @@
1
- /**
2
- * Unit tests for `hostd-dispatch.ts` — the gateway's helper that routes
3
- * self-restart slash-commands through the hostd UDS when enabled.
4
- *
5
- * The config-loading branches are validated by mocking
6
- * `loadSwitchroomConfig` (the schema's complexity isn't this test's
7
- * concern — we just need to feed it a known value). The wire-error
8
- * branch is validated by pointing the helper at a nonexistent socket.
9
- *
10
- * The "actually hits a real hostd" path is covered in
11
- * `tests/host-control/server.test.ts` end-to-end — we don't re-test
12
- * the server here.
13
- */
14
-
15
- import {
16
- describe,
17
- it,
18
- expect,
19
- beforeEach,
20
- afterEach,
21
- vi,
22
- } from "vitest";
23
-
24
- const loadConfigMock = vi.fn();
25
- vi.mock("../../src/config/loader.js", () => ({
26
- loadConfig: loadConfigMock,
27
- }));
28
-
29
- // Import AFTER the mock so the module captures the mocked function.
30
- const {
31
- tryHostdDispatch,
32
- hostdWillBeUsed,
33
- isHostdEnabled,
34
- hostdSocketPath,
35
- _resetHostdEnabledCache,
36
- } = await import("../gateway/hostd-dispatch.js");
37
-
38
- beforeEach(() => {
39
- _resetHostdEnabledCache();
40
- loadConfigMock.mockReset();
41
- });
42
-
43
- afterEach(() => {
44
- _resetHostdEnabledCache();
45
- });
46
-
47
- describe("isHostdEnabled() — config gate", () => {
48
- it("returns false when host_control absent", () => {
49
- loadConfigMock.mockReturnValue({});
50
- expect(isHostdEnabled()).toBe(false);
51
- });
52
-
53
- it("returns false when host_control.enabled is false", () => {
54
- loadConfigMock.mockReturnValue({ host_control: { enabled: false } });
55
- expect(isHostdEnabled()).toBe(false);
56
- });
57
-
58
- it("returns true when host_control.enabled is true", () => {
59
- loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
60
- expect(isHostdEnabled()).toBe(true);
61
- });
62
-
63
- it("returns false on config-load throw (best-effort fallback)", () => {
64
- // Gateway runs in environments where the config may not be
65
- // readable yet (very-early-boot, broken symlink). The helper must
66
- // not propagate — it just disables the hostd path.
67
- loadConfigMock.mockImplementation(() => {
68
- throw new Error("config: file not found");
69
- });
70
- expect(isHostdEnabled()).toBe(false);
71
- });
72
-
73
- it("caches the result across calls (no re-read)", () => {
74
- loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
75
- expect(isHostdEnabled()).toBe(true);
76
- expect(isHostdEnabled()).toBe(true);
77
- expect(isHostdEnabled()).toBe(true);
78
- expect(loadConfigMock).toHaveBeenCalledTimes(1);
79
- });
80
- });
81
-
82
- describe("hostdWillBeUsed() — config + socket existence", () => {
83
- it("false when hostd disabled even if socket would be present", () => {
84
- loadConfigMock.mockReturnValue({});
85
- expect(hostdWillBeUsed("klanker")).toBe(false);
86
- });
87
-
88
- it("false when hostd enabled but per-agent socket isn't bound", () => {
89
- loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
90
- // hostdSocketPath() is hard-coded to /run/switchroom/hostd/<name>/sock
91
- // — that path doesn't exist in the test env, so existsSync returns
92
- // false and hostdWillBeUsed is false.
93
- expect(hostdWillBeUsed("klanker-no-such-agent")).toBe(false);
94
- });
95
- });
96
-
97
- describe("tryHostdDispatch()", () => {
98
- it("returns 'not-configured' when hostd disabled", async () => {
99
- loadConfigMock.mockReturnValue({});
100
- const result = await tryHostdDispatch("klanker", {
101
- v: 1,
102
- op: "agent_restart",
103
- request_id: "test-1",
104
- args: { name: "klanker", force: true },
105
- });
106
- expect(result).toBe("not-configured");
107
- });
108
-
109
- it("returns 'not-configured' when socket absent", async () => {
110
- loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
111
- const result = await tryHostdDispatch("nonexistent-agent", {
112
- v: 1,
113
- op: "agent_restart",
114
- request_id: "test-2",
115
- args: { name: "nonexistent-agent", force: true },
116
- });
117
- expect(result).toBe("not-configured");
118
- });
119
-
120
- it("locks the socket-path contract", () => {
121
- // RFC C pins this path. If the gateway and the compose generator
122
- // drift apart on the bind path, the mount silently goes nowhere
123
- // and every dispatch returns "not-configured". Catch any rename
124
- // in lockstep with the compose-generator test.
125
- expect(hostdSocketPath("klanker")).toBe(
126
- "/run/switchroom/hostd/klanker/sock",
127
- );
128
- });
129
- });