pi-harness-runtime 1.1.50 → 1.1.77

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 (202) hide show
  1. package/CHANGELOG.md +30 -9
  2. package/README.md +119 -0
  3. package/harness/langchain/agents.d.ts +143 -0
  4. package/harness/langchain/agents.test.d.ts +2 -0
  5. package/harness/langchain/agents.ts +196 -13
  6. package/harness/langchain/checkpointer.d.ts +48 -0
  7. package/harness/langchain/checkpointer.test.d.ts +8 -0
  8. package/harness/langchain/cron-cli.d.ts +12 -0
  9. package/harness/langchain/cron-cli.ts +169 -0
  10. package/harness/langchain/daemon.d.ts +161 -0
  11. package/harness/langchain/daemon.test.d.ts +11 -0
  12. package/harness/langchain/daemon.test.ts +3 -1
  13. package/harness/langchain/daemon.ts +230 -12
  14. package/harness/langchain/file-tools.d.ts +97 -0
  15. package/harness/langchain/file-tools.ts +14 -6
  16. package/harness/langchain/graph.d.ts +706 -0
  17. package/harness/langchain/graph.ts +158 -25
  18. package/harness/langchain/model-router.d.ts +63 -0
  19. package/harness/langchain/model-router.test.d.ts +2 -0
  20. package/harness/langchain/model-router.test.ts +191 -0
  21. package/harness/langchain/model-router.ts +188 -0
  22. package/harness/langchain/models.d.ts +72 -0
  23. package/harness/langchain/models.ts +111 -49
  24. package/harness/langchain/ping-pong-middleware.d.ts +28 -0
  25. package/harness/langchain/ping-pong-middleware.test.d.ts +2 -0
  26. package/harness/langchain/ping-pong-middleware.test.ts +69 -0
  27. package/harness/langchain/ping-pong-middleware.ts +204 -0
  28. package/harness/langchain/run.d.ts +15 -0
  29. package/harness/langchain/run.ts +15 -3
  30. package/harness/langchain/scoreboard.test.d.ts +13 -0
  31. package/harness/langchain/scoreboard.test.ts +23 -1
  32. package/harness/langchain/status-line.d.ts +91 -0
  33. package/harness/langchain/status-line.test.d.ts +8 -0
  34. package/harness/langchain/surge.d.ts +103 -0
  35. package/harness/langchain/surge.test.d.ts +10 -0
  36. package/harness/langchain/surge.test.ts +179 -0
  37. package/harness/langchain/surge.ts +188 -19
  38. package/harness/langchain/widget.d.ts +105 -0
  39. package/harness/langchain/widget.test.d.ts +8 -0
  40. package/harness/ssh-detach-interceptor.test.ts +263 -0
  41. package/harness/ssh-detach-interceptor.ts +438 -0
  42. package/index.ts +212 -47
  43. package/package.json +4 -2
  44. package/packages/a2a-adapter/package.json +1 -1
  45. package/packages/architecture-generator/package.json +1 -1
  46. package/packages/auth/package.json +1 -1
  47. package/packages/autonomous-refactor/package.json +1 -1
  48. package/packages/autonomous-runtime/package.json +1 -1
  49. package/packages/cache-strategy/package.json +1 -1
  50. package/packages/capability-registry/package.json +1 -1
  51. package/packages/checkpoint/package.json +1 -1
  52. package/packages/cli-plugin-sdk/package.json +1 -1
  53. package/packages/clipboard/package.json +1 -1
  54. package/packages/clipboard-plugin/package.json +1 -1
  55. package/packages/code-generation/package.json +1 -1
  56. package/packages/code-review/package.json +1 -1
  57. package/packages/codex-adapter/package.json +1 -1
  58. package/packages/config-capture/package.json +1 -1
  59. package/packages/context-compiler/package.json +1 -1
  60. package/packages/context-discovery/package.json +1 -1
  61. package/packages/context-manager/package.json +1 -1
  62. package/packages/cookie-sanitizer/package.json +1 -1
  63. package/packages/cost-optimizer/package.json +1 -1
  64. package/packages/dependency-analyzer/package.json +1 -1
  65. package/packages/django-plugin/package.json +1 -1
  66. package/packages/doc-generator/package.json +1 -1
  67. package/packages/evaluation-engine/package.json +1 -1
  68. package/packages/evaluation-runner/package.json +1 -1
  69. package/packages/event-bus/package.json +1 -1
  70. package/packages/event-store/package.json +1 -1
  71. package/packages/experience-replay/package.json +1 -1
  72. package/packages/feedback-collector/package.json +1 -1
  73. package/packages/file-copy-helper/package.json +1 -1
  74. package/packages/framework-detector/package.json +1 -1
  75. package/packages/framework-plugin-sdk/package.json +1 -1
  76. package/packages/frappe-plugin/package.json +1 -1
  77. package/packages/generic-web-plugin/package.json +1 -1
  78. package/packages/health-monitor/package.json +1 -1
  79. package/packages/intent-analyzer/dist/src/index.d.ts +2 -0
  80. package/packages/intent-analyzer/dist/src/index.d.ts.map +1 -1
  81. package/packages/intent-analyzer/dist/src/index.js +1 -0
  82. package/packages/intent-analyzer/dist/src/index.js.map +1 -1
  83. package/packages/intent-analyzer/dist/src/ping-pong.d.ts +56 -0
  84. package/packages/intent-analyzer/dist/src/ping-pong.d.ts.map +1 -0
  85. package/packages/intent-analyzer/dist/src/ping-pong.js +286 -0
  86. package/packages/intent-analyzer/dist/src/ping-pong.js.map +1 -0
  87. package/packages/intent-analyzer/package.json +1 -1
  88. package/packages/intent-analyzer/src/index.ts +13 -0
  89. package/packages/intent-analyzer/src/ping-pong.ts +387 -0
  90. package/packages/intent-analyzer/test/ping-pong.test.ts +96 -0
  91. package/packages/jev-judge/README.md +234 -0
  92. package/packages/jev-judge/dist/decision-engine.d.ts +100 -0
  93. package/packages/jev-judge/dist/decision-engine.d.ts.map +1 -0
  94. package/packages/jev-judge/dist/decision-engine.js +151 -0
  95. package/packages/jev-judge/dist/decision-engine.js.map +1 -0
  96. package/packages/jev-judge/dist/e2e-judge.d.ts +61 -0
  97. package/packages/jev-judge/dist/e2e-judge.d.ts.map +1 -0
  98. package/packages/jev-judge/dist/e2e-judge.js +268 -0
  99. package/packages/jev-judge/dist/e2e-judge.js.map +1 -0
  100. package/packages/jev-judge/dist/flaky-detector.d.ts +34 -0
  101. package/packages/jev-judge/dist/flaky-detector.d.ts.map +1 -0
  102. package/packages/jev-judge/dist/flaky-detector.js +154 -0
  103. package/packages/jev-judge/dist/flaky-detector.js.map +1 -0
  104. package/packages/jev-judge/dist/index.d.ts +77 -0
  105. package/packages/jev-judge/dist/index.d.ts.map +1 -0
  106. package/packages/jev-judge/dist/index.js +271 -0
  107. package/packages/jev-judge/dist/index.js.map +1 -0
  108. package/packages/jev-judge/dist/types.d.ts +97 -0
  109. package/packages/jev-judge/dist/types.d.ts.map +1 -0
  110. package/packages/jev-judge/dist/types.js +8 -0
  111. package/packages/jev-judge/dist/types.js.map +1 -0
  112. package/packages/jev-judge/package.json +46 -0
  113. package/packages/jev-judge/src/decision-engine.ts +231 -0
  114. package/packages/jev-judge/src/e2e-judge.ts +364 -0
  115. package/packages/jev-judge/src/flaky-detector.ts +190 -0
  116. package/packages/jev-judge/src/index.ts +345 -0
  117. package/packages/jev-judge/src/types.ts +131 -0
  118. package/packages/knowledge-graph/package.json +1 -1
  119. package/packages/knowledge-retrieval/package.json +1 -1
  120. package/packages/laravel-plugin/package.json +1 -1
  121. package/packages/learning-engine/package.json +1 -1
  122. package/packages/mcp-adapter/package.json +1 -1
  123. package/packages/memory-engine/package.json +1 -1
  124. package/packages/milestone-manager/package.json +1 -1
  125. package/packages/model-registry/package.json +1 -1
  126. package/packages/nextjs-plugin/package.json +1 -1
  127. package/packages/notification/package.json +1 -1
  128. package/packages/observability/package.json +1 -1
  129. package/packages/okf-indexer/package.json +1 -1
  130. package/packages/performance-optimizer/package.json +1 -1
  131. package/packages/privilege-broker/package.json +1 -1
  132. package/packages/project-analyzer/package.json +1 -1
  133. package/packages/project-bootstrap/package.json +1 -1
  134. package/packages/projection-engine/package.json +1 -1
  135. package/packages/prompt-compiler/package.json +1 -1
  136. package/packages/prompt-versioning/package.json +1 -1
  137. package/packages/provider-adapter-sdk/package.json +1 -1
  138. package/packages/provider-router/package.json +1 -1
  139. package/packages/provider-selector/package.json +1 -1
  140. package/packages/providers/package.json +1 -1
  141. package/packages/quota-manager/package.json +1 -1
  142. package/packages/rate-limiter/package.json +1 -1
  143. package/packages/react-vite-plugin/package.json +1 -1
  144. package/packages/release-manager/package.json +1 -1
  145. package/packages/requirement-compiler/package.json +1 -1
  146. package/packages/runtime/package.json +1 -1
  147. package/packages/scheduler/package.json +1 -1
  148. package/packages/scheduler-adapter/package.json +1 -1
  149. package/packages/session/package.json +1 -1
  150. package/packages/session-api/package.json +1 -1
  151. package/packages/session-export/package.json +1 -1
  152. package/packages/shared-context/package.json +1 -1
  153. package/packages/skill-mcp-client/package.json +1 -1
  154. package/packages/skill-registry/package.json +1 -1
  155. package/packages/sprint-planner/package.json +1 -1
  156. package/packages/subscription-engine/package.json +1 -1
  157. package/packages/task-compiler/package.json +1 -1
  158. package/packages/tencentdb-memory/package.json +1 -1
  159. package/packages/tencentdb-sync/package.json +1 -1
  160. package/packages/test-data-generator/package.json +1 -1
  161. package/packages/test-generator/package.json +1 -1
  162. package/packages/todo-bd-sync/README.md +71 -0
  163. package/packages/todo-bd-sync/package.json +1 -1
  164. package/packages/todo-bd-sync/src/extension.ts +16 -1
  165. package/packages/token-estimation/package.json +1 -1
  166. package/packages/token-optimizer/package.json +1 -1
  167. package/packages/trajectory/dist/src/approved-patterns.d.ts +51 -0
  168. package/packages/trajectory/dist/src/approved-patterns.d.ts.map +1 -0
  169. package/packages/trajectory/dist/src/approved-patterns.js +134 -0
  170. package/packages/trajectory/dist/src/approved-patterns.js.map +1 -0
  171. package/packages/trajectory/dist/src/index.d.ts +13 -0
  172. package/packages/trajectory/dist/src/index.d.ts.map +1 -0
  173. package/packages/trajectory/dist/src/index.js +12 -0
  174. package/packages/trajectory/dist/src/index.js.map +1 -0
  175. package/packages/trajectory/dist/src/store.d.ts +69 -0
  176. package/packages/trajectory/dist/src/store.d.ts.map +1 -0
  177. package/packages/trajectory/dist/src/store.js +253 -0
  178. package/packages/trajectory/dist/src/store.js.map +1 -0
  179. package/packages/trajectory/dist/src/types.d.ts +73 -0
  180. package/packages/trajectory/dist/src/types.d.ts.map +1 -0
  181. package/packages/trajectory/dist/src/types.js +9 -0
  182. package/packages/trajectory/dist/src/types.js.map +1 -0
  183. package/packages/trajectory/dist/test/store.test.d.ts +2 -0
  184. package/packages/trajectory/dist/test/store.test.d.ts.map +1 -0
  185. package/packages/trajectory/dist/test/store.test.js +228 -0
  186. package/packages/trajectory/dist/test/store.test.js.map +1 -0
  187. package/packages/trajectory/package.json +29 -0
  188. package/packages/trajectory/src/approved-patterns.ts +180 -0
  189. package/packages/trajectory/src/index.ts +26 -0
  190. package/packages/trajectory/src/store.ts +285 -0
  191. package/packages/trajectory/src/types.ts +84 -0
  192. package/packages/trajectory/test/store.test.d.ts +2 -0
  193. package/packages/trajectory/test/store.test.ts +250 -0
  194. package/packages/tui/package.json +1 -1
  195. package/packages/types/package.json +1 -1
  196. package/packages/workflow-events/package.json +1 -1
  197. package/packages/workspace-scanner/package.json +1 -1
  198. package/packages/worktree/package.json +1 -1
  199. package/packages/write-review/package.json +1 -1
  200. package/packages/write-review/src/blackboard.ts +24 -1
  201. package/packages/write-review/src/types.ts +13 -0
  202. package/skills/harness-runtime/SKILL.md +67 -4
@@ -0,0 +1,263 @@
1
+ /**
2
+ * SSH Detach Interceptor — Tests
3
+ *
4
+ * Key insight on quoting:
5
+ * - `ssh user@host 'cmd &'` — & is INSIDE quotes → safe, SSH completes normally
6
+ * - `ssh user@host "cmd" &` — & is OUTSIDE quotes → risky, backgrounds SSH itself
7
+ * - `ssh user@host 'kill ... &' "2>/dev/null"` — & inside quotes, redirect outside → risky
8
+ */
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert";
11
+ import {
12
+ isSshCommand,
13
+ isDetachedPattern,
14
+ hasBareAmpersand,
15
+ transformToDetached,
16
+ detectSsh,
17
+ isForegroundDaemonSsh,
18
+ addSshTimeoutGuard,
19
+ getDefaultSshTimeoutSeconds,
20
+ } from "./ssh-detach-interceptor.js";
21
+
22
+ describe("isSshCommand", () => {
23
+ const cases: Array<[string, boolean]> = [
24
+ ["ssh user@host ls", true],
25
+ ["ssh -o StrictHostKeyChecking=no user@host ls", true],
26
+ ["echo 'pass' | ssh user@host ls", true],
27
+ ["curl https://example.com", false],
28
+ ["cd /home && ls", false],
29
+ ["ssh user@host 'echo test &'", true],
30
+ ["docker exec container ls", false],
31
+ ];
32
+ cases.forEach(([cmd, expected]) => {
33
+ it(`${cmd.slice(0, 50)} → ${expected}`, () => {
34
+ assert.strictEqual(isSshCommand(cmd), expected);
35
+ });
36
+ });
37
+ });
38
+
39
+ describe("isDetachedPattern", () => {
40
+ const cases: Array<[string, boolean]> = [
41
+ // & is inside quotes → not detached (no nohup locally visible)
42
+ ["ssh user@host 'cmd > /tmp/log 2>&1 & echo DONE'", false],
43
+ // bare & inside quotes → not detached
44
+ ["ssh user@host 'cmd &'", false],
45
+ // nohup + redirect + echo DONE → detached
46
+ ["ssh user@host 'nohup cmd > /tmp/log 2>&1 & echo DONE'", true],
47
+ // missing echo DONE → not detached
48
+ ["ssh user@host 'cmd > /tmp/log &'", false],
49
+ // missing redirect → not detached
50
+ ["ssh user@host 'nohup cmd &'", false],
51
+ // non-SSH → not detached
52
+ ["curl https://example.com", false],
53
+ ];
54
+ cases.forEach(([cmd, expected]) => {
55
+ it(`${cmd.slice(0, 55)} → ${expected}`, () => {
56
+ assert.strictEqual(isDetachedPattern(cmd), expected);
57
+ });
58
+ });
59
+ });
60
+
61
+ describe("hasBareAmpersand", () => {
62
+ // & outside quotes = risky (backgrounds SSH itself)
63
+ const risky: Array<[string, boolean]> = [
64
+ ["ssh user@host 'cmd' &", true],
65
+ ["ssh user@host 'kill 123' &", true],
66
+ ["echo 'pass' | ssh user@host 'kill 123' &", true],
67
+ ["ssh user@host 'cmd > /tmp/log 2>&1' &", true],
68
+ ];
69
+ // & inside quotes = safe (SSH completes, remote side backgrounds)
70
+ const safe: Array<[string, boolean]> = [
71
+ ["ssh user@host 'cmd &'", false],
72
+ ["ssh user@host 'kill -9 123 && uvicorn &'", false],
73
+ ["ssh user@host 'nohup cmd &'", false],
74
+ ["ssh user@host 'cmd'", false],
75
+ ["curl https://example.com", false],
76
+ ];
77
+ [...risky, ...safe].forEach(([cmd, expected]) => {
78
+ it(`${cmd.slice(0, 50)} → ${expected}`, () => {
79
+ assert.strictEqual(hasBareAmpersand(cmd), expected);
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("transformToDetached", () => {
85
+ it("leaves non-SSH commands unchanged", () => {
86
+ const cmd = "curl https://example.com";
87
+ assert.strictEqual(transformToDetached(cmd), cmd);
88
+ });
89
+
90
+ it("leaves already-detached SSH commands unchanged", () => {
91
+ const cmd = "ssh user@host 'cmd > /tmp/log 2>&1 & echo DONE'";
92
+ assert.strictEqual(transformToDetached(cmd), cmd);
93
+ });
94
+
95
+ it("transforms bare ssh 'cmd' & (risky: & outside quotes)", () => {
96
+ const cmd = "ssh user@host 'kill -9 123; sleep 2; uvicorn' &";
97
+ const result = transformToDetached(cmd);
98
+ assert.notStrictEqual(result, cmd);
99
+ assert.ok(result.includes("nohup"), "should add nohup");
100
+ assert.ok(result.includes("/tmp/herdr-ssh.log"), "should add log redirect");
101
+ assert.ok(result.includes("2>&1"), "should add stderr redirect");
102
+ assert.ok(result.includes("echo 'SSH_DETACHED'"), "should add DONE echo");
103
+ });
104
+
105
+ it("transforms pipelined ssh with bare & (risky: & outside quotes)", () => {
106
+ const cmd = "echo 'pass' | ssh user@host 'kill -9 123' &";
107
+ const result = transformToDetached(cmd);
108
+ assert.notStrictEqual(result, cmd);
109
+ assert.ok(result.includes("nohup"));
110
+ assert.ok(result.includes("echo 'SSH_DETACHED'"));
111
+ });
112
+
113
+ it("leaves & inside quotes unchanged (safe: SSH completes)", () => {
114
+ const cmd = "ssh user@host 'kill -9 123; uvicorn &'";
115
+ const result = transformToDetached(cmd);
116
+ assert.strictEqual(result, cmd); // no transform needed
117
+ });
118
+
119
+ it("uses custom log file", () => {
120
+ const cmd = "ssh user@host 'cmd' &";
121
+ const result = transformToDetached(cmd, { logFile: "/var/log/my-ssh.log" });
122
+ assert.ok(result.includes("/var/log/my-ssh.log"));
123
+ });
124
+ });
125
+
126
+ describe("detectSsh", () => {
127
+ it("warns on SSH with bare & outside quotes", () => {
128
+ const result = detectSsh("ssh user@host 'cmd' &");
129
+ assert.strictEqual(result.isSsh, true);
130
+ assert.strictEqual(result.hasBareAmpersand, true);
131
+ assert.strictEqual(result.isDetached, false);
132
+ assert.ok(result.suggestion.includes("nohup"));
133
+ });
134
+
135
+ it("no warning for & inside quotes (SSH completes normally)", () => {
136
+ const result = detectSsh("ssh user@host 'kill 123 && uvicorn &'");
137
+ assert.strictEqual(result.isSsh, true);
138
+ assert.strictEqual(result.isDetached, false);
139
+ assert.strictEqual(result.hasBareAmpersand, false);
140
+ assert.strictEqual(result.suggestion, "");
141
+ });
142
+
143
+ it("no detection for non-SSH", () => {
144
+ const result = detectSsh("curl https://example.com");
145
+ assert.strictEqual(result.isSsh, false);
146
+ assert.strictEqual(result.suggestion, "");
147
+ });
148
+ });
149
+
150
+ describe("isForegroundDaemonSsh", () => {
151
+ // The exact command that caused the 1911.9s hang (2026-09-14)
152
+ const HANG_CMD =
153
+ "echo 'manlucha' | ssh -tt frappe@100.101.238.114 \"cd /home/frappe/amos-saas/backend/services/worker && AMOS_API_URL=https://api.ezsysbot.com AMOS_WORKER_API_KEY=test /home/frappe/amos-saas/backend/.venv/bin/python -m amos_worker --video 2>&1 | head -10\"";
154
+
155
+ const risky: Array<[string, string]> = [
156
+ [
157
+ HANG_CMD,
158
+ "foreground ssh -tt running amos_worker poller (real 1911.9s hang)",
159
+ ],
160
+ [
161
+ 'ssh host "uvicorn main:app --host 0.0.0.0 --port 8100"',
162
+ "foreground uvicorn",
163
+ ],
164
+ ["ssh host 'python -m celery worker'", "celery worker"],
165
+ ['ssh host "tail -f /var/log/app.log"', "tail -f"],
166
+ ['ssh host "npm run dev"', "npm run dev"],
167
+ [
168
+ 'ssh host "python -m my_package.daemon --poll"',
169
+ "python -m with daemon in module path",
170
+ ],
171
+ ];
172
+ risky.forEach(([cmd, label]) => {
173
+ it(`RISKY: ${label}`, () => {
174
+ assert.strictEqual(isForegroundDaemonSsh(cmd), true, cmd.slice(0, 80));
175
+ });
176
+ });
177
+
178
+ const safe: Array<[string, string]> = [
179
+ ["ssh host 'ls -la'", "plain remote command"],
180
+ [
181
+ "timeout 60s ssh -o BatchMode=yes host 'uvicorn main:app'",
182
+ "local timeout guard",
183
+ ],
184
+ [
185
+ "ssh host 'timeout 30 python -m amos_worker --video'",
186
+ "remote timeout guard",
187
+ ],
188
+ [
189
+ "ssh host 'nohup python -m amos_worker --video > /tmp/w.log 2>&1 & echo DONE'",
190
+ "nohup detached",
191
+ ],
192
+ ["ssh -f host 'uvicorn main:app'", "ssh -f backgrounds itself"],
193
+ [
194
+ "python -m amos_worker --video",
195
+ "local (non-ssh) daemon command — not our scope",
196
+ ],
197
+ ["curl https://example.com", "not ssh"],
198
+ ];
199
+ safe.forEach(([cmd, label]) => {
200
+ it(`SAFE: ${label}`, () => {
201
+ assert.strictEqual(isForegroundDaemonSsh(cmd), false, cmd.slice(0, 80));
202
+ });
203
+ });
204
+ });
205
+
206
+ describe("addSshTimeoutGuard", () => {
207
+ it("prefixes the ssh segment with timeout, preserving pipeline prefix", () => {
208
+ const cmd = "echo 'x' | ssh -tt host \"python -m amos_worker --video\"";
209
+ assert.strictEqual(
210
+ addSshTimeoutGuard(cmd, 300),
211
+ "echo 'x' | timeout 300s ssh -tt host \"python -m amos_worker --video\"",
212
+ );
213
+ });
214
+
215
+ it("uses default seconds when omitted", () => {
216
+ const cmd = "ssh host 'uvicorn main:app'";
217
+ assert.ok(addSshTimeoutGuard(cmd).startsWith("timeout "));
218
+ assert.ok(addSshTimeoutGuard(cmd).includes("s ssh host"));
219
+ });
220
+
221
+ it("invalid seconds returns command unchanged", () => {
222
+ const cmd = "ssh host 'uvicorn main:app'";
223
+ assert.strictEqual(addSshTimeoutGuard(cmd, 0), cmd);
224
+ assert.strictEqual(addSshTimeoutGuard(cmd, NaN), cmd);
225
+ });
226
+
227
+ it("non-ssh command returns unchanged", () => {
228
+ assert.strictEqual(addSshTimeoutGuard("ls -la"), "ls -la");
229
+ });
230
+ });
231
+
232
+ describe("getDefaultSshTimeoutSeconds", () => {
233
+ it("defaults to 300", () => {
234
+ delete process.env.PI_HARNESS_SSH_TIMEOUT_S;
235
+ assert.strictEqual(getDefaultSshTimeoutSeconds(), 300);
236
+ });
237
+
238
+ it("honours PI_HARNESS_SSH_TIMEOUT_S", () => {
239
+ const prev = process.env.PI_HARNESS_SSH_TIMEOUT_S;
240
+ try {
241
+ process.env.PI_HARNESS_SSH_TIMEOUT_S = "120";
242
+ assert.strictEqual(getDefaultSshTimeoutSeconds(), 120);
243
+ process.env.PI_HARNESS_SSH_TIMEOUT_S = "bogus";
244
+ assert.strictEqual(getDefaultSshTimeoutSeconds(), 300);
245
+ } finally {
246
+ if (prev === undefined) delete process.env.PI_HARNESS_SSH_TIMEOUT_S;
247
+ else process.env.PI_HARNESS_SSH_TIMEOUT_S = prev;
248
+ }
249
+ });
250
+ });
251
+
252
+ describe("detectSsh (timeout guard)", () => {
253
+ it("flags foreground daemon ssh with timeout suggestion", () => {
254
+ const result = detectSsh("ssh -tt host 'python -m amos_worker --video'");
255
+ assert.strictEqual(result.needsTimeoutGuard, true);
256
+ assert.ok(result.suggestion.includes("timeout"));
257
+ });
258
+
259
+ it("no timeout flag for plain ssh", () => {
260
+ const result = detectSsh("ssh host 'ls -la'");
261
+ assert.strictEqual(result.needsTimeoutGuard, false);
262
+ });
263
+ });
@@ -0,0 +1,438 @@
1
+ /**
2
+ * SSH Detach Interceptor
3
+ *
4
+ * Automatically transforms bare `ssh ... &` commands into detached patterns
5
+ * that don't hang the SSH session.
6
+ *
7
+ * Problem: `ssh user@host "command &"` keeps SSH open waiting for the background
8
+ * process. This causes 2000+ second hangs.
9
+ *
10
+ * Solution: Auto-inject nohup + redirect + DONE echo so SSH exits immediately
11
+ * while the process continues running on the server.
12
+ */
13
+
14
+ /**
15
+ * Find the last SSH command in a string (handles pipelined commands).
16
+ * Returns { start, end } of the ssh ... part, or null if no SSH found.
17
+ */
18
+ function findLastSshCommand(s: string): { start: number; end: number } | null {
19
+ // Find the LAST occurrence of "ssh " (word boundary)
20
+ const matches = [...s.matchAll(/\bssh\s+/gm)];
21
+ if (matches.length === 0) return null;
22
+
23
+ const lastMatch = matches[matches.length - 1];
24
+ const sshStart = lastMatch.index!;
25
+
26
+ // Find where the SSH command ends:
27
+ // Look for a | that's OUTSIDE quotes, which starts a new pipe segment
28
+ let sshEnd = s.length;
29
+ let depth = 0;
30
+ for (let i = sshStart; i < s.length; i++) {
31
+ const ch = s[i];
32
+ if (ch === "\\" && i + 1 < s.length) {
33
+ i++; // skip escaped char
34
+ continue;
35
+ }
36
+ if (depth > 0) {
37
+ if ((ch === '"' && depth === 1) || (ch === "'" && depth === 2)) {
38
+ depth = 0;
39
+ }
40
+ } else if (ch === '"') depth = 1;
41
+ else if (ch === "'") depth = 2;
42
+ else if (ch === "|") {
43
+ sshEnd = i;
44
+ break;
45
+ }
46
+ }
47
+ return { start: sshStart, end: sshEnd };
48
+ }
49
+
50
+ /**
51
+ * Check if a command is an SSH command.
52
+ */
53
+ export function isSshCommand(command: string): boolean {
54
+ return /\bssh\s+/m.test(command.trim());
55
+ }
56
+
57
+ /**
58
+ * Check if an SSH command uses the detached pattern:
59
+ * nohup + redirect + trailing DONE echo. The chain may live remotely
60
+ * (inside the quoted command) or locally — either way ssh exits promptly.
61
+ *
62
+ * Note: a quoted `cmd &` alone (no redirect/echo) does NOT count.
63
+ */
64
+ export function isDetachedPattern(command: string): boolean {
65
+ const sshRange = findLastSshCommand(command);
66
+ if (!sshRange) return false;
67
+ const sshPart = command.slice(sshRange.start, sshRange.end);
68
+ return (
69
+ /nohup\b/.test(sshPart) &&
70
+ (/>\s*\//.test(sshPart) || /2>&1/.test(sshPart)) &&
71
+ /echo\s+['"]?\w+['"]?\s*$/.test(command.trim())
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Check if an SSH command has a trailing bare & OUTSIDE the quoted remote command.
77
+ * This is the risky pattern that backgrounds the SSH connection itself.
78
+ *
79
+ * NOT risky (safe): & inside the quoted remote command string
80
+ * RISKY (needs transform): & outside the quoted remote command (backgrounds SSH)
81
+ */
82
+ export function hasBareAmpersand(command: string): boolean {
83
+ if (!isSshCommand(command)) return false;
84
+ if (isDetachedPattern(command)) return false;
85
+
86
+ const sshRange = findLastSshCommand(command);
87
+ if (!sshRange) return false;
88
+ const sshPart = command.slice(sshRange.start, sshRange.end);
89
+
90
+ // Extract the ssh prefix (ssh + host + flags) and the remote command argument
91
+ // The remote command is typically quoted; we need to find where it ends
92
+ // Strategy: find the last quoted string in the ssh part
93
+ let remoteCmdEnd = sshPart.length;
94
+
95
+ // Scan for the last closing quote outside escaped chars
96
+ for (let i = sshPart.length - 1; i >= 0; i--) {
97
+ if (sshPart[i] === "\\" && i > 0) {
98
+ i--; // skip escaped char
99
+ continue;
100
+ }
101
+ // Count if we see a quote — the first unescaped quote pair we encounter
102
+ // marks the end of the quoted remote command
103
+ if (sshPart[i] === '"' || sshPart[i] === "'") {
104
+ // Find the matching opening quote going forward
105
+ const closeQuote = sshPart[i];
106
+ let found = false;
107
+ for (let j = i - 1; j >= 0; j--) {
108
+ if (sshPart[j] === "\\") {
109
+ j--;
110
+ continue;
111
+ }
112
+ if (sshPart[j] === closeQuote) {
113
+ found = true;
114
+ break;
115
+ }
116
+ }
117
+ if (found) {
118
+ remoteCmdEnd = i + 1;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ // Everything after the quoted remote command
125
+ const afterQuotes = sshPart.slice(remoteCmdEnd).trim();
126
+ // Check if there's a bare & (not &&, not |&) outside the quoted part
127
+ return /^\s*&\s*$/.test(afterQuotes);
128
+ }
129
+
130
+ /**
131
+ * Check if an SSH command has a `&& ... &` or `; ... &` pattern INSIDE
132
+ * the quoted remote command. This means: "run A, then run B, then background B".
133
+ *
134
+ * This is risky because SSH waits for the WHOLE quoted command chain to complete.
135
+ * If B hangs (import error, DB timeout, port conflict), SSH never exits.
136
+ *
137
+ * RISKY: ssh ... "cd X && python uvicorn ... &"
138
+ * RISKY: ssh ... "cmd1; cmd2 &".
139
+ * SAFE: ssh ... "cmd &" (just backgrounds one command)
140
+ */
141
+ export function hasCommandChainWithAmpersand(command: string): boolean {
142
+ if (!isSshCommand(command)) return false;
143
+ if (isDetachedPattern(command)) return false;
144
+
145
+ const sshRange = findLastSshCommand(command);
146
+ if (!sshRange) return false;
147
+ const sshPart = command.slice(sshRange.start, sshRange.end);
148
+
149
+ // Find the quoted remote command
150
+ const quoteMatch = sshPart.match(/['"](.+?)['"]\s*$/s);
151
+ if (!quoteMatch) return false;
152
+
153
+ const quotedContent = quoteMatch[1];
154
+ // Risky: command chain followed by &
155
+ // e.g. "cd /path && python uvicorn 2>&1 &" or "cmd1 && cmd2 &"
156
+ // Safe: just one command with & ("cmd &") or detached ("cmd > log 2>&1 & echo DONE")
157
+ const trimmed = quotedContent.trim();
158
+ // Detect && or ; followed by anything, then &
159
+ return /&&.+\s+&\s*$/.test(trimmed) || /;.+\s+&\s*$/.test(trimmed);
160
+ }
161
+
162
+ /**
163
+ * Transform a command to use detached SSH pattern.
164
+ * Removes trailing unquoted & and appends nohup wrapper.
165
+ *
166
+ * Before: ssh user@host 'kill 123' &
167
+ * After: ssh user@host 'kill 123' nohup '' > /tmp/herdr-ssh.log 2>&1 & echo 'SSH_DETACHED'
168
+ *
169
+ * The && operators inside the quoted command are preserved.
170
+ */
171
+ export function transformToDetached(
172
+ command: string,
173
+ config: { logFile?: string } = {},
174
+ ): string {
175
+ const { logFile = "/tmp/herdr-ssh.log" } = config;
176
+
177
+ if (isDetachedPattern(command)) return command;
178
+
179
+ const sshRange = findLastSshCommand(command);
180
+ if (!sshRange) return command;
181
+
182
+ const beforeSsh = command.slice(0, sshRange.start);
183
+ const sshPart = command.slice(sshRange.start, sshRange.end);
184
+ const afterSsh = command.slice(sshRange.end);
185
+
186
+ // Helper: find the position AFTER the closing quote of the last quoted arg
187
+ // Returns position AFTER the closing quote, or s.length if no quotes found.
188
+ function findClosingQuotePos(s: string): number {
189
+ for (let i = s.length - 1; i >= 0; i--) {
190
+ if (s[i] === "\\" && i > 0) {
191
+ i--;
192
+ continue;
193
+ }
194
+ if (s[i] === '"' || s[i] === "'") {
195
+ const cq = s[i];
196
+ let found = false;
197
+ for (let j = i - 1; j >= 0; j--) {
198
+ if (s[j] === "\\") {
199
+ j--;
200
+ continue;
201
+ }
202
+ if (s[j] === cq) {
203
+ found = true;
204
+ break;
205
+ }
206
+ }
207
+ if (found) return i + 1; // AFTER closing quote
208
+ }
209
+ }
210
+ return s.length;
211
+ }
212
+
213
+ // Helper: find the position OF the closing quote (excluding the quote itself)
214
+ function findClosingQuotePosExcl(s: string): number {
215
+ for (let i = s.length - 1; i >= 0; i--) {
216
+ if (s[i] === "\\" && i > 0) {
217
+ i--;
218
+ continue;
219
+ }
220
+ if (s[i] === '"' || s[i] === "'") {
221
+ const cq = s[i];
222
+ let found = false;
223
+ for (let j = i - 1; j >= 0; j--) {
224
+ if (s[j] === "\\") {
225
+ j--;
226
+ continue;
227
+ }
228
+ if (s[j] === cq) {
229
+ found = true;
230
+ break;
231
+ }
232
+ }
233
+ if (found) return i; // AT (not after) closing quote
234
+ }
235
+ }
236
+ return s.length;
237
+ }
238
+
239
+ // Pattern 1: bare & OUTSIDE the quoted remote command
240
+ // e.g. ssh host 'kill 123' &
241
+ // remoteCmdEnd1 includes the closing quote in sshPrefix
242
+ const remoteCmdEnd1 = findClosingQuotePos(sshPart);
243
+ const sshPrefix = sshPart.slice(0, remoteCmdEnd1).trimEnd();
244
+ const afterRemoteCmd = sshPart.slice(remoteCmdEnd1);
245
+ if (/^\s*&\s*$/.test(afterRemoteCmd.trim())) {
246
+ return (
247
+ beforeSsh +
248
+ sshPrefix +
249
+ ` nohup '' > ${logFile} 2>&1 & echo 'SSH_DETACHED'` +
250
+ afterSsh
251
+ );
252
+ }
253
+
254
+ // Pattern 2: command chain with & INSIDE the quoted part
255
+ // e.g. ssh host "cmd1 && cmd2 &" or ssh host "cmd1 && cmd2 2>&1 &"
256
+ // Find opening quote to exclude closing quote from beforeQuote (needed for chain regex)
257
+ let openQuotePos = -1;
258
+ for (let i = 0; i < sshPart.length; i++) {
259
+ if (sshPart[i] === "'" || sshPart[i] === '"') {
260
+ openQuotePos = i;
261
+ break;
262
+ }
263
+ }
264
+ if (openQuotePos < 0) return command;
265
+
266
+ // Exclude closing quote so chain regex can match the & that precedes it
267
+ const remoteCmdEnd2 = findClosingQuotePosExcl(sshPart);
268
+ const beforeQuote = sshPart.slice(0, remoteCmdEnd2);
269
+
270
+ // Chain with 2>&1 redirect first (&& chains only — `;` chains inside quotes
271
+ // are left alone per contract: SSH completes normally for them)
272
+ const chainMatch2 = beforeQuote.match(/(.+?)&&\s*(.+?)\s+(2>&1)\s*&\s*$/);
273
+ if (chainMatch2) {
274
+ return (
275
+ beforeSsh +
276
+ chainMatch2[1] +
277
+ "&&" +
278
+ ` nohup ${chainMatch2[2]} ${chainMatch2[3]} & echo 'SSH_DETACHED'"`
279
+ );
280
+ }
281
+
282
+ // Chain without 2>&1
283
+ const chainMatch = beforeQuote.match(/(.+?)&&\s*(.+?)\s*&\s*$/);
284
+ if (chainMatch) {
285
+ return (
286
+ beforeSsh +
287
+ chainMatch[1] +
288
+ "&&" +
289
+ ` nohup ${chainMatch[2]} > ${logFile} 2>&1 & echo 'SSH_DETACHED'"`
290
+ );
291
+ }
292
+
293
+ return command;
294
+ }
295
+
296
+ /**
297
+ * Detect SSH in a bash command and return warning info.
298
+ */
299
+ export interface SshDetection {
300
+ isSsh: boolean;
301
+ isDetached: boolean;
302
+ hasBareAmpersand: boolean;
303
+ /** Foreground SSH running a daemon-ish remote command with no timeout guard. */
304
+ needsTimeoutGuard: boolean;
305
+ suggestion: string;
306
+ }
307
+
308
+ export function detectSsh(command: string): SshDetection {
309
+ const ssh = isSshCommand(command);
310
+ const detached = ssh ? isDetachedPattern(command) : false;
311
+ const bare = ssh ? hasBareAmpersand(command) : false;
312
+ const needsTimeout = ssh ? isForegroundDaemonSsh(command) : false;
313
+
314
+ let suggestion = "";
315
+ if (ssh && !detached && bare) {
316
+ suggestion =
317
+ "Warning: trailing '&' outside quotes backgrounds SSH itself. Use nohup ... > /tmp/log 2>&1 & echo DONE.";
318
+ } else if (needsTimeout) {
319
+ suggestion = `Warning: foreground SSH running a long-lived remote command. Auto-wrapped with timeout ${getDefaultSshTimeoutSeconds()}s (set PI_HARNESS_SSH_TIMEOUT_S to change).`;
320
+ }
321
+
322
+ return {
323
+ isSsh: ssh,
324
+ isDetached: detached,
325
+ hasBareAmpersand: bare,
326
+ needsTimeoutGuard: needsTimeout,
327
+ suggestion,
328
+ };
329
+ }
330
+
331
+ // --- Foreground daemon-over-SSH timeout guard ---------------------------------
332
+ //
333
+ // Problem: `ssh -tt host "python -m worker --video | head -10"` runs a poller
334
+ // daemon in the foreground. head -10 exits, the daemon swallows BrokenPipeError
335
+ // and keeps ticking; ssh -tt holds the session until the whole remote pipeline
336
+ // ends — which never happens. Observed: 1911.9s hang until manual abort.
337
+ //
338
+ // Solution: detect daemon-ish remote payloads without any timeout protection
339
+ // and prefix the ssh invocation with `timeout Ns` so the local side is always
340
+ // guaranteed to return.
341
+
342
+ /** Long-running remote command patterns (pollers, servers, watchers). */
343
+ const REMOTE_DAEMON_PATTERNS: RegExp[] = [
344
+ /\buvicorn\b/,
345
+ /\bgunicorn\b/,
346
+ /\bcelery\b/,
347
+ /\bflask\s+run\b/,
348
+ /\btail\s+-f\b/,
349
+ /\bnpm\s+(run\s+)?(dev|start)\b/,
350
+ /\bbun\s+(run\s+)?(dev|start)\b/,
351
+ /\byarn\s+(dev|start)\b/,
352
+ /\bnode\s+\S*(server|daemon)\S*/,
353
+ /--watch(\s|=)/,
354
+ /\bpython3?\s+-m\s+\S*(worker|daemon|server|bot|poller|scheduler|agent)\S*/,
355
+ /\buv\s+run\s+\S*(worker|daemon|server|bot|poller|scheduler|agent)\S*/,
356
+ ];
357
+
358
+ export function getDefaultSshTimeoutSeconds(): number {
359
+ const raw = process.env.PI_HARNESS_SSH_TIMEOUT_S;
360
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
361
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 300;
362
+ }
363
+
364
+ /**
365
+ * Extract the remote command payload of an ssh invocation (the quoted
366
+ * argument). Falls back to the whole ssh segment when unquoted.
367
+ */
368
+ function extractRemotePayload(sshPart: string): string {
369
+ const m = sshPart.match(/["'](.+)["']/s);
370
+ return m ? m[1] : sshPart;
371
+ }
372
+
373
+ /**
374
+ * The ssh flag segment: everything before the quoted remote command.
375
+ * Used for host-side flag checks (-f, -o, ...) so that remote payload args
376
+ * like `tail -f` are not mistaken for ssh flags.
377
+ */
378
+ function getSshFlagSegment(sshPart: string): string {
379
+ const quotePos = sshPart.search(/["']/);
380
+ return quotePos >= 0 ? sshPart.slice(0, quotePos) : sshPart;
381
+ }
382
+
383
+ /**
384
+ * Check if an SSH command runs a daemon-ish remote command in the
385
+ * foreground with no timeout protection anywhere (local or remote).
386
+ *
387
+ * RISKY: ssh -tt host "cd app && python -m amos_worker --video 2>&1 | head -10"
388
+ * SAFE: timeout 60s ssh host "python -m amos_worker --video"
389
+ * SAFE: ssh host "timeout 30 python -m amos_worker --video"
390
+ * SAFE: ssh host "nohup python -m amos_worker ... &" (detached patterns)
391
+ * SAFE: ssh -f host "..." (ssh backgrounds itself)
392
+ */
393
+ export function isForegroundDaemonSsh(command: string): boolean {
394
+ if (!isSshCommand(command)) return false;
395
+ if (isDetachedPattern(command)) return false;
396
+ if (hasBareAmpersand(command)) return false; // handled by detach transform
397
+ if (/\bnohup\b/.test(command)) return false;
398
+
399
+ const range = findLastSshCommand(command);
400
+ if (!range) return false;
401
+ const sshPart = command.slice(range.start, range.end);
402
+
403
+ // ssh -f: requests ssh itself to go to background before executing.
404
+ // Only inspect the flag segment — `tail -f` in the remote payload is NOT ssh -f.
405
+ const flagSegment = getSshFlagSegment(sshPart);
406
+ if (/(^|\s)-f(\s|$)/.test(flagSegment)) return false;
407
+
408
+ // Any timeout guard (local `timeout Ns ssh` or remote `timeout N cmd`)
409
+ if (/\btimeout\b/.test(command)) return false;
410
+
411
+ const payload = extractRemotePayload(sshPart);
412
+ // Remote-side backgrounding (`... &` inside quotes): SSH completes when the
413
+ // remote shell returns — per project convention this is the safe pattern.
414
+ if (/&\s*$/.test(payload.trim())) return false;
415
+
416
+ return REMOTE_DAEMON_PATTERNS.some((re) => re.test(payload));
417
+ }
418
+
419
+ /**
420
+ * Prefix the last ssh invocation with `timeout Ns` so the local bash call is
421
+ * guaranteed to return even if the remote daemon never exits.
422
+ *
423
+ * Before: echo 'x' | ssh -tt host "python -m amos_worker --video | head -10"
424
+ * After: echo 'x' | timeout 300s ssh -tt host "python -m amos_worker --video | head -10"
425
+ */
426
+ export function addSshTimeoutGuard(
427
+ command: string,
428
+ seconds: number = getDefaultSshTimeoutSeconds(),
429
+ ): string {
430
+ if (!Number.isFinite(seconds) || seconds <= 0) return command;
431
+ const range = findLastSshCommand(command);
432
+ if (!range) return command;
433
+ return (
434
+ command.slice(0, range.start) +
435
+ `timeout ${seconds}s ` +
436
+ command.slice(range.start)
437
+ );
438
+ }