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.
- package/CHANGELOG.md +30 -9
- package/README.md +119 -0
- package/harness/langchain/agents.d.ts +143 -0
- package/harness/langchain/agents.test.d.ts +2 -0
- package/harness/langchain/agents.ts +196 -13
- package/harness/langchain/checkpointer.d.ts +48 -0
- package/harness/langchain/checkpointer.test.d.ts +8 -0
- package/harness/langchain/cron-cli.d.ts +12 -0
- package/harness/langchain/cron-cli.ts +169 -0
- package/harness/langchain/daemon.d.ts +161 -0
- package/harness/langchain/daemon.test.d.ts +11 -0
- package/harness/langchain/daemon.test.ts +3 -1
- package/harness/langchain/daemon.ts +230 -12
- package/harness/langchain/file-tools.d.ts +97 -0
- package/harness/langchain/file-tools.ts +14 -6
- package/harness/langchain/graph.d.ts +706 -0
- package/harness/langchain/graph.ts +158 -25
- package/harness/langchain/model-router.d.ts +63 -0
- package/harness/langchain/model-router.test.d.ts +2 -0
- package/harness/langchain/model-router.test.ts +191 -0
- package/harness/langchain/model-router.ts +188 -0
- package/harness/langchain/models.d.ts +72 -0
- package/harness/langchain/models.ts +111 -49
- package/harness/langchain/ping-pong-middleware.d.ts +28 -0
- package/harness/langchain/ping-pong-middleware.test.d.ts +2 -0
- package/harness/langchain/ping-pong-middleware.test.ts +69 -0
- package/harness/langchain/ping-pong-middleware.ts +204 -0
- package/harness/langchain/run.d.ts +15 -0
- package/harness/langchain/run.ts +15 -3
- package/harness/langchain/scoreboard.test.d.ts +13 -0
- package/harness/langchain/scoreboard.test.ts +23 -1
- package/harness/langchain/status-line.d.ts +91 -0
- package/harness/langchain/status-line.test.d.ts +8 -0
- package/harness/langchain/surge.d.ts +103 -0
- package/harness/langchain/surge.test.d.ts +10 -0
- package/harness/langchain/surge.test.ts +179 -0
- package/harness/langchain/surge.ts +188 -19
- package/harness/langchain/widget.d.ts +105 -0
- package/harness/langchain/widget.test.d.ts +8 -0
- package/harness/ssh-detach-interceptor.test.ts +263 -0
- package/harness/ssh-detach-interceptor.ts +438 -0
- package/index.ts +212 -47
- package/package.json +4 -2
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/dist/src/index.d.ts +2 -0
- package/packages/intent-analyzer/dist/src/index.d.ts.map +1 -1
- package/packages/intent-analyzer/dist/src/index.js +1 -0
- package/packages/intent-analyzer/dist/src/index.js.map +1 -1
- package/packages/intent-analyzer/dist/src/ping-pong.d.ts +56 -0
- package/packages/intent-analyzer/dist/src/ping-pong.d.ts.map +1 -0
- package/packages/intent-analyzer/dist/src/ping-pong.js +286 -0
- package/packages/intent-analyzer/dist/src/ping-pong.js.map +1 -0
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/intent-analyzer/src/index.ts +13 -0
- package/packages/intent-analyzer/src/ping-pong.ts +387 -0
- package/packages/intent-analyzer/test/ping-pong.test.ts +96 -0
- package/packages/jev-judge/README.md +234 -0
- package/packages/jev-judge/dist/decision-engine.d.ts +100 -0
- package/packages/jev-judge/dist/decision-engine.d.ts.map +1 -0
- package/packages/jev-judge/dist/decision-engine.js +151 -0
- package/packages/jev-judge/dist/decision-engine.js.map +1 -0
- package/packages/jev-judge/dist/e2e-judge.d.ts +61 -0
- package/packages/jev-judge/dist/e2e-judge.d.ts.map +1 -0
- package/packages/jev-judge/dist/e2e-judge.js +268 -0
- package/packages/jev-judge/dist/e2e-judge.js.map +1 -0
- package/packages/jev-judge/dist/flaky-detector.d.ts +34 -0
- package/packages/jev-judge/dist/flaky-detector.d.ts.map +1 -0
- package/packages/jev-judge/dist/flaky-detector.js +154 -0
- package/packages/jev-judge/dist/flaky-detector.js.map +1 -0
- package/packages/jev-judge/dist/index.d.ts +77 -0
- package/packages/jev-judge/dist/index.d.ts.map +1 -0
- package/packages/jev-judge/dist/index.js +271 -0
- package/packages/jev-judge/dist/index.js.map +1 -0
- package/packages/jev-judge/dist/types.d.ts +97 -0
- package/packages/jev-judge/dist/types.d.ts.map +1 -0
- package/packages/jev-judge/dist/types.js +8 -0
- package/packages/jev-judge/dist/types.js.map +1 -0
- package/packages/jev-judge/package.json +46 -0
- package/packages/jev-judge/src/decision-engine.ts +231 -0
- package/packages/jev-judge/src/e2e-judge.ts +364 -0
- package/packages/jev-judge/src/flaky-detector.ts +190 -0
- package/packages/jev-judge/src/index.ts +345 -0
- package/packages/jev-judge/src/types.ts +131 -0
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/README.md +71 -0
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/todo-bd-sync/src/extension.ts +16 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/dist/src/approved-patterns.d.ts +51 -0
- package/packages/trajectory/dist/src/approved-patterns.d.ts.map +1 -0
- package/packages/trajectory/dist/src/approved-patterns.js +134 -0
- package/packages/trajectory/dist/src/approved-patterns.js.map +1 -0
- package/packages/trajectory/dist/src/index.d.ts +13 -0
- package/packages/trajectory/dist/src/index.d.ts.map +1 -0
- package/packages/trajectory/dist/src/index.js +12 -0
- package/packages/trajectory/dist/src/index.js.map +1 -0
- package/packages/trajectory/dist/src/store.d.ts +69 -0
- package/packages/trajectory/dist/src/store.d.ts.map +1 -0
- package/packages/trajectory/dist/src/store.js +253 -0
- package/packages/trajectory/dist/src/store.js.map +1 -0
- package/packages/trajectory/dist/src/types.d.ts +73 -0
- package/packages/trajectory/dist/src/types.d.ts.map +1 -0
- package/packages/trajectory/dist/src/types.js +9 -0
- package/packages/trajectory/dist/src/types.js.map +1 -0
- package/packages/trajectory/dist/test/store.test.d.ts +2 -0
- package/packages/trajectory/dist/test/store.test.d.ts.map +1 -0
- package/packages/trajectory/dist/test/store.test.js +228 -0
- package/packages/trajectory/dist/test/store.test.js.map +1 -0
- package/packages/trajectory/package.json +29 -0
- package/packages/trajectory/src/approved-patterns.ts +180 -0
- package/packages/trajectory/src/index.ts +26 -0
- package/packages/trajectory/src/store.ts +285 -0
- package/packages/trajectory/src/types.ts +84 -0
- package/packages/trajectory/test/store.test.d.ts +2 -0
- package/packages/trajectory/test/store.test.ts +250 -0
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/packages/write-review/src/blackboard.ts +24 -1
- package/packages/write-review/src/types.ts +13 -0
- 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
|
+
}
|