@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
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/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -14,13 +14,17 @@ import type {
|
|
|
14
14
|
SubprocessTerminalSpawnSpec,
|
|
15
15
|
} from '@deepseek-ai/dsh-subprocess'
|
|
16
16
|
import type { WorkspaceState } from '../protocol.ts'
|
|
17
|
-
import { quoteShellArg,
|
|
17
|
+
import { quoteShellArg, serializeEnvironment } from './environment.ts'
|
|
18
18
|
|
|
19
19
|
/** Resolve after one duration. */
|
|
20
20
|
function delay(ms: number): Promise<void> {
|
|
21
21
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** How long to wait for the login shell's first output before typing anyway.
|
|
25
|
+
* A silent shell (unusual for an interactive login) must not hang the tab. */
|
|
26
|
+
const TERMINAL_READY_TIMEOUT_MS = 750
|
|
27
|
+
|
|
24
28
|
/** One SSH PTY and its remote login shell, projected onto the subprocess terminal seam. */
|
|
25
29
|
export class SshTerminalHandle implements SubprocessTerminalHandle {
|
|
26
30
|
readonly pid = -1
|
|
@@ -77,6 +81,17 @@ export class SshTerminalHandle implements SubprocessTerminalHandle {
|
|
|
77
81
|
return cleanup
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/** Immediately release the channel when the owning runtime's independent
|
|
85
|
+
* disposal deadline expires. This also settles output/done if ssh2 never
|
|
86
|
+
* reports a terminal exit after close(). */
|
|
87
|
+
forceClose(): void {
|
|
88
|
+
if (this.topLevelExited) return
|
|
89
|
+
try { this.closeChannel() } catch { /* channel already closed */ }
|
|
90
|
+
this.topLevelExited = true
|
|
91
|
+
this.output.end()
|
|
92
|
+
this.resolveExit({ exitCode: null, signal: null })
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
private signal(name: string): void {
|
|
81
96
|
try {
|
|
82
97
|
this.signalChannel(name)
|
|
@@ -115,27 +130,97 @@ export async function spawnSshTerminal(
|
|
|
115
130
|
if (state.mode !== 'remote' || state.alias === undefined) {
|
|
116
131
|
throw new Error('subprocess-ssh: not in remote mode — switch the GUI to SSH mode first')
|
|
117
132
|
}
|
|
118
|
-
const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, state.alias), spec.env)
|
|
119
|
-
const session = await engine.openShell(state.alias, { cols: spec.cols, rows: spec.rows })
|
|
120
133
|
const root = state.remoteRoot
|
|
121
134
|
if (root === undefined) throw new Error('subprocess-ssh: remote workspace root is not set')
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
const session = await engine.openShell(state.alias, { cols: spec.cols, rows: spec.rows })
|
|
136
|
+
let handedOff = false
|
|
137
|
+
let readyTimer: NodeJS.Timeout | undefined
|
|
138
|
+
let removeAbortListener = (): void => {}
|
|
139
|
+
try {
|
|
140
|
+
// Everything after openShell() is setup owned by this function. Until the
|
|
141
|
+
// handle is successfully returned, every validation/setup/abort failure
|
|
142
|
+
// must close the standalone channel rather than orphaning its connection.
|
|
143
|
+
spec.signal?.throwIfAborted()
|
|
144
|
+
const cwd = spec.cwd !== undefined && spec.cwd.startsWith('/') ? spec.cwd : root
|
|
145
|
+
const handle = new SshTerminalHandle(
|
|
146
|
+
() => session.close(),
|
|
147
|
+
(data) => session.send(data),
|
|
148
|
+
(name) => session.signal(name),
|
|
149
|
+
spec.graceMs,
|
|
150
|
+
)
|
|
151
|
+
// Resolves on the login shell's first output (banner/prompt) or the bound:
|
|
152
|
+
// typing the session command before the shell is reading makes the terminal
|
|
153
|
+
// echo it twice — once into the banner, once when readline redraws its input.
|
|
154
|
+
let shellReady: () => void = () => {}
|
|
155
|
+
const ready = new Promise<void>((resolve) => { shellReady = resolve })
|
|
156
|
+
readyTimer = setTimeout(() => { shellReady() }, TERMINAL_READY_TIMEOUT_MS)
|
|
157
|
+
readyTimer.unref?.()
|
|
158
|
+
let sawOutput = false
|
|
159
|
+
session.onData = (data: Buffer) => {
|
|
160
|
+
if (!sawOutput) {
|
|
161
|
+
sawOutput = true
|
|
162
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
163
|
+
shellReady()
|
|
164
|
+
}
|
|
165
|
+
if (handle.output.destroyed) return
|
|
166
|
+
// Leak guard: a remote terminal stream reaches the model through this
|
|
167
|
+
// capability, so it passes the same engine redactor the exec paths use
|
|
168
|
+
// (exact-match over known secrets; best-effort, like the PTY route).
|
|
169
|
+
// Engine doubles without a redactor stream through unchanged.
|
|
170
|
+
if (typeof engine.redact !== 'function') {
|
|
171
|
+
handle.output.write(data)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const text = data.toString('utf8')
|
|
175
|
+
const safe = engine.redact(text)
|
|
176
|
+
handle.output.write(safe === text ? data : Buffer.from(safe, 'utf8'))
|
|
177
|
+
}
|
|
178
|
+
session.onExit = (code: number | null, error?: string) => {
|
|
179
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
180
|
+
shellReady()
|
|
181
|
+
if (handle.topLevelExited) return
|
|
182
|
+
handle.topLevelExited = true
|
|
183
|
+
handle.output.end()
|
|
184
|
+
handle.resolveExit({ exitCode: error !== undefined ? null : code, signal: null })
|
|
185
|
+
}
|
|
186
|
+
const argv = spec.argv.map(quoteShellArg).join(' ')
|
|
187
|
+
// The PTY IS the remote login shell, so it already carries the remote's own
|
|
188
|
+
// environment (HOME, PATH, the site's profile.d variables). Rebuilding that
|
|
189
|
+
// environment into one command line is both redundant and fragile. Apply
|
|
190
|
+
// ONLY the explicit overlay with `env` (not `env -i`) so the inherited login
|
|
191
|
+
// environment stays intact.
|
|
192
|
+
const overlay = serializeEnvironment(new Map(), spec.env)
|
|
193
|
+
const prefix = overlay === '' ? '' : `env ${overlay} `
|
|
194
|
+
|
|
195
|
+
let readyOrAbort: Promise<void> = ready
|
|
196
|
+
if (spec.signal !== undefined) {
|
|
197
|
+
const signal = spec.signal
|
|
198
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
199
|
+
const onAbort = (): void => {
|
|
200
|
+
reject(signal.reason instanceof Error
|
|
201
|
+
? signal.reason
|
|
202
|
+
: Object.assign(new Error('terminal setup aborted'), { name: 'AbortError' }))
|
|
203
|
+
}
|
|
204
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
205
|
+
removeAbortListener = () => { signal.removeEventListener('abort', onAbort) }
|
|
206
|
+
if (signal.aborted) onAbort()
|
|
207
|
+
})
|
|
208
|
+
readyOrAbort = Promise.race([ready, aborted])
|
|
209
|
+
}
|
|
210
|
+
await readyOrAbort
|
|
211
|
+
removeAbortListener()
|
|
212
|
+
removeAbortListener = () => {}
|
|
213
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
214
|
+
spec.signal?.throwIfAborted()
|
|
215
|
+
await handle.write(`cd ${quoteShellArg(cwd)} && exec ${prefix}${argv}\r`)
|
|
216
|
+
spec.signal?.throwIfAborted()
|
|
217
|
+
handedOff = true
|
|
218
|
+
return handle
|
|
219
|
+
} finally {
|
|
220
|
+
removeAbortListener()
|
|
221
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
222
|
+
if (!handedOff) {
|
|
223
|
+
try { session.close() } catch { /* best-effort close after failed setup */ }
|
|
224
|
+
}
|
|
136
225
|
}
|
|
137
|
-
const argv = spec.argv.map(quoteShellArg).join(' ')
|
|
138
|
-
await handle.write(`cd ${quoteShellArg(cwd)} && exec env -i -- ${environment} ${argv}\r`)
|
|
139
|
-
spec.signal?.throwIfAborted()
|
|
140
|
-
return handle
|
|
141
226
|
}
|
package/src/remote-search.ts
CHANGED
|
@@ -59,6 +59,97 @@ function sanitizeSearchQuery(query: string): string {
|
|
|
59
59
|
return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Trailer the wrapper appends after the (byte-capped) body: the REAL producer
|
|
64
|
+
* exit code plus its captured stderr.
|
|
65
|
+
*/
|
|
66
|
+
const STATUS_MARKER = '\0DSH_SEARCH_STATUS:'
|
|
67
|
+
|
|
68
|
+
/** Minutes after which an abandoned scratch directory is pruned by the next
|
|
69
|
+
* search (a SIGKILLed run cannot clean up after itself). */
|
|
70
|
+
const PRUNE_AGE_MINUTES = 10
|
|
71
|
+
|
|
72
|
+
/** One wrapped command's decomposed output. */
|
|
73
|
+
interface WrappedOutput {
|
|
74
|
+
/** The producer's stdout, capped at the requested byte budget. */
|
|
75
|
+
body: string
|
|
76
|
+
/** The producer's own exit code; undefined when the trailer is absent. */
|
|
77
|
+
code: number | undefined
|
|
78
|
+
/** Captured producer stderr (falls back to the engine's stream). */
|
|
79
|
+
stderr: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Split a wrapped command's stdout into body / producer exit code / stderr. */
|
|
83
|
+
function splitWrappedOutput(stdout: string, fallbackStderr: string): WrappedOutput {
|
|
84
|
+
const at = stdout.indexOf(STATUS_MARKER)
|
|
85
|
+
if (at < 0) return { body: stdout, code: undefined, stderr: fallbackStderr }
|
|
86
|
+
const body = stdout.slice(0, at)
|
|
87
|
+
const rest = stdout.slice(at + STATUS_MARKER.length)
|
|
88
|
+
const end = rest.indexOf('\0')
|
|
89
|
+
const codeText = end < 0 ? rest : rest.slice(0, end)
|
|
90
|
+
const code = Number.parseInt(codeText, 10)
|
|
91
|
+
const captured = (end < 0 ? '' : rest.slice(end + 1)).trim()
|
|
92
|
+
return {
|
|
93
|
+
body,
|
|
94
|
+
code: Number.isSafeInteger(code) ? code : undefined,
|
|
95
|
+
stderr: captured === '' ? fallbackStderr : captured,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wrap a producer command so its exit code and stderr survive the output cap.
|
|
101
|
+
*
|
|
102
|
+
* Piping into `head -c` made the pipeline report `head`'s status, so a missing
|
|
103
|
+
* root or a permission failure looked like "success, no matches". The producer
|
|
104
|
+
* now writes into a temp directory: one `mktemp -d` (a half-failed `mktemp`
|
|
105
|
+
* pair used to leak the first file), whose removal is registered with `trap`
|
|
106
|
+
* so a normal exit, SIGINT, SIGTERM or SIGHUP still cleans up.
|
|
107
|
+
*
|
|
108
|
+
* `trap` cannot cover SIGKILL — which is exactly how a timed-out command is
|
|
109
|
+
* ended — so the directory lives under ONE per-user scratch root that each
|
|
110
|
+
* search prunes of anything older than PRUNE_AGE_MINUTES. A killed run can
|
|
111
|
+
* therefore leave at most one directory, and no litter accumulates in /tmp.
|
|
112
|
+
*
|
|
113
|
+
* @param producer - the command whose stdout/stderr/status must be observed.
|
|
114
|
+
* @param capBytes - byte budget for the returned body.
|
|
115
|
+
* @returns one POSIX-sh command string.
|
|
116
|
+
*/
|
|
117
|
+
function wrapWithStatus(producer: string, capBytes: number): string {
|
|
118
|
+
return [
|
|
119
|
+
'__dsh_root="${TMPDIR:-/tmp}/dsh-hardssh-search"',
|
|
120
|
+
'mkdir -p "$__dsh_root" 2>/dev/null',
|
|
121
|
+
`find "$__dsh_root" -mindepth 1 -maxdepth 1 -mmin +${PRUNE_AGE_MINUTES} -exec rm -rf {} + 2>/dev/null`,
|
|
122
|
+
'__dsh_dir=$(mktemp -d "$__dsh_root/run.XXXXXX") || { echo "dsh-search: mktemp failed" >&2; exit 90; }',
|
|
123
|
+
"trap 'rm -rf \"$__dsh_dir\"' EXIT INT TERM HUP",
|
|
124
|
+
`{ ${producer} ; } >"$__dsh_dir/out" 2>"$__dsh_dir/err"; __dsh_code=$?`,
|
|
125
|
+
`head -c ${capBytes} "$__dsh_dir/out"`,
|
|
126
|
+
`printf '\\0DSH_SEARCH_STATUS:%s\\0' "$__dsh_code"`,
|
|
127
|
+
'head -c 4096 "$__dsh_dir/err"',
|
|
128
|
+
'exit 0',
|
|
129
|
+
].join('; ')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the producer's exit code, refusing to fall back to the wrapper's own
|
|
134
|
+
* (successful) status when the wrapper died before reporting one.
|
|
135
|
+
*
|
|
136
|
+
* The trailer is absent only if the wrapped shell was killed at the hard
|
|
137
|
+
* deadline. Falling back to the wrapper's exit code there would resurrect the
|
|
138
|
+
* original "success, no matches" bug for exactly the case that matters.
|
|
139
|
+
*/
|
|
140
|
+
function producerExitCode(wrapped: WrappedOutput, wrapperExitCode: number | null): number {
|
|
141
|
+
if (wrapped.code !== undefined) return wrapped.code
|
|
142
|
+
// The engine reports a null exit code for a signal-killed process; that is a
|
|
143
|
+
// failure too, so normalise it before deciding.
|
|
144
|
+
const fallback = wrapperExitCode ?? 0
|
|
145
|
+
if (wrapped.body === '' && fallback === 0) {
|
|
146
|
+
throw new Error(wrapped.stderr.trim() !== ''
|
|
147
|
+
? wrapped.stderr.trim()
|
|
148
|
+
: 'remote search did not report a result status (the search shell was terminated)')
|
|
149
|
+
}
|
|
150
|
+
return fallback
|
|
151
|
+
}
|
|
152
|
+
|
|
62
153
|
/** Escape find `-iname` metacharacters so user input matches literally. */
|
|
63
154
|
function escapeFindLiteral(value: string): string {
|
|
64
155
|
return value.replace(/[*?[\]\\]/g, '\\$&')
|
|
@@ -85,25 +176,27 @@ export class RemoteSearchService {
|
|
|
85
176
|
constructor(private readonly engine: SshEngine) {}
|
|
86
177
|
|
|
87
178
|
/** Literal file-name search under `root` (skips node_modules/.git). */
|
|
88
|
-
async searchNames(target: SearchTarget, query: string): Promise<RemoteNameSearch> {
|
|
179
|
+
async searchNames(target: SearchTarget, query: string, signal?: AbortSignal): Promise<RemoteNameSearch> {
|
|
89
180
|
const literal = sanitizeSearchQuery(query)
|
|
90
181
|
if (literal === '') return { query, hits: [], truncated: false }
|
|
91
182
|
const pattern = escapeFindLiteral(literal)
|
|
92
|
-
const
|
|
183
|
+
const producer =
|
|
93
184
|
`find ${shellQuote(target.root)}`
|
|
94
185
|
+ ` -maxdepth ${SEARCH_MAX_DEPTH}`
|
|
95
186
|
+ ` -not -path ${shellQuote('*/node_modules/*')}`
|
|
96
187
|
+ ` -not -path ${shellQuote('*/.git/*')}`
|
|
97
188
|
+ ` -iname ${shellQuote(`*${pattern}*`)}`
|
|
98
189
|
+ ` -printf '%y\\0%p\\0'`
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
190
|
+
const result = await this.engine.exec(target.alias, wrapWithStatus(producer, OUTPUT_CAP_BYTES), { timeoutMs: SEARCH_TIMEOUT_MS, signal })
|
|
191
|
+
const { body, code, stderr } = splitWrappedOutput(result.stdout, result.stderr)
|
|
192
|
+
const effective = producerExitCode({ body, code, stderr }, result.exitCode)
|
|
193
|
+
// An empty body with a failing producer is a real failure (missing root,
|
|
194
|
+
// permission denied), never "success, no matches".
|
|
195
|
+
if (body === '' && effective !== 0) {
|
|
196
|
+
throw new Error(stderr.trim() !== '' ? stderr.trim() : `remote search failed with exit code ${effective}`)
|
|
104
197
|
}
|
|
105
|
-
const allHits = parseNameRecords(
|
|
106
|
-
const capped = Buffer.byteLength(
|
|
198
|
+
const allHits = parseNameRecords(body)
|
|
199
|
+
const capped = Buffer.byteLength(body, 'utf8') >= OUTPUT_CAP_BYTES
|
|
107
200
|
return {
|
|
108
201
|
query,
|
|
109
202
|
hits: allHits.slice(0, SEARCH_HIT_CAP),
|
|
@@ -112,40 +205,43 @@ export class RemoteSearchService {
|
|
|
112
205
|
}
|
|
113
206
|
|
|
114
207
|
/** Glob search (pattern keeps glob semantics; `**` crosses directories). */
|
|
115
|
-
async glob(target: SearchTarget, pattern: string): Promise<RemoteGlobResult> {
|
|
208
|
+
async glob(target: SearchTarget, pattern: string, signal?: AbortSignal): Promise<RemoteGlobResult> {
|
|
116
209
|
const relative = pattern.replace(/^\/+/, '')
|
|
117
|
-
const
|
|
210
|
+
const producer =
|
|
118
211
|
`find ${shellQuote(target.root)}`
|
|
119
212
|
+ ` -maxdepth ${GLOB_MAX_DEPTH}`
|
|
120
213
|
+ ` -path ${shellQuote(`${target.root}/${relative}`)}`
|
|
121
214
|
+ ` -printf '%p\\0'`
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
215
|
+
const result = await this.engine.exec(target.alias, wrapWithStatus(producer, OUTPUT_CAP_BYTES), { timeoutMs: SEARCH_TIMEOUT_MS, signal })
|
|
216
|
+
const { body, code, stderr } = splitWrappedOutput(result.stdout, result.stderr)
|
|
217
|
+
const effective = producerExitCode({ body, code, stderr }, result.exitCode)
|
|
218
|
+
if (body === '' && effective !== 0) {
|
|
219
|
+
throw new Error(stderr.trim() !== '' ? stderr.trim() : `remote glob failed with exit code ${effective}`)
|
|
220
|
+
}
|
|
221
|
+
const hits = body.split('\0').filter((path) => path !== '')
|
|
222
|
+
const capped = Buffer.byteLength(body, 'utf8') >= OUTPUT_CAP_BYTES
|
|
127
223
|
// Truncation means "there ARE more than the cap", never "== cap".
|
|
128
224
|
return { hits: hits.slice(0, SEARCH_HIT_CAP), truncated: hits.length > SEARCH_HIT_CAP || capped }
|
|
129
225
|
}
|
|
130
226
|
|
|
131
227
|
/** Fixed-string grep (literal pattern, `-F`; NUL file boundary `-Z`). */
|
|
132
|
-
async grepFixed(target: SearchTarget, pattern: string): Promise<RemoteGrepResult> {
|
|
228
|
+
async grepFixed(target: SearchTarget, pattern: string, signal?: AbortSignal): Promise<RemoteGrepResult> {
|
|
133
229
|
if (pattern.includes('\0')) return { lines: [], truncated: false }
|
|
134
|
-
const
|
|
230
|
+
const producer =
|
|
135
231
|
`grep -rInFZ --exclude-dir=.git --exclude-dir=node_modules -m 200`
|
|
136
232
|
+ ` -- ${shellQuote(pattern)} ${shellQuote(target.root)}`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
233
|
+
const result = await this.engine.exec(target.alias, wrapWithStatus(producer, GREP_CAP_BYTES), { timeoutMs: SEARCH_TIMEOUT_MS, signal })
|
|
234
|
+
const { body, code, stderr } = splitWrappedOutput(result.stdout, result.stderr)
|
|
235
|
+
const effective = producerExitCode({ body, code, stderr }, result.exitCode)
|
|
140
236
|
// grep exit 1 = no matches: an empty result, not an error.
|
|
141
|
-
if (
|
|
142
|
-
if (
|
|
143
|
-
throw new Error(
|
|
237
|
+
if (body === '' && effective === 1) return { lines: [], truncated: false }
|
|
238
|
+
if (body === '' && effective !== 0) {
|
|
239
|
+
throw new Error(stderr.trim() !== '' ? stderr.trim() : `remote grep failed with exit code ${effective}`)
|
|
144
240
|
}
|
|
145
241
|
// -Z emits `file\0:line` then bare `:line` continuations for the same file.
|
|
146
242
|
const lines: string[] = []
|
|
147
243
|
let lastFile: string | undefined
|
|
148
|
-
for (const line of
|
|
244
|
+
for (const line of body.split('\n')) {
|
|
149
245
|
if (line === '') continue
|
|
150
246
|
const nul = line.indexOf('\0')
|
|
151
247
|
if (nul >= 0) {
|
|
@@ -157,7 +253,7 @@ export class RemoteSearchService {
|
|
|
157
253
|
lines.push(line)
|
|
158
254
|
}
|
|
159
255
|
}
|
|
160
|
-
const capped = Buffer.byteLength(
|
|
256
|
+
const capped = Buffer.byteLength(body, 'utf8') >= GREP_CAP_BYTES
|
|
161
257
|
return { lines, truncated: capped }
|
|
162
258
|
}
|
|
163
259
|
}
|