@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. 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, readScrubbedRemoteEnvironment, serializeEnvironment } from './environment.ts'
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 cwd = spec.cwd !== undefined && spec.cwd.startsWith('/') ? spec.cwd : root
123
- const handle = new SshTerminalHandle(
124
- () => session.close(),
125
- (data) => session.send(data),
126
- (name) => session.signal(name),
127
- spec.graceMs,
128
- )
129
- session.onData = (data: Buffer) => {
130
- if (!handle.output.destroyed) handle.output.write(data)
131
- }
132
- session.onExit = (code: number | null, error?: string) => {
133
- handle.topLevelExited = true
134
- handle.output.end()
135
- handle.resolveExit({ exitCode: error !== undefined ? null : code, signal: null })
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
  }
@@ -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 command =
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
- + ` 2>/dev/null`
100
- + ` | head -c ${OUTPUT_CAP_BYTES}`
101
- const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
102
- if (!result.success && result.stdout === '' && result.stderr !== '') {
103
- throw new Error(result.stderr.trim())
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(result.stdout)
106
- const capped = Buffer.byteLength(result.stdout, 'utf8') >= OUTPUT_CAP_BYTES
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 command =
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
- + ` 2>/dev/null`
123
- + ` | head -c ${OUTPUT_CAP_BYTES}`
124
- const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
125
- const hits = result.stdout.split('\0').filter((path) => path !== '')
126
- const capped = Buffer.byteLength(result.stdout, 'utf8') >= OUTPUT_CAP_BYTES
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 command =
230
+ const producer =
135
231
  `grep -rInFZ --exclude-dir=.git --exclude-dir=node_modules -m 200`
136
232
  + ` -- ${shellQuote(pattern)} ${shellQuote(target.root)}`
137
- + ` 2>/dev/null`
138
- + ` | head -c ${GREP_CAP_BYTES}`
139
- const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
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 (result.exitCode === 1) return { lines: [], truncated: false }
142
- if (!result.success && result.stdout === '' && result.stderr !== '') {
143
- throw new Error(result.stderr.trim())
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 result.stdout.split('\n')) {
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(result.stdout, 'utf8') >= GREP_CAP_BYTES
256
+ const capped = Buffer.byteLength(body, 'utf8') >= GREP_CAP_BYTES
161
257
  return { lines, truncated: capped }
162
258
  }
163
259
  }