@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4
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 +60 -0
- package/dist/cli.js +3682 -3615
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +242 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/manager.ts +2 -1
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -2,7 +2,18 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getRemoteHostDir } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildRemoteCommand,
|
|
7
|
+
extractProbePayload,
|
|
8
|
+
findProbeMarker,
|
|
9
|
+
getHostInfo,
|
|
10
|
+
HOST_PROBE_MARKER,
|
|
11
|
+
osFromUname,
|
|
12
|
+
parseHostInfo,
|
|
13
|
+
type SSHConnectionTarget,
|
|
14
|
+
type SSHHostShell,
|
|
15
|
+
TRANSFER_PROBE_MARKER,
|
|
16
|
+
} from "../connection-manager";
|
|
6
17
|
import { buildSshTarget, sanitizeHostName } from "../utils";
|
|
7
18
|
|
|
8
19
|
const TARGET: SSHConnectionTarget = { name: "h", host: "h" };
|
|
@@ -67,3 +78,114 @@ describe("ssh host shell classification", () => {
|
|
|
67
78
|
}
|
|
68
79
|
});
|
|
69
80
|
});
|
|
81
|
+
|
|
82
|
+
describe("extractProbePayload (host probe framing)", () => {
|
|
83
|
+
it("returns the text after the first marker line, ignoring login banners", async () => {
|
|
84
|
+
// Real-world failure shape: noisy dotfiles print a banner before the
|
|
85
|
+
// echo we asked for, so the legacy first-line parser would have read
|
|
86
|
+
// `Last login: ...` and classified the host as unknown (#3719).
|
|
87
|
+
const stdout = [
|
|
88
|
+
"Last login: Wed Mar 19 09:14:22 2025 from 10.0.0.1",
|
|
89
|
+
"Welcome to fancybox 1.0",
|
|
90
|
+
`${HOST_PROBE_MARKER}linux-gnu|/bin/bash|5.2.21`,
|
|
91
|
+
].join("\n");
|
|
92
|
+
expect(extractProbePayload(stdout, "")).toBe("linux-gnu|/bin/bash|5.2.21");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("falls back to stderr when the payload only shows up there", async () => {
|
|
96
|
+
// Some shells redirect every echo to stderr after a dotfile error; the
|
|
97
|
+
// parser needs to recover the marker line from either stream.
|
|
98
|
+
const stderr = `noise\n${HOST_PROBE_MARKER}darwin|/bin/zsh|\n`;
|
|
99
|
+
expect(extractProbePayload("", stderr)).toBe("darwin|/bin/zsh|");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null when no marker line is present", async () => {
|
|
103
|
+
expect(extractProbePayload("just login banner\n", "and stderr noise\n")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("findProbeMarker (transfer-shell probe recovery)", () => {
|
|
108
|
+
it("returns the tail after the marker when it appears in stdout", () => {
|
|
109
|
+
// Happy path: `sh -lc 'printf "PI_TRANSFER_OK|"; uname -s'` lands in
|
|
110
|
+
// stdout. The tail is the uname output the caller uses to refine OS.
|
|
111
|
+
const stdout = `${TRANSFER_PROBE_MARKER}Linux\n`;
|
|
112
|
+
expect(findProbeMarker(stdout, "", TRANSFER_PROBE_MARKER)).toBe("Linux\n");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls back to stderr when a broken dotfile swaps fd 1/2", () => {
|
|
116
|
+
// Some remotes have dotfiles that redirect every shell write to stderr.
|
|
117
|
+
// The transfer probe must still recognize the marker so ssh:// doesn't
|
|
118
|
+
// refuse a POSIX-capable host (#3722 review).
|
|
119
|
+
const stderr = `dotfile noise\n${TRANSFER_PROBE_MARKER}Darwin\n`;
|
|
120
|
+
expect(findProbeMarker("", stderr, TRANSFER_PROBE_MARKER)).toBe("Darwin\n");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("prefers stdout over stderr when the marker is in both", () => {
|
|
124
|
+
// Order matters: stdout is the canonical path, stderr is the rescue.
|
|
125
|
+
// A reordering bug would silently use stale stderr fragments first.
|
|
126
|
+
const stdout = `${TRANSFER_PROBE_MARKER}Linux`;
|
|
127
|
+
const stderr = `${TRANSFER_PROBE_MARKER}stale`;
|
|
128
|
+
expect(findProbeMarker(stdout, stderr, TRANSFER_PROBE_MARKER)).toBe("Linux");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns null when the marker is in neither stream", () => {
|
|
132
|
+
expect(findProbeMarker("noise", "more noise", TRANSFER_PROBE_MARKER)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("osFromUname (transfer-shell probe OS recovery)", () => {
|
|
137
|
+
it("classifies common POSIX uname payloads", () => {
|
|
138
|
+
// The markerless host-info fallback uses the transfer-shell probe's
|
|
139
|
+
// uname output to avoid returning a durable `os: "unknown"` when csh/tcsh
|
|
140
|
+
// killed the first marker probe before it could echo anything (#3722 review).
|
|
141
|
+
expect(osFromUname("Linux")).toBe("linux");
|
|
142
|
+
expect(osFromUname("GNU/Linux")).toBe("linux");
|
|
143
|
+
expect(osFromUname("Darwin")).toBe("macos");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("classifies Windows compat unames as windows so ssh:// still refuses them", () => {
|
|
147
|
+
expect(osFromUname("MINGW64_NT-10.0")).toBe("windows");
|
|
148
|
+
expect(osFromUname("MSYS_NT-10.0")).toBe("windows");
|
|
149
|
+
expect(osFromUname("CYGWIN_NT-10.0")).toBe("windows");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns undefined when uname is not recognized", () => {
|
|
153
|
+
expect(osFromUname("")).toBeUndefined();
|
|
154
|
+
expect(osFromUname("SunOS")).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("parseHostInfo transferShell handling", () => {
|
|
159
|
+
it("round-trips a verified transferShell value", () => {
|
|
160
|
+
// Cache writers persist `transferShell` so callers don't re-probe
|
|
161
|
+
// every session; parseHostInfo must thread it back through (#3719).
|
|
162
|
+
const parsed = parseHostInfo({
|
|
163
|
+
version: 4,
|
|
164
|
+
os: "linux",
|
|
165
|
+
shell: "unknown",
|
|
166
|
+
transferShell: "bash",
|
|
167
|
+
compatEnabled: false,
|
|
168
|
+
});
|
|
169
|
+
expect(parsed?.transferShell).toBe("bash");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("drops a transferShell value outside the sh/bash/zsh allowlist", () => {
|
|
173
|
+
// Anything we couldn't have probed (fish, csh, garbage) must not slip
|
|
174
|
+
// into the cache and bypass the ssh:// transfer guard.
|
|
175
|
+
const parsed = parseHostInfo({
|
|
176
|
+
version: 4,
|
|
177
|
+
os: "linux",
|
|
178
|
+
shell: "sh",
|
|
179
|
+
transferShell: "fish",
|
|
180
|
+
compatEnabled: false,
|
|
181
|
+
});
|
|
182
|
+
expect(parsed?.transferShell).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns transferShell undefined when the field is missing", () => {
|
|
186
|
+
// A pre-v4 cache file lacks transferShell entirely; the parsed value
|
|
187
|
+
// must be undefined so shouldRefreshHostInfo treats it as stale.
|
|
188
|
+
const parsed = parseHostInfo({ version: 3, os: "linux", shell: "sh", compatEnabled: false });
|
|
189
|
+
expect(parsed?.transferShell).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
2
|
import type { SSHConnectionTarget } from "../connection-manager";
|
|
3
3
|
import * as connectionManager from "../connection-manager";
|
|
4
|
-
import { readRemoteFile, writeRemoteFile } from "../file-transfer";
|
|
4
|
+
import { listRemoteDir, readRemoteFile, statRemotePath, writeRemoteFile } from "../file-transfer";
|
|
5
5
|
|
|
6
6
|
describe("ssh file-transfer POSIX guard", () => {
|
|
7
7
|
afterEach(() => {
|
|
@@ -13,7 +13,7 @@ describe("ssh file-transfer POSIX guard", () => {
|
|
|
13
13
|
// without opening a real SSH connection and before any command is spawned.
|
|
14
14
|
const ensureConnectionSpy = vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
15
15
|
const ensureHostInfoSpy = vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
16
|
-
version:
|
|
16
|
+
version: 4,
|
|
17
17
|
os: "windows",
|
|
18
18
|
shell: "powershell",
|
|
19
19
|
compatEnabled: false,
|
|
@@ -27,42 +27,79 @@ describe("ssh file-transfer POSIX guard", () => {
|
|
|
27
27
|
expect(ensureHostInfoSpy).toHaveBeenCalled();
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
it("rejects a non-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
it("rejects a non-Windows remote with no verified transferShell", async () => {
|
|
31
|
+
// No transferShell means the capability probe never confirmed any of
|
|
32
|
+
// sh/bash/zsh works. The guard refuses regardless of `shell` because the
|
|
33
|
+
// real ssh:// contract is "did we verify a POSIX shell works", not
|
|
34
|
+
// "what name did the login shell self-report" (#3719).
|
|
33
35
|
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
34
36
|
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
35
|
-
version:
|
|
37
|
+
version: 4,
|
|
36
38
|
os: "linux",
|
|
37
39
|
shell: "unknown",
|
|
38
40
|
compatEnabled: false,
|
|
39
41
|
});
|
|
40
|
-
const target: SSHConnectionTarget = { name: "
|
|
41
|
-
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/
|
|
42
|
-
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(
|
|
42
|
+
const target: SSHConnectionTarget = { name: "noshell", host: "noshell" };
|
|
43
|
+
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/no verified POSIX shell/);
|
|
44
|
+
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(
|
|
45
|
+
/no verified POSIX shell/,
|
|
46
|
+
);
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
it("
|
|
46
|
-
//
|
|
47
|
-
//
|
|
49
|
+
it("dispatches transfer commands through the verified transferShell, not the login shell", async () => {
|
|
50
|
+
// The bug fix: if the login shell is fish/csh/tcsh, the legacy guard
|
|
51
|
+
// would refuse the host — but allowing it isn't enough on its own.
|
|
52
|
+
// OpenSSH still hands our snippets to `$SHELL -c`, so a fish login
|
|
53
|
+
// shell would choke on `if [ … ]; then …`. Every transfer command
|
|
54
|
+
// must be wrapped in `<transferShell> -c '…'` to force parsing
|
|
55
|
+
// under the shell we verified can run it (#3719).
|
|
48
56
|
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
49
57
|
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
50
|
-
version:
|
|
58
|
+
version: 4,
|
|
51
59
|
os: "linux",
|
|
52
|
-
shell
|
|
60
|
+
// Login shell is fish; only `transferShell` indicates a working POSIX shell.
|
|
61
|
+
shell: "unknown",
|
|
62
|
+
transferShell: "bash",
|
|
53
63
|
compatEnabled: false,
|
|
54
64
|
});
|
|
55
65
|
const buildSpy = vi
|
|
56
66
|
.spyOn(connectionManager, "buildRemoteCommand")
|
|
57
67
|
.mockRejectedValue(new Error("stop-before-spawn"));
|
|
58
|
-
const target: SSHConnectionTarget = { name: "
|
|
68
|
+
const target: SSHConnectionTarget = { name: "fishbox", host: "fishbox" };
|
|
59
69
|
|
|
60
70
|
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
|
|
61
71
|
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(/stop-before-spawn/);
|
|
72
|
+
await expect(statRemotePath(target, "/etc/hosts")).rejects.toThrow(/stop-before-spawn/);
|
|
73
|
+
await expect(listRemoteDir(target, "/etc")).rejects.toThrow(/stop-before-spawn/);
|
|
62
74
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
75
|
+
// Each dispatch must start with `bash -c '…'` and embed the original
|
|
76
|
+
// POSIX snippet inside the quoted command. Read also drops `-n`
|
|
77
|
+
// (allowStdin: true) because cat-staging needs stdin streaming.
|
|
78
|
+
const dispatches = buildSpy.mock.calls.map(call => call[1] as string);
|
|
79
|
+
expect(dispatches[0]).toMatch(/^bash -c '.*head -c 1025/);
|
|
80
|
+
expect(dispatches[1]).toMatch(/^bash -c '.*cat > /);
|
|
66
81
|
expect(buildSpy.mock.calls[1]?.[2]).toMatchObject({ allowStdin: true });
|
|
82
|
+
expect(dispatches[2]).toMatch(/^bash -c '.*if \[ -d /);
|
|
83
|
+
expect(dispatches[3]).toMatch(/^bash -c '.*LC_ALL=C ls -1Ap /);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("uses sh -c when transferShell is sh (the most universal POSIX fallback)", async () => {
|
|
87
|
+
// Belt-and-suspenders: the common happy path with a sh-family login
|
|
88
|
+
// shell still routes through `sh -c` to keep one dispatch shape.
|
|
89
|
+
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
90
|
+
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
91
|
+
version: 4,
|
|
92
|
+
os: "linux",
|
|
93
|
+
shell: "sh",
|
|
94
|
+
transferShell: "sh",
|
|
95
|
+
compatEnabled: false,
|
|
96
|
+
});
|
|
97
|
+
const buildSpy = vi
|
|
98
|
+
.spyOn(connectionManager, "buildRemoteCommand")
|
|
99
|
+
.mockRejectedValue(new Error("stop-before-spawn"));
|
|
100
|
+
const target: SSHConnectionTarget = { name: "shbox", host: "shbox" };
|
|
101
|
+
|
|
102
|
+
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
|
|
103
|
+
expect(buildSpy.mock.calls[0]?.[1]).toMatch(/^sh -c '.*head -c 1025/);
|
|
67
104
|
});
|
|
68
105
|
});
|
|
@@ -25,6 +25,15 @@ export interface SSHHostInfo {
|
|
|
25
25
|
version: number;
|
|
26
26
|
os: SSHHostOs;
|
|
27
27
|
shell: SSHHostShell;
|
|
28
|
+
/**
|
|
29
|
+
* Shell name OMP verified can execute the POSIX transfer snippets
|
|
30
|
+
* (`head`/`cat`/`mv`/`test`/`ls`) `ssh://` uses. Probed by running
|
|
31
|
+
* `sh -lc` / `bash -lc` / `zsh -lc` against the remote and keeping the
|
|
32
|
+
* first one that round-trips a known marker. Independent of `shell`
|
|
33
|
+
* (the self-reported login shell), which may be noisy, exotic, or simply
|
|
34
|
+
* mis-classified — only `transferShell` gates ssh:// transfers.
|
|
35
|
+
*/
|
|
36
|
+
transferShell?: "sh" | "bash" | "zsh";
|
|
28
37
|
compatShell?: "bash" | "sh";
|
|
29
38
|
compatEnabled: boolean;
|
|
30
39
|
}
|
|
@@ -32,7 +41,7 @@ export interface SSHHostInfo {
|
|
|
32
41
|
const CONTROL_DIR = getSshControlDir();
|
|
33
42
|
const CONTROL_PATH = path.join(CONTROL_DIR, "%C.sock");
|
|
34
43
|
const HOST_INFO_DIR = getRemoteHostDir();
|
|
35
|
-
const HOST_INFO_VERSION =
|
|
44
|
+
const HOST_INFO_VERSION = 4;
|
|
36
45
|
|
|
37
46
|
const activeHosts = new Map<string, SSHConnectionTarget>();
|
|
38
47
|
const pendingConnections = new Map<string, Promise<void>>();
|
|
@@ -166,6 +175,11 @@ function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
|
|
|
166
175
|
return undefined;
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
function parseTransferShell(value: unknown): SSHHostInfo["transferShell"] {
|
|
179
|
+
if (value === "sh" || value === "bash" || value === "zsh") return value;
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
|
|
170
184
|
const compatShell =
|
|
171
185
|
info.compatShell ??
|
|
@@ -184,18 +198,26 @@ function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHH
|
|
|
184
198
|
return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
|
|
185
199
|
}
|
|
186
200
|
|
|
187
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Parse a raw cache-file value (or any unknown) into a normalized
|
|
203
|
+
* {@link SSHHostInfo}, dropping fields that don't pass the per-field guards.
|
|
204
|
+
* Exported so cache-layer round-tripping (incl. the new `transferShell`
|
|
205
|
+
* field, #3719) is testable without touching disk.
|
|
206
|
+
*/
|
|
207
|
+
export function parseHostInfo(value: unknown): SSHHostInfo | null {
|
|
188
208
|
if (!value || typeof value !== "object") return null;
|
|
189
209
|
const record = value as Record<string, unknown>;
|
|
190
210
|
const os = parseOs(record.os) ?? "unknown";
|
|
191
211
|
const shell = parseShell(record.shell) ?? "unknown";
|
|
192
212
|
const compatShell = parseCompatShell(record.compatShell);
|
|
213
|
+
const transferShell = parseTransferShell(record.transferShell);
|
|
193
214
|
const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
|
|
194
215
|
const version = typeof record.version === "number" ? record.version : 0;
|
|
195
216
|
return {
|
|
196
217
|
version,
|
|
197
218
|
os,
|
|
198
219
|
shell,
|
|
220
|
+
transferShell,
|
|
199
221
|
compatShell,
|
|
200
222
|
compatEnabled,
|
|
201
223
|
};
|
|
@@ -208,6 +230,11 @@ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): bo
|
|
|
208
230
|
if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
|
|
209
231
|
if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
|
|
210
232
|
if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
|
|
233
|
+
// A non-Windows host with no verified POSIX transfer shell is ambiguous —
|
|
234
|
+
// either the probe never ran capability checks, or every candidate failed.
|
|
235
|
+
// Re-probe rather than letting the ssh:// transfer guard reject it on a
|
|
236
|
+
// stale `shell: "unknown"` classification (#3719).
|
|
237
|
+
if (info.os !== "windows" && !info.transferShell) return true;
|
|
211
238
|
return false;
|
|
212
239
|
}
|
|
213
240
|
|
|
@@ -252,15 +279,99 @@ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Pr
|
|
|
252
279
|
}
|
|
253
280
|
}
|
|
254
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Frame marker emitted by the remote OS/shell probe. The probe wraps its
|
|
284
|
+
* payload in this prefix so the parser can ignore startup-file noise (banners,
|
|
285
|
+
* `motd`, login messages, `Last login: …`) instead of trusting only the first
|
|
286
|
+
* line of stdout. See #3719.
|
|
287
|
+
*/
|
|
288
|
+
export const HOST_PROBE_MARKER = "PI_HOST_PROBE=";
|
|
289
|
+
|
|
290
|
+
/** Marker for the transfer-shell capability probe. */
|
|
291
|
+
export const TRANSFER_PROBE_MARKER = "PI_TRANSFER_OK|";
|
|
292
|
+
|
|
293
|
+
/** sh / bash / zsh, in the order we'll try as `transferShell` candidates. */
|
|
294
|
+
const TRANSFER_SHELL_CANDIDATES = ["sh", "bash", "zsh"] as const;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Find the first line of `stdout`/`stderr` that begins with `marker` and
|
|
298
|
+
* return everything after it. Used by the SSH host probe so noisy login
|
|
299
|
+
* dotfiles can't corrupt OS/shell classification by emitting text on the
|
|
300
|
+
* first line of `ssh` output.
|
|
301
|
+
*
|
|
302
|
+
* Returns `null` when no marker line is found in either stream.
|
|
303
|
+
*/
|
|
304
|
+
export function extractProbePayload(stdout: string, stderr: string, marker = HOST_PROBE_MARKER): string | null {
|
|
305
|
+
for (const blob of [stdout, stderr]) {
|
|
306
|
+
if (!blob) continue;
|
|
307
|
+
for (const line of blob.split("\n")) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (trimmed.startsWith(marker)) {
|
|
310
|
+
return trimmed.slice(marker.length);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Find `marker` anywhere in `stdout` or `stderr` and return everything that
|
|
319
|
+
* follows it, scanning stdout first. Returns `null` when the marker is in
|
|
320
|
+
* neither stream.
|
|
321
|
+
*
|
|
322
|
+
* Used by the transfer-shell capability probe. Some remotes have broken
|
|
323
|
+
* login dotfiles that swap fd 1/2, so the marker can land on stderr even
|
|
324
|
+
* though the probe ran the printf successfully (matches the host-info
|
|
325
|
+
* probe's stderr fallback). See #3719.
|
|
326
|
+
*/
|
|
327
|
+
export function findProbeMarker(stdout: string, stderr: string, marker: string): string | null {
|
|
328
|
+
for (const blob of [stdout, stderr]) {
|
|
329
|
+
if (!blob) continue;
|
|
330
|
+
const idx = blob.indexOf(marker);
|
|
331
|
+
if (idx !== -1) return blob.slice(idx + marker.length);
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Classify a POSIX-ish `uname -s` payload from the transfer-shell probe. */
|
|
337
|
+
export function osFromUname(value: string): SSHHostOs | undefined {
|
|
338
|
+
const uname = value.toLowerCase();
|
|
339
|
+
if (uname.includes("darwin")) return "macos";
|
|
340
|
+
if (uname.includes("linux") || uname.includes("gnu")) return "linux";
|
|
341
|
+
if (uname.includes("mingw") || uname.includes("msys") || uname.includes("cygwin") || uname.includes("windows")) {
|
|
342
|
+
return "windows";
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function probeTransferShell(
|
|
348
|
+
host: SSHConnectionTarget,
|
|
349
|
+
): Promise<{ shell: SSHHostInfo["transferShell"]; uname: string }> {
|
|
350
|
+
for (const candidate of TRANSFER_SHELL_CANDIDATES) {
|
|
351
|
+
// `printf` is POSIX and emits no trailing newline, so we can pin the
|
|
352
|
+
// marker right against the uname output and split on it cleanly.
|
|
353
|
+
const remote = `${candidate} -lc 'printf "${TRANSFER_PROBE_MARKER}"; uname -s 2>/dev/null || true'`;
|
|
354
|
+
const probe = await runSshCaptureSync(await buildRemoteCommand(host, remote));
|
|
355
|
+
if (probe.exitCode !== 0) continue;
|
|
356
|
+
const tail = findProbeMarker(probe.stdout, probe.stderr, TRANSFER_PROBE_MARKER);
|
|
357
|
+
if (tail === null) continue;
|
|
358
|
+
return { shell: candidate, uname: tail.trim() };
|
|
359
|
+
}
|
|
360
|
+
return { shell: undefined, uname: "" };
|
|
361
|
+
}
|
|
362
|
+
|
|
255
363
|
async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
256
|
-
const command =
|
|
364
|
+
const command = `echo "${HOST_PROBE_MARKER}$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "${HOST_PROBE_MARKER}%OS%|%COMSPEC%|"`;
|
|
257
365
|
const result = await runSshCaptureSync(await buildRemoteCommand(host, command));
|
|
258
|
-
|
|
366
|
+
const payload = extractProbePayload(result.stdout, result.stderr);
|
|
367
|
+
if (payload === null) {
|
|
259
368
|
logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
|
|
369
|
+
const transferProbe = await probeTransferShell(host);
|
|
260
370
|
const fallback: SSHHostInfo = {
|
|
261
371
|
version: HOST_INFO_VERSION,
|
|
262
|
-
os: "unknown",
|
|
372
|
+
os: transferProbe.shell ? (osFromUname(transferProbe.uname) ?? "unknown") : "unknown",
|
|
263
373
|
shell: "unknown",
|
|
374
|
+
transferShell: transferProbe.shell,
|
|
264
375
|
compatShell: undefined,
|
|
265
376
|
compatEnabled: false,
|
|
266
377
|
};
|
|
@@ -268,27 +379,26 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
|
268
379
|
return fallback;
|
|
269
380
|
}
|
|
270
381
|
|
|
271
|
-
const
|
|
272
|
-
const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
|
|
382
|
+
const [rawOs = "", rawShell = "", rawBash = ""] = payload.split("|");
|
|
273
383
|
const ostype = rawOs.trim();
|
|
274
384
|
const shellRaw = rawShell.trim();
|
|
275
385
|
const bashVersion = rawBash.trim();
|
|
276
|
-
const
|
|
386
|
+
const payloadLower = payload.toLowerCase();
|
|
277
387
|
const osLower = ostype.toLowerCase();
|
|
278
388
|
const shellLower = shellRaw.toLowerCase();
|
|
279
389
|
const unexpandedPosixVars =
|
|
280
|
-
|
|
390
|
+
payload.includes("$OSTYPE") || payload.includes("$SHELL") || payload.includes("$BASH_VERSION");
|
|
281
391
|
const windowsDetected =
|
|
282
392
|
osLower.includes("windows") ||
|
|
283
393
|
osLower.includes("msys") ||
|
|
284
394
|
osLower.includes("cygwin") ||
|
|
285
395
|
osLower.includes("mingw") ||
|
|
286
|
-
|
|
287
|
-
|
|
396
|
+
payloadLower.includes("windows_nt") ||
|
|
397
|
+
payloadLower.includes("comspec") ||
|
|
288
398
|
shellLower.includes("cmd") ||
|
|
289
399
|
shellLower.includes("powershell") ||
|
|
290
400
|
unexpandedPosixVars ||
|
|
291
|
-
|
|
401
|
+
payload.includes("%OS%");
|
|
292
402
|
|
|
293
403
|
let os: SSHHostOs = "unknown";
|
|
294
404
|
if (windowsDetected) {
|
|
@@ -305,6 +415,22 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
|
305
415
|
shell = "cmd";
|
|
306
416
|
}
|
|
307
417
|
|
|
418
|
+
// For any non-Windows host (including `unknown`, which is often a misclassified
|
|
419
|
+
// POSIX remote with noisy login output) verify a working transfer shell by
|
|
420
|
+
// running `sh -lc` / `bash -lc` / `zsh -lc` against it. The first one whose
|
|
421
|
+
// printf round-trips becomes `transferShell`; ssh:// gates on this rather
|
|
422
|
+
// than the self-reported login-shell name (#3719).
|
|
423
|
+
let transferShell: SSHHostInfo["transferShell"];
|
|
424
|
+
if (os !== "windows") {
|
|
425
|
+
const probe = await probeTransferShell(host);
|
|
426
|
+
transferShell = probe.shell;
|
|
427
|
+
// `uname -s` from the same probe lets us recover the OS when the first
|
|
428
|
+
// probe couldn't classify it (e.g. the remote silently nuked `$OSTYPE`).
|
|
429
|
+
if (transferShell && os === "unknown") {
|
|
430
|
+
os = osFromUname(probe.uname) ?? os;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
308
434
|
const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
|
|
309
435
|
let compatShell: SSHHostInfo["compatShell"];
|
|
310
436
|
if (os === "windows" && host.compat !== false) {
|
|
@@ -328,6 +454,7 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
|
328
454
|
version: HOST_INFO_VERSION,
|
|
329
455
|
os,
|
|
330
456
|
shell,
|
|
457
|
+
transferShell,
|
|
331
458
|
compatShell,
|
|
332
459
|
compatEnabled,
|
|
333
460
|
});
|
package/src/ssh/file-transfer.ts
CHANGED
|
@@ -8,20 +8,24 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { ptree } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
|
|
11
|
-
import { quotePosixPath } from "./utils";
|
|
11
|
+
import { quotePosixPath, wrapInPosixShell } from "./utils";
|
|
12
12
|
|
|
13
13
|
/** Per-operation timeout for remote transfers (matches the ssh tool's grep window). */
|
|
14
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Ensure the ControlMaster connection and
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* Ensure the ControlMaster connection and pick the verified POSIX shell to
|
|
18
|
+
* run transfer commands under. Returns the shell name so the caller can
|
|
19
|
+
* wrap its snippet in `<shell> -c '…'`; OpenSSH otherwise hands the command
|
|
20
|
+
* to the user's login shell, which on fish/csh/tcsh hosts can't parse our
|
|
21
|
+
* `if [ … ]; then …` constructs (#3719).
|
|
22
|
+
*
|
|
23
|
+
* Windows hosts are refused up front — `ssh://` runs `head`/`cat`/`mv`/`test`
|
|
24
|
+
* directly and cmd/powershell can't drive those. Everywhere else, we require
|
|
25
|
+
* a non-empty `transferShell` (set by `probeHostInfo` after `sh -lc` /
|
|
26
|
+
* `bash -lc` / `zsh -lc` round-trips a marker against the remote).
|
|
23
27
|
*/
|
|
24
|
-
async function ensurePosixRemote(target: SSHConnectionTarget): Promise<
|
|
28
|
+
async function ensurePosixRemote(target: SSHConnectionTarget): Promise<"sh" | "bash" | "zsh"> {
|
|
25
29
|
await ensureConnection(target);
|
|
26
30
|
const info = await ensureHostInfo(target);
|
|
27
31
|
if (info.os === "windows") {
|
|
@@ -29,11 +33,12 @@ async function ensurePosixRemote(target: SSHConnectionTarget): Promise<void> {
|
|
|
29
33
|
`ssh://: ${target.name} is a Windows host; ssh:// supports POSIX remotes only (head/cat/mv) — use the ssh tool for Windows hosts`,
|
|
30
34
|
);
|
|
31
35
|
}
|
|
32
|
-
if (info.
|
|
36
|
+
if (!info.transferShell) {
|
|
33
37
|
throw new Error(
|
|
34
|
-
`ssh://: ${target.name}
|
|
38
|
+
`ssh://: ${target.name} has no verified POSIX shell for ssh:// read/write — none of sh/bash/zsh round-tripped a capability probe (use the ssh tool for this host)`,
|
|
35
39
|
);
|
|
36
40
|
}
|
|
41
|
+
return info.transferShell;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export interface RemoteFileReadOptions {
|
|
@@ -67,9 +72,9 @@ export async function readRemoteFile(
|
|
|
67
72
|
remotePath: string,
|
|
68
73
|
opts: RemoteFileReadOptions,
|
|
69
74
|
): Promise<RemoteFileReadResult> {
|
|
70
|
-
await ensurePosixRemote(target);
|
|
75
|
+
const shell = await ensurePosixRemote(target);
|
|
71
76
|
const command = `head -c ${opts.maxBytes + 1} ${quotePosixPath(remotePath)}`;
|
|
72
|
-
const args = await buildRemoteCommand(target, command);
|
|
77
|
+
const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
|
|
73
78
|
using child = ptree.spawn(["ssh", ...args], {
|
|
74
79
|
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
75
80
|
});
|
|
@@ -110,7 +115,7 @@ export async function writeRemoteFile(
|
|
|
110
115
|
content: Uint8Array,
|
|
111
116
|
opts: RemoteFileWriteOptions,
|
|
112
117
|
): Promise<void> {
|
|
113
|
-
await ensurePosixRemote(target);
|
|
118
|
+
const shell = await ensurePosixRemote(target);
|
|
114
119
|
if (remotePath.endsWith("/")) {
|
|
115
120
|
throw new Error("ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path");
|
|
116
121
|
}
|
|
@@ -135,7 +140,7 @@ export async function writeRemoteFile(
|
|
|
135
140
|
`elif [ -e ${dest} ] && [ ! -L ${dest} ]; then echo 'ssh://: destination is a special file (not a regular file)' >&2; exit 1; ` +
|
|
136
141
|
`else mv "$t" ${dest}; fi; ` +
|
|
137
142
|
`}`;
|
|
138
|
-
const args = await buildRemoteCommand(target, command, { allowStdin: true });
|
|
143
|
+
const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command), { allowStdin: true });
|
|
139
144
|
using child = ptree.spawn(["ssh", ...args], {
|
|
140
145
|
stdin: content,
|
|
141
146
|
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
@@ -155,10 +160,10 @@ export async function statRemotePath(
|
|
|
155
160
|
remotePath: string,
|
|
156
161
|
opts: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
157
162
|
): Promise<RemotePathKind> {
|
|
158
|
-
await ensurePosixRemote(target);
|
|
163
|
+
const shell = await ensurePosixRemote(target);
|
|
159
164
|
const p = quotePosixPath(remotePath);
|
|
160
165
|
const command = `if [ -d ${p} ]; then echo directory; elif [ -f ${p} ]; then echo file; elif [ -e ${p} ]; then echo other; else echo missing; fi`;
|
|
161
|
-
const args = await buildRemoteCommand(target, command);
|
|
166
|
+
const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
|
|
162
167
|
using child = ptree.spawn(["ssh", ...args], {
|
|
163
168
|
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
164
169
|
});
|
|
@@ -188,9 +193,9 @@ export async function listRemoteDir(
|
|
|
188
193
|
remotePath: string,
|
|
189
194
|
opts: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
190
195
|
): Promise<RemoteDirEntry[]> {
|
|
191
|
-
await ensurePosixRemote(target);
|
|
196
|
+
const shell = await ensurePosixRemote(target);
|
|
192
197
|
const command = `LC_ALL=C ls -1Ap -- ${quotePosixPath(remotePath)}`;
|
|
193
|
-
const args = await buildRemoteCommand(target, command);
|
|
198
|
+
const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
|
|
194
199
|
using child = ptree.spawn(["ssh", ...args], {
|
|
195
200
|
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
196
201
|
});
|
package/src/ssh/ssh-executor.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { OutputSink } from "../session/streaming-output";
|
|
|
4
4
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
|
|
5
5
|
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
|
|
6
6
|
import { hasSshfs, mountRemote } from "./sshfs-mount";
|
|
7
|
+
import { wrapInPosixShell } from "./utils";
|
|
7
8
|
|
|
8
9
|
export interface SSHExecutorOptions {
|
|
9
10
|
/** Timeout in milliseconds */
|
|
@@ -78,18 +79,6 @@ function createAbortWaiter(
|
|
|
78
79
|
return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function quoteForCompatShell(command: string): string {
|
|
82
|
-
if (command.length === 0) {
|
|
83
|
-
return "''";
|
|
84
|
-
}
|
|
85
|
-
const escaped = command.replace(/'/g, "'\\''");
|
|
86
|
-
return `'${escaped}'`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function buildCompatCommand(shell: "bash" | "sh", command: string): string {
|
|
90
|
-
return `${shell} -c ${quoteForCompatShell(command)}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
82
|
export async function executeSSH(
|
|
94
83
|
host: SSHConnectionTarget,
|
|
95
84
|
command: string,
|
|
@@ -108,7 +97,7 @@ export async function executeSSH(
|
|
|
108
97
|
if (options?.compatEnabled) {
|
|
109
98
|
const info = await ensureHostInfo(host);
|
|
110
99
|
if (info.compatShell) {
|
|
111
|
-
resolvedCommand =
|
|
100
|
+
resolvedCommand = wrapInPosixShell(info.compatShell, command);
|
|
112
101
|
} else {
|
|
113
102
|
logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
|
|
114
103
|
}
|
package/src/ssh/utils.ts
CHANGED
|
@@ -30,3 +30,22 @@ export function quotePosixPath(value: string): string {
|
|
|
30
30
|
if (value.length === 0) return "''";
|
|
31
31
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a POSIX command in `<shell> -c '<command>'` so it runs under the
|
|
36
|
+
* named shell rather than whatever `$SHELL` happens to be on the remote.
|
|
37
|
+
*
|
|
38
|
+
* Used by the `ssh://` transfer helpers and the Windows compat dispatch:
|
|
39
|
+
* OpenSSH passes our snippets to `<login-shell> -c`, so a remote whose
|
|
40
|
+
* login shell is fish/csh/tcsh (or cmd/powershell on Windows compat)
|
|
41
|
+
* can't parse `if [ … ]; then …`. Wrapping forces parsing under the
|
|
42
|
+
* shell OMP actually verified can run the snippet.
|
|
43
|
+
*
|
|
44
|
+
* `-c` (not `-lc`): the transfer snippets only call absolute POSIX
|
|
45
|
+
* builtins (`head`/`cat`/`mv`/`test`/`ls`/`mkdir`/`rm`/`dirname`) and
|
|
46
|
+
* don't need login-profile setup. Capability *probing* still uses
|
|
47
|
+
* `-lc` to mirror the user's real environment.
|
|
48
|
+
*/
|
|
49
|
+
export function wrapInPosixShell(shell: "sh" | "bash" | "zsh", command: string): string {
|
|
50
|
+
return `${shell} -c ${quotePosixPath(command)}`;
|
|
51
|
+
}
|