@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { executeSandboxCommand } from '../sandbox-command.js';
4
+
5
+ import type {
6
+ ICommandSandboxAdapter,
7
+ ICommandSandboxStatus,
8
+ TSandboxCommandMode,
9
+ } from '@robota-sdk/agent-framework';
10
+
11
+ /** Issue #3082 — `/sandbox` shows and changes how shell commands are confined. */
12
+ function contextWith(
13
+ status: Partial<ICommandSandboxStatus>,
14
+ answer?: string,
15
+ ): { context: Parameters<typeof executeSandboxCommand>[0]; setMode: ReturnType<typeof vi.fn> } {
16
+ let current: ICommandSandboxStatus = {
17
+ mode: 'off',
18
+ backend: 'bubblewrap',
19
+ network: false,
20
+ excludedCommands: [],
21
+ ...status,
22
+ };
23
+ const setMode = vi.fn((mode: TSandboxCommandMode) => {
24
+ current = { ...current, mode };
25
+ });
26
+ const adapter: ICommandSandboxAdapter = { status: () => current, setMode };
27
+ const ui =
28
+ answer === undefined
29
+ ? undefined
30
+ : { ask: vi.fn().mockResolvedValue({ type: 'answer', values: [answer] }) };
31
+ return {
32
+ context: {
33
+ getCommandHostAdapters: () => ({ sandbox: adapter }),
34
+ getUserInteraction: () => ui,
35
+ } as never,
36
+ setMode,
37
+ };
38
+ }
39
+
40
+ describe('/sandbox', () => {
41
+ it('shows the current state when no mode is given and nobody is asked', async () => {
42
+ const { context } = contextWith({ mode: 'auto-allow', excludedCommands: ['docker'] });
43
+ const result = await executeSandboxCommand(context, '');
44
+ expect(result.message).toContain('Sandbox: auto-allow (bubblewrap)');
45
+ expect(result.message).toContain('Network: blocked.');
46
+ expect(result.message).toContain('Run unconfined: docker.');
47
+ });
48
+
49
+ it('sets a mode named on the command line', async () => {
50
+ const { context, setMode } = contextWith({});
51
+ const result = await executeSandboxCommand(context, 'regular');
52
+ expect(setMode).toHaveBeenCalledWith('regular');
53
+ expect(result.message).toContain('Sandbox mode set to: regular');
54
+ });
55
+
56
+ it('asks for a mode through the picker', async () => {
57
+ const { context, setMode } = contextWith({}, 'auto-allow');
58
+ await executeSandboxCommand(context, '');
59
+ expect(setMode).toHaveBeenCalledWith('auto-allow');
60
+ });
61
+
62
+ it('says what is missing when the backend cannot run', async () => {
63
+ const { context } = contextWith({ mode: 'auto-allow', unavailable: 'missing bubblewrap' });
64
+ const result = await executeSandboxCommand(context, '');
65
+ expect(result.message).toContain(
66
+ 'Cannot run here: missing bubblewrap. Commands run unconfined.',
67
+ );
68
+ });
69
+
70
+ it('refuses an unknown mode and a host without a sandbox', async () => {
71
+ const { context, setMode } = contextWith({});
72
+ expect((await executeSandboxCommand(context, 'loose')).success).toBe(false);
73
+ expect(setMode).not.toHaveBeenCalled();
74
+ const bare = {
75
+ getCommandHostAdapters: () => ({}),
76
+ getUserInteraction: () => undefined,
77
+ } as never;
78
+ expect((await executeSandboxCommand(bare, '')).message).toBe('This host has no OS sandbox.');
79
+ });
80
+ });
@@ -0,0 +1,2 @@
1
+ export { createSandboxCommandModule, SandboxCommandSource } from './sandbox-command-module.js';
2
+ export { executeSandboxCommand, formatSandboxStatus, SANDBOX_MODES } from './sandbox-command.js';
@@ -0,0 +1,56 @@
1
+ import { executeSandboxCommand, SANDBOX_MODES } from './sandbox-command.js';
2
+
3
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
4
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
5
+
6
+ const DESCRIPTION = 'Show or change how shell commands are confined';
7
+ const ARGUMENT_HINT = SANDBOX_MODES.map((entry) => entry.mode).join(' | ');
8
+
9
+ export function createSandboxCommandEntry(): ICommand {
10
+ return {
11
+ name: 'sandbox',
12
+ displayName: 'Sandbox',
13
+ description: DESCRIPTION,
14
+ source: 'sandbox',
15
+ argumentHint: ARGUMENT_HINT,
16
+ subcommands: SANDBOX_MODES.map(({ mode, description }) => ({
17
+ name: mode,
18
+ description,
19
+ source: 'sandbox',
20
+ })),
21
+ // User-only: changes the sandbox boundary; permission widening is the user's.
22
+ modelInvocable: false,
23
+ };
24
+ }
25
+
26
+ function createSandboxSystemCommand(): ISystemCommand {
27
+ const entry = createSandboxCommandEntry();
28
+ return {
29
+ name: entry.name,
30
+ displayName: entry.displayName,
31
+ description: entry.description,
32
+ requiresPermission: false,
33
+ userInvocable: true,
34
+ modelInvocable: false,
35
+ argumentHint: entry.argumentHint,
36
+ subcommands: entry.subcommands,
37
+ lifecycle: 'inline',
38
+ execute: executeSandboxCommand,
39
+ };
40
+ }
41
+
42
+ export class SandboxCommandSource implements ICommandSource {
43
+ readonly name = 'sandbox';
44
+
45
+ getCommands(): ICommand[] {
46
+ return [createSandboxCommandEntry()];
47
+ }
48
+ }
49
+
50
+ export function createSandboxCommandModule(): ICommandModule {
51
+ return {
52
+ name: 'agent-command-sandbox',
53
+ commandSources: [new SandboxCommandSource()],
54
+ systemCommands: [createSandboxSystemCommand()],
55
+ };
56
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `/sandbox`: see how shell commands are confined and change it (issue #3082).
3
+ *
4
+ * The OS sandbox is live — the choice applies to the next command — and is saved in the user
5
+ * settings so the next session starts the same way.
6
+ */
7
+ import { selectAction } from '@robota-sdk/agent-core';
8
+
9
+ import type {
10
+ ICommandHostAdapterAccess,
11
+ ICommandHostUserInteraction,
12
+ ICommandSandboxStatus,
13
+ TSandboxCommandMode,
14
+ } from '@robota-sdk/agent-framework';
15
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
16
+
17
+ export const SANDBOX_MODES: readonly {
18
+ readonly mode: TSandboxCommandMode;
19
+ readonly description: string;
20
+ }[] = [
21
+ { mode: 'auto-allow', description: 'Confine shell commands; confined ones run without a prompt' },
22
+ { mode: 'regular', description: 'Confine shell commands; prompts work as usual' },
23
+ { mode: 'off', description: 'Run shell commands on this machine, unconfined' },
24
+ ];
25
+
26
+ function isSandboxMode(value: string): value is TSandboxCommandMode {
27
+ return SANDBOX_MODES.some((entry) => entry.mode === value);
28
+ }
29
+
30
+ export function formatSandboxStatus(status: ICommandSandboxStatus): string {
31
+ const lines = [
32
+ `Sandbox: ${status.mode}${status.backend !== undefined ? ` (${status.backend})` : ''}`,
33
+ ];
34
+ if (status.unavailable !== undefined) {
35
+ lines.push(
36
+ status.mode === 'off'
37
+ ? `Not available here: ${status.unavailable}.`
38
+ : `Cannot run here: ${status.unavailable}. Commands run unconfined.`,
39
+ );
40
+ }
41
+ if (status.mode !== 'off' && status.unavailable === undefined) {
42
+ lines.push(
43
+ 'Writes are limited to the workspace and temp directories; settings and git hooks stay read-only.',
44
+ `Network: ${status.network ? 'allowed' : 'blocked'}.`,
45
+ );
46
+ if (status.excludedCommands.length > 0) {
47
+ lines.push(`Run unconfined: ${status.excludedCommands.join(', ')}.`);
48
+ }
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
53
+ async function askForMode(
54
+ context: ICommandHostUserInteraction,
55
+ current: TSandboxCommandMode,
56
+ ): Promise<string | undefined> {
57
+ const ui = context.getUserInteraction();
58
+ if (!ui) return undefined;
59
+ const options = SANDBOX_MODES.map(({ mode, description }) => ({
60
+ value: mode,
61
+ label: mode === current ? `${mode} (current)` : mode,
62
+ description,
63
+ }));
64
+ const response = await ui.ask(selectAction('sandbox', 'Sandbox mode', options));
65
+ return response.type === 'answer' ? response.values[0] : undefined;
66
+ }
67
+
68
+ export async function executeSandboxCommand(
69
+ context: ICommandHostAdapterAccess & ICommandHostUserInteraction,
70
+ args: string,
71
+ ): Promise<ICommandResult> {
72
+ const adapter = context.getCommandHostAdapters?.().sandbox;
73
+ if (adapter === undefined) {
74
+ return { message: 'This host has no OS sandbox.', success: false };
75
+ }
76
+ const before = adapter.status();
77
+ const requested = args.trim().split(/\s+/)[0] || (await askForMode(context, before.mode));
78
+ if (requested === undefined || requested === '') {
79
+ return { message: formatSandboxStatus(before), success: true, data: { ...before } };
80
+ }
81
+ if (!isSandboxMode(requested)) {
82
+ return {
83
+ message: `Unknown sandbox mode "${requested}". Valid: ${SANDBOX_MODES.map((entry) => entry.mode).join(' | ')}`,
84
+ success: false,
85
+ };
86
+ }
87
+ adapter.setMode(requested);
88
+ const after = adapter.status();
89
+ return {
90
+ message: `Sandbox mode set to: ${requested}\n${formatSandboxStatus(after)}`,
91
+ success: true,
92
+ data: { ...after },
93
+ };
94
+ }
@@ -0,0 +1,399 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
4
+
5
+ import { createScheduleCommandModule } from '../schedule-command-module.js';
6
+
7
+ let harness: ScriptedSessionHarness | undefined;
8
+ afterEach(async () => {
9
+ await harness?.dispose();
10
+ harness = undefined;
11
+ });
12
+
13
+ /**
14
+ * #3288 §1: a bare/interval-only loop now asks before it falls back to the default prompt, even in
15
+ * this harness's "typed the command" (`source: 'user'`) path — subscribing an `askHandler` gives
16
+ * `getUserInteraction()` a live port (REMOTE-007 D4a), so these functional tests stay interactive
17
+ * rather than hitting the new headless refusal. Always answers "Start".
18
+ */
19
+ const confirmDefaultStart = async (): Promise<{ type: 'answer'; values: readonly string[] }> =>
20
+ ({ type: 'answer', values: ['start'] });
21
+
22
+ describe('/loop command in a real interactive session', () => {
23
+ it('re-reads an omitted self-paced prompt before each iteration without changing explicit prompts', async () => {
24
+ let prompt = 'project first';
25
+ const resolveDefaultPrompt = () => prompt;
26
+ harness = scriptedSession({
27
+ turns: [{ text: 'first' }, { text: 'second' }],
28
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
29
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
30
+ askHandler: confirmDefaultStart,
31
+ backgroundTasks: true,
32
+ persistence: true,
33
+ });
34
+ const created = await harness.command('loop', '');
35
+ expect(created?.success).toBe(true);
36
+ const loopId = (created?.data as { loopId: string }).loopId;
37
+ await vi.waitFor(() => expect(harness?.session.listSelfPacedLoops()[0]?.phase).toBe('waiting'));
38
+ expect(JSON.stringify(harness.requests[0])).toContain('project first');
39
+
40
+ prompt = 'project second';
41
+ const timer = harness.session.listSchedules().find((task) => task.metadata?.['sessionLoopId'] === loopId)!;
42
+ const nextAllowedAt = harness.session.listSelfPacedLoops()[0]!.nextAllowedAt!;
43
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(nextAllowedAt) + 1);
44
+ try {
45
+ expect(await harness.wake('project first', timer.id)).not.toBeNull();
46
+ } finally {
47
+ clock.mockRestore();
48
+ }
49
+ expect(harness.requests[1]?.filter((message) => message.role === 'user').at(-1)?.content)
50
+ .toBe('project second');
51
+ expect(harness.session.getFullHistory().filter((entry) => entry.type === 'user').at(-1))
52
+ .toMatchObject({ data: { content: 'project second' } });
53
+ expect(harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
54
+ .toBe('stopped');
55
+ });
56
+
57
+ it('re-reads an interval-only fixed-loop prompt before each wake', async () => {
58
+ let prompt = 'user first';
59
+ const resolveDefaultPrompt = () => prompt;
60
+ harness = scriptedSession({
61
+ turns: [{ text: 'first' }, { text: 'second' }],
62
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
63
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
64
+ askHandler: confirmDefaultStart,
65
+ backgroundTasks: true,
66
+ persistence: true,
67
+ });
68
+ const created = await harness.command('loop', '5m');
69
+ expect(created?.success).toBe(true);
70
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
71
+ loopId: string; taskId: string; firstAllowedAt: string;
72
+ };
73
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
74
+ try {
75
+ expect(await harness.wake('user first', taskId)).not.toBeNull();
76
+ prompt = 'user second';
77
+ expect(await harness.wake('user first', taskId)).not.toBeNull();
78
+ } finally {
79
+ eligibleClock.mockRestore();
80
+ }
81
+ expect(JSON.stringify(harness.requests[0])).toContain('user first');
82
+ expect(harness.requests[1]?.filter((message) => message.role === 'user').at(-1)?.content)
83
+ .toBe('user second');
84
+ expect(harness.session.getFullHistory().filter((entry) => entry.type === 'user').at(-1))
85
+ .toMatchObject({ data: { content: 'user second' } });
86
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
87
+ });
88
+
89
+ it('does not consult the default resolver for an explicit fixed-loop prompt', async () => {
90
+ harness = scriptedSession({
91
+ turns: [{ text: 'done' }],
92
+ commandModules: [createScheduleCommandModule({
93
+ defaultPrompt: 'fallback',
94
+ resolveDefaultPrompt: () => { throw new Error('must not read default'); },
95
+ })],
96
+ resolveDefaultLoopPrompt: () => { throw new Error('must not read default'); },
97
+ backgroundTasks: true,
98
+ persistence: true,
99
+ });
100
+ const created = await harness.command('loop', '5m explicit check');
101
+ expect(created?.success).toBe(true);
102
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
103
+ loopId: string; taskId: string; firstAllowedAt: string;
104
+ };
105
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
106
+ try {
107
+ expect(await harness.wake('explicit check', taskId)).not.toBeNull();
108
+ } finally {
109
+ eligibleClock.mockRestore();
110
+ }
111
+ expect(harness.requests[0]?.filter((message) => message.role === 'user').at(-1)?.content)
112
+ .toBe('explicit check');
113
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
114
+ });
115
+
116
+ it('honors an explicit schedule edit to a fixed loop that originally used the default', async () => {
117
+ let prompt = 'default first';
118
+ const resolveDefaultPrompt = () => prompt;
119
+ harness = scriptedSession({
120
+ turns: [{ text: 'done' }],
121
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
122
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
123
+ askHandler: confirmDefaultStart,
124
+ backgroundTasks: true,
125
+ persistence: true,
126
+ });
127
+ const created = await harness.command('loop', '5m');
128
+ expect(created?.success).toBe(true);
129
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
130
+ loopId: string; taskId: string; firstAllowedAt: string;
131
+ };
132
+ expect((await harness.command('schedule', `edit ${taskId} cron "*/5 * * * *" explicit edit`))?.success)
133
+ .toBe(true);
134
+ prompt = 'default changed';
135
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
136
+ try {
137
+ expect(await harness.wake('explicit edit', taskId)).not.toBeNull();
138
+ } finally {
139
+ eligibleClock.mockRestore();
140
+ }
141
+ expect(harness.requests[0]?.filter((message) => message.role === 'user').at(-1)?.content)
142
+ .toBe('explicit edit');
143
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
144
+ });
145
+
146
+ it('runs a prompt-only iteration, records the model delay, and stops its one-shot timer', async () => {
147
+ harness = scriptedSession({
148
+ turns: [
149
+ { toolCalls: [{ name: 'report_loop_decision', args: {
150
+ action: 'continue', delaySeconds: 60, reason: 'Recheck after the build settles',
151
+ } }] },
152
+ { text: 'Build still running.' },
153
+ ],
154
+ commandModules: [createScheduleCommandModule()],
155
+ backgroundTasks: true,
156
+ persistence: true,
157
+ });
158
+ let phaseAtComplete: string | undefined;
159
+ let historyAtComplete = '';
160
+ harness.session.on('complete', () => {
161
+ phaseAtComplete = harness?.session.listSelfPacedLoops()[0]?.phase;
162
+ historyAtComplete = JSON.stringify(harness?.session.getFullHistory());
163
+ });
164
+ const created = await harness.command('loop', 'check the build');
165
+ expect(created?.success).toBe(true);
166
+ const loopId = (created?.data as { loopId: string }).loopId;
167
+ await vi.waitFor(() =>
168
+ expect(harness?.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId))
169
+ .toMatchObject({ phase: 'waiting', generation: 1, delaySeconds: 60, fallbackUsed: false }),
170
+ );
171
+ const timer = harness.session.listSchedules().find(
172
+ (task) => task.metadata?.['sessionLoopId'] === loopId && task.metadata?.['sessionLoopSelfPaced'],
173
+ );
174
+ expect(timer?.kind === 'scheduled' ? timer.schedule?.cronExpression : undefined).toBe(
175
+ harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.nextAllowedAt,
176
+ );
177
+ const listed = await harness.command('loop', 'list');
178
+ expect(listed?.message).toContain(loopId);
179
+ expect(listed?.message).toContain('60s');
180
+ expect(listed?.message).toContain('Recheck after the build settles');
181
+ expect(JSON.stringify(harness.session.getFullHistory()))
182
+ .toContain('Recheck after the build settles');
183
+ expect(phaseAtComplete).toBe('waiting');
184
+ expect(historyAtComplete).toContain('Recheck after the build settles');
185
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
186
+ expect(harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase).toBe('stopped');
187
+ expect((await harness.command('loop', 'list'))?.message).toBe('No active loops.');
188
+ });
189
+
190
+ it('restores one future self-paced wake with a new timer id, without replaying the turn', async () => {
191
+ harness = scriptedSession({
192
+ turns: [{ text: 'Still waiting.' }],
193
+ commandModules: [createScheduleCommandModule()],
194
+ backgroundTasks: true,
195
+ persistence: true,
196
+ });
197
+ const created = await harness.command('loop', 'check the build');
198
+ const loopId = (created?.data as { loopId: string }).loopId;
199
+ await vi.waitFor(() =>
200
+ expect(harness?.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
201
+ .toBe('waiting'),
202
+ );
203
+ const firstTimer = harness.session.listSchedules().find(
204
+ (task) => task.metadata?.['sessionLoopId'] === loopId,
205
+ )!;
206
+ const sessionId = harness.session.getSession().getSessionId();
207
+ await harness.session.shutdown({ reason: 'other' });
208
+
209
+ const resumed = scriptedSession({
210
+ turns: [],
211
+ cwd: harness.cwd,
212
+ resumeSessionId: sessionId,
213
+ backgroundTasks: true,
214
+ persistence: true,
215
+ });
216
+ try {
217
+ await vi.waitFor(() =>
218
+ expect(resumed.session.listSchedules().some(
219
+ (task) => task.metadata?.['sessionLoopId'] === loopId && task.id !== firstTimer.id,
220
+ )).toBe(true),
221
+ );
222
+ expect(resumed.requests).toHaveLength(0);
223
+ expect(resumed.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
224
+ .toBe('waiting');
225
+ } finally {
226
+ await resumed.dispose();
227
+ }
228
+ });
229
+
230
+ it('does not honor a denied model reschedule call', async () => {
231
+ harness = scriptedSession({
232
+ turns: [
233
+ { toolCalls: [{ name: 'report_loop_decision', args: {
234
+ action: 'continue', delaySeconds: 60, reason: 'Model asked, host denied',
235
+ } }] },
236
+ { text: 'done' },
237
+ ],
238
+ commandModules: [createScheduleCommandModule()],
239
+ backgroundTasks: true,
240
+ persistence: true,
241
+ deniedTools: ['report_loop_decision'],
242
+ permissionMode: 'default',
243
+ });
244
+ const created = await harness.command('loop', 'check');
245
+ expect(created?.success).toBe(true);
246
+ await vi.waitFor(() =>
247
+ expect(harness?.session.listSelfPacedLoops()[0]?.phase).toBe('waiting'),
248
+ );
249
+ expect(harness.session.listSelfPacedLoops()[0]).toMatchObject({
250
+ delaySeconds: 1200,
251
+ fallbackUsed: true,
252
+ });
253
+ });
254
+
255
+ it('runs only one fallback wake after omitted decisions, then stops', async () => {
256
+ harness = scriptedSession({
257
+ turns: [{ text: 'First check.' }, { text: 'Second check.' }],
258
+ commandModules: [createScheduleCommandModule()],
259
+ backgroundTasks: true,
260
+ persistence: true,
261
+ });
262
+ const created = await harness.command('loop', 'check');
263
+ const loopId = (created?.data as { loopId: string }).loopId;
264
+ await vi.waitFor(() =>
265
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
266
+ phase: 'waiting', generation: 1, fallbackUsed: true,
267
+ }),
268
+ );
269
+ const timer = harness.session.listSchedules().find(
270
+ (task) => task.metadata?.['sessionLoopId'] === loopId,
271
+ )!;
272
+ const nextAllowedAt = harness.session.listSelfPacedLoops()[0]!.nextAllowedAt!;
273
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(nextAllowedAt) + 1);
274
+ try {
275
+ expect(await harness.wake('check', timer.id)).not.toBeNull();
276
+ } finally {
277
+ clock.mockRestore();
278
+ }
279
+ await vi.waitFor(() =>
280
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
281
+ phase: 'stopped', terminalReason: 'missing-reschedule-decision',
282
+ }),
283
+ );
284
+ expect(harness.requests).toHaveLength(2);
285
+ });
286
+
287
+ it('allows the model to stop its own loop without arming a timer', async () => {
288
+ harness = scriptedSession({
289
+ turns: [
290
+ { toolCalls: [{ name: 'report_loop_decision', args: { action: 'stop' } }] },
291
+ { text: 'Done checking.' },
292
+ ],
293
+ commandModules: [createScheduleCommandModule()],
294
+ backgroundTasks: true,
295
+ persistence: true,
296
+ });
297
+ await harness.command('loop', 'check');
298
+ await vi.waitFor(() =>
299
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
300
+ phase: 'stopped', terminalReason: 'model-stopped',
301
+ }),
302
+ );
303
+ expect(harness.session.listSchedules().filter((task) => task.metadata?.['sessionLoopSelfPaced']))
304
+ .toHaveLength(0);
305
+ });
306
+
307
+ it('uses the host maintenance prompt, persists expiry, and refuses a wake after expiry', async () => {
308
+ const maintenancePrompt = 'Check the current work only.';
309
+ harness = scriptedSession({
310
+ turns: [{ text: 'unused' }],
311
+ commandModules: [createScheduleCommandModule({ defaultPrompt: maintenancePrompt })],
312
+ askHandler: confirmDefaultStart,
313
+ backgroundTasks: true,
314
+ persistence: true,
315
+ });
316
+
317
+ const created = await harness.command('loop', '5m');
318
+ expect(created?.success).toBe(true);
319
+ const { taskId, expiresAt, firstAllowedAt } = created?.data as {
320
+ taskId: string;
321
+ expiresAt: string;
322
+ firstAllowedAt: string;
323
+ };
324
+ const scheduled = harness.session.listSchedules().find((task) => task.id === taskId);
325
+ expect(scheduled?.kind === 'scheduled' ? scheduled.schedule?.agentInstruction : undefined).toBe(
326
+ maintenancePrompt,
327
+ );
328
+ expect(scheduled?.metadata?.['sessionLoopExpiresAt']).toBe(expiresAt);
329
+ expect(scheduled?.metadata?.['sessionLoopFirstAllowedAt']).toBe(firstAllowedAt);
330
+
331
+ const earlyClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) - 1);
332
+ try {
333
+ expect(await harness.wake(maintenancePrompt, taskId)).toBeNull();
334
+ expect(harness.requests).toHaveLength(0);
335
+ } finally {
336
+ earlyClock.mockRestore();
337
+ }
338
+
339
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(expiresAt) + 1);
340
+ try {
341
+ expect(await harness.wake(maintenancePrompt, taskId)).toBeNull();
342
+ await vi.waitFor(() =>
343
+ expect(harness?.session.listSchedules().find((task) => task.id === taskId)?.status).toBe(
344
+ 'cancelled',
345
+ ),
346
+ );
347
+ expect(harness.requests).toHaveLength(0);
348
+ } finally {
349
+ clock.mockRestore();
350
+ }
351
+ });
352
+
353
+ it('creates, lists, and stops one scheduled wake without cancelling another schedule', async () => {
354
+ harness = scriptedSession({
355
+ turns: [{ text: 'unused' }],
356
+ commandModules: [createScheduleCommandModule()],
357
+ backgroundTasks: true,
358
+ persistence: true,
359
+ });
360
+
361
+ const other = await harness.command('schedule', 'cron "0 0 * * *" unrelated check');
362
+ expect(other?.success).toBe(true);
363
+
364
+ const created = await harness.command('loop', '1h check the build');
365
+ expect(created?.success).toBe(true);
366
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
367
+ loopId: string;
368
+ taskId: string;
369
+ firstAllowedAt: string;
370
+ };
371
+ expect(loopId).not.toBe(taskId);
372
+ expect(created?.message).toContain(`Stop with /loop stop ${loopId}`);
373
+
374
+ const listed = await harness.command('loop', 'list');
375
+ expect(listed?.message).toContain(loopId);
376
+ expect(listed?.message).not.toContain('unrelated check');
377
+
378
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
379
+ const fired = await harness
380
+ .wake('check the build', taskId)
381
+ .finally(() => eligibleClock.mockRestore());
382
+ expect(fired).not.toBeNull();
383
+ expect(harness.requests.length).toBe(1);
384
+ await new Promise<void>((resolve) => setImmediate(resolve));
385
+
386
+ const edited = await harness.command(
387
+ 'schedule',
388
+ `edit ${taskId} cron "0 0 * * *" renamed check`,
389
+ );
390
+ expect(edited?.success).toBe(true);
391
+ expect((await harness.command('loop', 'list'))?.message).toContain(loopId);
392
+
393
+ const stopped = await harness.command('loop', `stop ${loopId}`);
394
+ expect(stopped?.message).toContain('Loop stopped:');
395
+ expect(await harness.wake('check the build', taskId)).toBeNull();
396
+ expect((await harness.command('loop', 'list'))?.message).toBe('No active loops.');
397
+ expect((await harness.command('schedule', 'list'))?.message).toContain('unrelated check');
398
+ }, 20_000);
399
+ });