@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,126 @@
1
+ /**
2
+ * A model-requested `/monitor` starts its command as a process, so the command is decided by the
3
+ * shell tool's own gate — the user's Bash/Shell rules — never by consent to `/monitor` itself.
4
+ */
5
+
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
9
+
10
+ import { createScheduleCommandModule } from '../schedule-command-module.js';
11
+
12
+ import type { THooksConfig, TPermissionMode } from '@robota-sdk/agent-core';
13
+ type ISandboxClient = NonNullable<Parameters<typeof scriptedSession>[0]['sandboxClient']>;
14
+
15
+ /** A sandbox that would let every confined shell command run without a prompt. */
16
+ const autoApprovingSandbox: ISandboxClient = {
17
+ filesystem: 'shared',
18
+ wrapCommand: (invocation) => invocation,
19
+ autoApproves: () => true,
20
+ run: () => Promise.reject(new Error('not used')),
21
+ readFile: () => Promise.reject(new Error('not used')),
22
+ writeFile: () => Promise.reject(new Error('not used')),
23
+ };
24
+
25
+ let harness: ScriptedSessionHarness | undefined;
26
+ afterEach(async () => {
27
+ await harness?.dispose();
28
+ harness = undefined;
29
+ });
30
+
31
+ function session(options: {
32
+ permissionMode: TPermissionMode;
33
+ permissions?: { allow?: string[]; deny?: string[] };
34
+ hooks?: THooksConfig;
35
+ sandboxClient?: ISandboxClient;
36
+ }): ScriptedSessionHarness {
37
+ harness = scriptedSession({
38
+ turns: [],
39
+ commandModules: [createScheduleCommandModule()],
40
+ backgroundTasks: true,
41
+ ...options,
42
+ });
43
+ return harness;
44
+ }
45
+
46
+ const MONITOR_ARGS = '"sleep 30" "ready" check the server';
47
+
48
+ function monitorTasks(h: ScriptedSessionHarness) {
49
+ return h.session.listBackgroundTasks().filter((task) => task.kind === 'process');
50
+ }
51
+
52
+ describe('/monitor from the model is decided by the shell gate', () => {
53
+ it('a Bash deny rule refuses the monitored command, even under bypassPermissions', async () => {
54
+ const h = session({
55
+ permissionMode: 'bypassPermissions',
56
+ permissions: { deny: ['Bash(sleep *)'] },
57
+ });
58
+
59
+ const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
60
+
61
+ expect(result?.success).toBe(false);
62
+ expect(result?.message).toContain('shell permission rules refused this command');
63
+ expect(monitorTasks(h)).toEqual([]);
64
+ });
65
+
66
+ it('an allow rule for a different command does not approve it', async () => {
67
+ // Plan mode refuses an execute call no allow rule matches, so a prompt cannot stand in for it.
68
+ const h = session({ permissionMode: 'plan', permissions: { allow: ['Bash(echo *)'] } });
69
+
70
+ const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
71
+
72
+ expect(result?.success).toBe(false);
73
+ expect(monitorTasks(h)).toEqual([]);
74
+ });
75
+
76
+ it('starts the monitor when the shell rules allow that command', async () => {
77
+ const h = session({ permissionMode: 'plan', permissions: { allow: ['Bash(sleep *)'] } });
78
+
79
+ const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
80
+
81
+ expect(result?.success).toBe(true);
82
+ expect(monitorTasks(h)).toHaveLength(1);
83
+ });
84
+
85
+ it('leaves the user-typed command to the user', async () => {
86
+ const h = session({
87
+ permissionMode: 'bypassPermissions',
88
+ permissions: { deny: ['Bash(sleep *)'] },
89
+ });
90
+
91
+ const result = await h.command('monitor', MONITOR_ARGS);
92
+
93
+ expect(result?.success).toBe(true);
94
+ });
95
+
96
+ it('never takes the sandbox auto-approval: the monitor does not run inside the sandbox', async () => {
97
+ // Default mode would auto-run a sandboxed shell command. The monitor's process is started by the
98
+ // background-task manager, outside the sandbox, so it falls to the ask path — and with no one
99
+ // to ask here, it is refused rather than started unconfined.
100
+ const h = session({ permissionMode: 'default', sandboxClient: autoApprovingSandbox });
101
+
102
+ const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
103
+
104
+ expect(result?.success).toBe(false);
105
+ expect(monitorTasks(h)).toEqual([]);
106
+ });
107
+
108
+ it('runs the PreToolUse hooks, so a guardrail blocks the monitored command', async () => {
109
+ const h = session({
110
+ permissionMode: 'bypassPermissions',
111
+ hooks: {
112
+ PreToolUse: [
113
+ {
114
+ matcher: 'Shell',
115
+ hooks: [{ type: 'command', command: 'echo "no sleeping" >&2; exit 2' }],
116
+ },
117
+ ],
118
+ },
119
+ });
120
+
121
+ const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
122
+
123
+ expect(result?.success).toBe(false);
124
+ expect(monitorTasks(h)).toEqual([]);
125
+ });
126
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * FLOW-005: `/schedule` + `/monitor` command surface.
3
+ * - parseScheduleSpec: relative delay → one-shot ISO; cron form; invalid input rejected.
4
+ * - executeScheduleCommand / executeMonitorCommand: build the wake request and spawn it.
5
+ */
6
+
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ import { executeMonitorCommand, executeScheduleCommand } from '../schedule-command.js';
10
+ import { createScheduleCommandModule } from '../schedule-command-module.js';
11
+ import { parseScheduleSpec } from '../schedule-spec-parser.js';
12
+
13
+ import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
14
+ import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
15
+
16
+ describe('in-session repeat command', () => {
17
+ it('exposes a user-visible loop command through the default schedule module', () => {
18
+ const module = createScheduleCommandModule();
19
+ const executable = module.systemCommands?.find((command) => command.name === 'loop');
20
+ const palette = module.commandSources?.flatMap((source) => source.getCommands())
21
+ .find((command) => command.name === 'loop');
22
+
23
+ expect(executable).toBeDefined();
24
+ expect(palette).toBeDefined();
25
+ expect(executable?.requiresPermission).toBe(true);
26
+ expect(executable?.modelInvocable).toBe(true);
27
+ expect(executable?.userInvocable).toBe(true);
28
+ expect(palette?.subcommands?.map(({ name }) => name)).toEqual(['list', 'stop']);
29
+ expect(palette?.subcommands).toEqual(executable?.subcommands);
30
+ });
31
+ });
32
+
33
+ function mockHost(): {
34
+ host: IAgentJobHostContext;
35
+ scheduled: ReturnType<typeof vi.fn>;
36
+ monitor: ReturnType<typeof vi.fn>;
37
+ listSchedules: ReturnType<typeof vi.fn>;
38
+ pauseSchedule: ReturnType<typeof vi.fn>;
39
+ resumeSchedule: ReturnType<typeof vi.fn>;
40
+ editSchedule: ReturnType<typeof vi.fn>;
41
+ } {
42
+ const scheduled = vi.fn().mockResolvedValue({ id: 'task_sched' });
43
+ const monitor = vi.fn().mockResolvedValue({ id: 'task_mon' });
44
+ const listSchedules = vi.fn().mockReturnValue([
45
+ {
46
+ id: 'sched_1',
47
+ status: 'sleeping',
48
+ label: 'daily',
49
+ kind: 'scheduled',
50
+ nextFireAt: '2030-01-01T00:00:00.000Z',
51
+ schedule: { cronExpression: '0 0 * * *' },
52
+ },
53
+ ]);
54
+ const pauseSchedule = vi.fn().mockResolvedValue(undefined);
55
+ const resumeSchedule = vi.fn().mockResolvedValue(undefined);
56
+ const editSchedule = vi.fn().mockResolvedValue(undefined);
57
+ const host = createTestAgentJobHost({
58
+ spawnScheduledWake: scheduled,
59
+ spawnMonitorWake: monitor,
60
+ listSchedules,
61
+ pauseSchedule,
62
+ resumeSchedule,
63
+ editSchedule,
64
+ });
65
+ return { host, scheduled, monitor, listSchedules, pauseSchedule, resumeSchedule, editSchedule };
66
+ }
67
+
68
+ describe('parseScheduleSpec (FLOW-005)', () => {
69
+ it('parses a relative delay into a one-shot ISO timestamp', () => {
70
+ const result = parseScheduleSpec('in 5m summarize the logs', 0);
71
+ expect(result.ok).toBe(true);
72
+ if (!result.ok) return;
73
+ expect(result.spec.recurring).toBe(false);
74
+ expect(result.spec.cronExpression).toBe(new Date(5 * 60_000).toISOString());
75
+ expect(result.spec.instruction).toBe('summarize the logs');
76
+ });
77
+
78
+ it('parses a quoted cron expression as recurring', () => {
79
+ const result = parseScheduleSpec('cron "0 9 * * *" run standup', 0);
80
+ expect(result.ok).toBe(true);
81
+ if (!result.ok) return;
82
+ expect(result.spec.recurring).toBe(true);
83
+ expect(result.spec.cronExpression).toBe('0 9 * * *');
84
+ expect(result.spec.instruction).toBe('run standup');
85
+ });
86
+
87
+ it('rejects an invalid duration', () => {
88
+ expect(parseScheduleSpec('in 5x do X', 0).ok).toBe(false);
89
+ });
90
+
91
+ it('rejects a missing instruction', () => {
92
+ expect(parseScheduleSpec('in 5m', 0).ok).toBe(false);
93
+ });
94
+
95
+ it('rejects unrecognized input', () => {
96
+ expect(parseScheduleSpec('whenever you feel like it', 0).ok).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('executeScheduleCommand (FLOW-005)', () => {
101
+ it('TC-01: relative delay spawns a one-shot scheduled wake', async () => {
102
+ const { host, scheduled } = mockHost();
103
+ const now = 1_000_000;
104
+ const result = await executeScheduleCommand(host, 'in 1m summarize', now);
105
+
106
+ expect(result.success).toBe(true);
107
+ expect(scheduled).toHaveBeenCalledTimes(1);
108
+ expect(scheduled).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ cronExpression: new Date(now + 60_000).toISOString(),
111
+ agentInstruction: 'summarize',
112
+ }),
113
+ );
114
+ });
115
+
116
+ it('TC-02: cron form spawns a recurring scheduled wake', async () => {
117
+ const { host, scheduled } = mockHost();
118
+ const result = await executeScheduleCommand(host, 'cron "0 9 * * *" standup', Date.now());
119
+
120
+ expect(result.success).toBe(true);
121
+ expect(scheduled).toHaveBeenCalledWith(
122
+ expect.objectContaining({ cronExpression: '0 9 * * *', agentInstruction: 'standup' }),
123
+ );
124
+ });
125
+
126
+ it('TC-03: invalid input is rejected without spawning', async () => {
127
+ const { host, scheduled } = mockHost();
128
+ const result = await executeScheduleCommand(host, 'garbage', Date.now());
129
+
130
+ expect(result.success).toBe(false);
131
+ expect(scheduled).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('TC-04: monitor command spawns a process+match wake task', async () => {
135
+ const { host, monitor } = mockHost();
136
+ const result = await executeMonitorCommand(host, '"npm run dev" "ERROR" fix the failure');
137
+
138
+ expect(result.success).toBe(true);
139
+ expect(monitor).toHaveBeenCalledWith(
140
+ expect.objectContaining({
141
+ command: 'npm run dev',
142
+ matchPattern: 'ERROR',
143
+ agentInstruction: 'fix the failure',
144
+ }),
145
+ );
146
+ });
147
+ });
148
+
149
+ // SELFHOST-012 TC-05: /schedule list|pause|resume|edit dispatch (mirror /background); create stays default.
150
+ describe('executeScheduleCommand — management subcommands (SELFHOST-012)', () => {
151
+ it('list returns the caller schedules with cadence + status, no create', async () => {
152
+ const { host, listSchedules, scheduled } = mockHost();
153
+ const result = await executeScheduleCommand(host, 'list', Date.now());
154
+ expect(result.success).toBe(true);
155
+ expect(listSchedules).toHaveBeenCalledOnce();
156
+ expect(result.message).toContain('sched_1');
157
+ expect(result.message).toContain('[sleeping]');
158
+ expect(result.message).toContain('0 0 * * *');
159
+ expect(scheduled).not.toHaveBeenCalled(); // list is not a create
160
+ });
161
+
162
+ it('pause <id> / resume <id> dispatch to the host lifecycle calls', async () => {
163
+ const { host, pauseSchedule, resumeSchedule } = mockHost();
164
+ const paused = await executeScheduleCommand(host, 'pause sched_1', Date.now());
165
+ expect(paused.success).toBe(true);
166
+ expect(pauseSchedule).toHaveBeenCalledWith('sched_1');
167
+
168
+ const resumed = await executeScheduleCommand(host, 'resume sched_1', Date.now());
169
+ expect(resumed.success).toBe(true);
170
+ expect(resumeSchedule).toHaveBeenCalledWith('sched_1');
171
+ });
172
+
173
+ it('edit <id> <spec> parses the spec and patches the schedule', async () => {
174
+ const { host, editSchedule } = mockHost();
175
+ const result = await executeScheduleCommand(
176
+ host,
177
+ 'edit sched_1 cron "*/5 * * * *" ping',
178
+ Date.now(),
179
+ );
180
+ expect(result.success).toBe(true);
181
+ expect(editSchedule).toHaveBeenCalledWith('sched_1', {
182
+ cronExpression: '*/5 * * * *',
183
+ agentInstruction: 'ping',
184
+ label: 'Scheduled: ping',
185
+ });
186
+ });
187
+
188
+ // CMD-009: create → edit → list must render the EDITED instruction. The host double applies the
189
+ // patch the way `BackgroundTaskManager.editScheduledTask` does (pinned in agent-executor), so a
190
+ // command that omits `label` from the patch leaves the list showing the creation-time text.
191
+ it('list renders the edited instruction, not the one captured at creation (CMD-009)', async () => {
192
+ const schedules: Array<Record<string, unknown>> = [];
193
+ const host = createTestAgentJobHost({
194
+ spawnScheduledWake: vi.fn().mockImplementation((input: Record<string, unknown>) => {
195
+ const task = {
196
+ id: 'sched_x',
197
+ status: 'sleeping',
198
+ kind: 'scheduled',
199
+ label: input['label'],
200
+ schedule: {
201
+ cronExpression: input['cronExpression'],
202
+ agentInstruction: input['agentInstruction'],
203
+ },
204
+ };
205
+ schedules.push(task);
206
+ return Promise.resolve(task);
207
+ }),
208
+ listSchedules: vi.fn().mockImplementation(() => schedules),
209
+ editSchedule: vi.fn().mockImplementation((id: string, patch: Record<string, unknown>) => {
210
+ const { label, ...schedulePatch } = patch;
211
+ const task = schedules.find((s) => s['id'] === id);
212
+ if (task === undefined) throw new Error(`no schedule ${id}`);
213
+ task['schedule'] = { ...(task['schedule'] as Record<string, unknown>), ...schedulePatch };
214
+ if (label !== undefined) task['label'] = label;
215
+ return Promise.resolve();
216
+ }),
217
+ });
218
+
219
+ await executeScheduleCommand(host, 'cron "0 9 * * *" write the standup', 0);
220
+ await executeScheduleCommand(host, 'edit sched_x cron "0 10 * * *" file the report', 0);
221
+ const listed = await executeScheduleCommand(host, 'list', 0);
222
+
223
+ expect(listed.message).toContain('file the report');
224
+ expect(listed.message).not.toContain('write the standup');
225
+ expect(listed.message).toContain('0 10 * * *');
226
+ });
227
+
228
+ it('pause/resume/edit without an id is a usage error (no host call)', async () => {
229
+ const { host, pauseSchedule, editSchedule } = mockHost();
230
+ const p = await executeScheduleCommand(host, 'pause', Date.now());
231
+ expect(p.success).toBe(false);
232
+ expect(p.message).toContain('Usage:');
233
+ expect(pauseSchedule).not.toHaveBeenCalled();
234
+
235
+ const e = await executeScheduleCommand(host, 'edit sched_1', Date.now());
236
+ expect(e.success).toBe(false);
237
+ expect(editSchedule).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('a create spec (in/cron) still creates — subcommand keywords do not collide', async () => {
241
+ const { host, scheduled, pauseSchedule } = mockHost();
242
+ const result = await executeScheduleCommand(host, 'in 5m summarize', 0);
243
+ expect(result.success).toBe(true);
244
+ expect(scheduled).toHaveBeenCalledOnce();
245
+ expect(pauseSchedule).not.toHaveBeenCalled();
246
+ });
247
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * SEC-003 (`js/polynomial-redos`): `/schedule cron` and `/monitor` argument parsing must stay
3
+ * linear in the length of their argument string.
4
+ *
5
+ * Both commands are declared `modelInvocable: true` (see `schedule-command-module.ts`), so the
6
+ * argument string is composed by the model — a prompt-injected model can therefore choose it.
7
+ * The pre-fix regexes used `\s+(.+)$`; because `.` also matches a space, the split point between
8
+ * `\s+` and `(.+)` was ambiguous and a non-matching argument was rejected in O(n^2). Measured
9
+ * pre-fix on the inputs below: ~17s (`/schedule cron`) and ~15s (`/monitor`); post-fix, <1ms.
10
+ * `/loop`'s leading interval had the same `\s+([\s\S]+)$` shape and is held to the same bound.
11
+ */
12
+
13
+ import { describe, expect, it, vi } from 'vitest';
14
+
15
+ import { executeLoopCommand } from '../loop-command.js';
16
+ import { executeMonitorCommand } from '../schedule-command.js';
17
+ import { parseScheduleSpec } from '../schedule-spec-parser.js';
18
+
19
+ import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
20
+ import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
21
+
22
+ /** Long enough that the quadratic path takes >10s while the linear path stays sub-millisecond. */
23
+ const PUMP_LENGTH = 200_000;
24
+
25
+ /** Generous versus the <1ms the linear parse costs, and versus the >10s the quadratic one did. */
26
+ const BUDGET_MS = 250;
27
+
28
+ function elapsedMs(run: () => void): number {
29
+ const started = performance.now();
30
+ run();
31
+ return performance.now() - started;
32
+ }
33
+
34
+ describe('schedule argument parsing is not polynomial-ReDoS-able', () => {
35
+ it('rejects a pumped `cron` spec in linear time', () => {
36
+ // Rejected because `(.+)$` cannot cross the newline, which is what forces the full backtrack.
37
+ const hostile = `cron "* * * * *"${' '.repeat(PUMP_LENGTH)}a\nb`;
38
+
39
+ const took = elapsedMs(() => {
40
+ const result = parseScheduleSpec(hostile, 0);
41
+ expect(result.ok).toBe(false);
42
+ });
43
+
44
+ expect(took).toBeLessThan(BUDGET_MS);
45
+ });
46
+
47
+ it('rejects pumped `/monitor` arguments in linear time', async () => {
48
+ const host = createTestAgentJobHost();
49
+ const hostile = `"cmd" "pattern"${' '.repeat(PUMP_LENGTH)}a\nb`;
50
+
51
+ const started = performance.now();
52
+ const result = await executeMonitorCommand(host, hostile);
53
+ const took = performance.now() - started;
54
+
55
+ expect(result.success).toBe(false);
56
+ expect(took).toBeLessThan(BUDGET_MS);
57
+ });
58
+
59
+ it('still parses well-formed specs identically', () => {
60
+ const parsed = parseScheduleSpec('cron "0 9 * * *" send the digest ', 0);
61
+
62
+ expect(parsed).toEqual({
63
+ ok: true,
64
+ spec: { cronExpression: '0 9 * * *', instruction: 'send the digest', recurring: true },
65
+ });
66
+ });
67
+
68
+ it('still parses well-formed `/monitor` arguments identically', async () => {
69
+ const spawnMonitorWake = vi.fn().mockResolvedValue({ id: 'job-1' });
70
+ const host = createTestAgentJobHost({ spawnMonitorWake });
71
+
72
+ const result = await executeMonitorCommand(host, ' "pnpm build" "error" report it ');
73
+
74
+ expect(result.success).toBe(true);
75
+ expect(spawnMonitorWake).toHaveBeenCalledWith(
76
+ expect.objectContaining({
77
+ command: 'pnpm build',
78
+ matchPattern: 'error',
79
+ agentInstruction: 'report it',
80
+ }),
81
+ );
82
+ });
83
+
84
+ it('parses a pumped leading-interval `/loop` in linear time', async () => {
85
+ // The shape CodeQL named for `\s+([\s\S]+)$`: an interval, then a long run of whitespace.
86
+ const spawnScheduledWake = vi.fn();
87
+ const host = createTestAgentJobHost({ spawnScheduledWake });
88
+ const hostile = `0s\t${'\t\t'.repeat(PUMP_LENGTH)}x`;
89
+
90
+ const started = performance.now();
91
+ const result = await executeLoopCommand(host, vi.fn(), hostile);
92
+ const took = performance.now() - started;
93
+
94
+ expect(result.success).toBe(false);
95
+ expect(spawnScheduledWake).not.toHaveBeenCalled();
96
+ expect(took).toBeLessThan(BUDGET_MS);
97
+ });
98
+
99
+ it.each([
100
+ ['5m check the build', 'check the build'],
101
+ ['5m \t\n check the build ', 'check the build'],
102
+ ['5M\ncheck', 'check'],
103
+ ])('still reads a leading-interval `/loop` identically: %j', async (args, instruction) => {
104
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_1' });
105
+ const host = createTestAgentJobHost({ spawnScheduledWake });
106
+
107
+ const result = await executeLoopCommand(host, vi.fn(), args);
108
+
109
+ expect(result.success).toBe(true);
110
+ expect(spawnScheduledWake).toHaveBeenCalledWith(
111
+ expect.objectContaining({ agentInstruction: instruction }),
112
+ );
113
+ });
114
+ });
@@ -0,0 +1,12 @@
1
+ export {
2
+ createScheduleCommandEntry,
3
+ createMonitorCommandEntry,
4
+ createLoopCommandEntry,
5
+ createScheduleCommandModule,
6
+ ScheduleCommandSource,
7
+ } from './schedule-command-module.js';
8
+ export { executeScheduleCommand, executeMonitorCommand } from './schedule-command.js';
9
+ export { executeLoopCommand } from './loop-command.js';
10
+ export type { ILoopCommandOptions } from './loop-command.js';
11
+ export { parseScheduleSpec } from './schedule-spec-parser.js';
12
+ export type { IScheduleSpec, TScheduleParseResult } from './schedule-spec-parser.js';