@tiphareth/dsh-hardssh 0.1.2-alpha
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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-workspace runner: lets other plugins (dsh-workbench-tiphareth) run
|
|
3
|
+
* git/files/terminals against an SSH-bound workspace. The workbench host asks
|
|
4
|
+
* `resolveRemote(root)` for a local anchor path; a hit returns the remote
|
|
5
|
+
* context + callable channels (git exec via the engine, SFTP ops, PTY).
|
|
6
|
+
* Kept duck-typed so the workbench never needs a compile-time dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SshEngine } from './ssh/engine.ts'
|
|
10
|
+
import type { SshWorkspaceLedger } from './ledger.ts'
|
|
11
|
+
|
|
12
|
+
/** The remote context one SSH-bound workspace resolves to. */
|
|
13
|
+
export interface RemoteWorkspaceContext {
|
|
14
|
+
alias: string
|
|
15
|
+
remoteRoot: string
|
|
16
|
+
/** Run `git [args...]` inside remoteRoot. Mirrors runGit's result shape. */
|
|
17
|
+
git(args: readonly string[], opts?: { timeoutMs?: number; input?: string; allowNonZero?: boolean }): Promise<{ stdout: string; stderr: string; exitCode: number }>
|
|
18
|
+
/** Run one arbitrary command (used by tools that shell out). */
|
|
19
|
+
run(command: string, opts?: { timeoutMs?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>
|
|
20
|
+
/** SFTP operations. */
|
|
21
|
+
list(path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>>
|
|
22
|
+
stat(path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }>
|
|
23
|
+
readFile(path: string): Promise<{ content: Buffer; mtime: number; size: number }>
|
|
24
|
+
writeFile(path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }>
|
|
25
|
+
mkdir(path: string): Promise<void>
|
|
26
|
+
rm(path: string, recursive: boolean): Promise<void>
|
|
27
|
+
rename(from: string, to: string): Promise<void>
|
|
28
|
+
/** Open an interactive SSH PTY in remoteRoot (columns/rows for the channel). */
|
|
29
|
+
openTerminal(cols: number, rows: number): Promise<RemotePtyHandle>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal PTY handle (duck-typed for consumers like dsh-workbench-tiphareth). */
|
|
33
|
+
export interface RemotePtyHandle {
|
|
34
|
+
shell: string
|
|
35
|
+
write(data: string): void
|
|
36
|
+
resize(cols: number, rows: number): void
|
|
37
|
+
kill(): void
|
|
38
|
+
onData(handler: (data: string) => void): { dispose(): void }
|
|
39
|
+
onExit(handler: (event: { exitCode: number }) => void): { dispose(): void }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Last-resort bound on opening a remote PTY (fresh SSH connect + shell). */
|
|
43
|
+
const OPEN_SHELL_TIMEOUT_MS = 30_000
|
|
44
|
+
|
|
45
|
+
/** Reject `promise` after `ms` with a clear message (unref'd timer). */
|
|
46
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
47
|
+
return new Promise<T>((resolve, reject) => {
|
|
48
|
+
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
49
|
+
timer.unref?.()
|
|
50
|
+
promise.then(
|
|
51
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
52
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Builds remote-workspace contexts off the shared ledger + engine. */
|
|
58
|
+
export class RemoteWorkspaceRunner {
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly engine: SshEngine,
|
|
61
|
+
private readonly ledger: SshWorkspaceLedger,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/** Resolve a LOCAL anchor path to a remote context, or null if not SSH-bound. */
|
|
65
|
+
resolveRemote(root: string): RemoteWorkspaceContext | null {
|
|
66
|
+
const record = this.ledger.findByAnchorSync(root)
|
|
67
|
+
if (record === undefined) return null
|
|
68
|
+
return this.build(record.alias, record.remoteRoot)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private build(alias: string, remoteRoot: string): RemoteWorkspaceContext {
|
|
72
|
+
const engine = this.engine
|
|
73
|
+
const git = async (
|
|
74
|
+
args: readonly string[],
|
|
75
|
+
opts: { timeoutMs?: number; input?: string; allowNonZero?: boolean } = {},
|
|
76
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
|
77
|
+
const quoted = args.map(arg => quote(arg)).join(' ')
|
|
78
|
+
// Restore real stderr ordering by capturing both streams; the engine
|
|
79
|
+
// exec returns them merged per frame — keep them merged for git too.
|
|
80
|
+
const command = `git ${quoted}`
|
|
81
|
+
const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
|
|
82
|
+
const stdout = result.stdout
|
|
83
|
+
const stderr = result.stderr
|
|
84
|
+
const exitCode = result.exitCode ?? 1
|
|
85
|
+
if (exitCode !== 0 && opts.allowNonZero !== true) {
|
|
86
|
+
const err = new Error(stderr.trim() || `git exited ${exitCode}`)
|
|
87
|
+
;(err as { shortMessage?: string }).shortMessage = stderr.trim()
|
|
88
|
+
throw err
|
|
89
|
+
}
|
|
90
|
+
return { stdout, stderr, exitCode }
|
|
91
|
+
}
|
|
92
|
+
const run = async (
|
|
93
|
+
command: string,
|
|
94
|
+
opts: { timeoutMs?: number } = {},
|
|
95
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
|
96
|
+
const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
|
|
97
|
+
return {
|
|
98
|
+
stdout: result.stdout,
|
|
99
|
+
stderr: result.stderr,
|
|
100
|
+
exitCode: result.exitCode ?? 1,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const list = async (path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>> => {
|
|
104
|
+
return engine.ls(alias, path)
|
|
105
|
+
}
|
|
106
|
+
const stat = async (path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> => {
|
|
107
|
+
return engine.stat(alias, path)
|
|
108
|
+
}
|
|
109
|
+
const readFile = async (path: string): Promise<{ content: Buffer; mtime: number; size: number }> => {
|
|
110
|
+
return engine.readFile(alias, path)
|
|
111
|
+
}
|
|
112
|
+
const writeFile = async (path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }> => {
|
|
113
|
+
return engine.writeFile(alias, path, content, expectedMtime)
|
|
114
|
+
}
|
|
115
|
+
const mkdir = async (path: string): Promise<void> => { await engine.mkdir(alias, path) }
|
|
116
|
+
const rm = async (path: string, recursive: boolean): Promise<void> => { await engine.rm(alias, path, recursive) }
|
|
117
|
+
const rename = async (from: string, to: string): Promise<void> => { await engine.rename(alias, from, to) }
|
|
118
|
+
const openTerminal = async (cols: number, rows: number): Promise<RemotePtyHandle> => {
|
|
119
|
+
// Bound the shell open: the engine's own connect/channel timeouts cover
|
|
120
|
+
// the normal failure modes, this is a last-resort guard so a terminal
|
|
121
|
+
// request can never hang the workbench's SSE stream indefinitely.
|
|
122
|
+
const session = await withTimeout(
|
|
123
|
+
engine.openShell(alias, { cols, rows }),
|
|
124
|
+
OPEN_SHELL_TIMEOUT_MS,
|
|
125
|
+
`SSH 终端打开超时(${OPEN_SHELL_TIMEOUT_MS / 1000} 秒):${alias}`,
|
|
126
|
+
)
|
|
127
|
+
let exited = false
|
|
128
|
+
const exitHandlers = new Set<(event: { exitCode: number }) => void>()
|
|
129
|
+
const onDataHandlers = new Set<(data: string) => void>()
|
|
130
|
+
const enc = new TextDecoder()
|
|
131
|
+
|
|
132
|
+
// The PTY already runs the user's interactive login shell (client.shell
|
|
133
|
+
// allocates one); just cd into the remote root. Do NOT exec a new shell
|
|
134
|
+
// here — that restarts the shell, re-runs profile/.bashrc, and replays
|
|
135
|
+
// the login banner + prompt (the "command ran twice" symptom).
|
|
136
|
+
//
|
|
137
|
+
// Send `cd` only AFTER the first shell prompt has been emitted — a
|
|
138
|
+
// prompt (line ending in $ / # / > / %) means the login banner finished
|
|
139
|
+
// and the shell is interactive. Sending earlier makes the PTY echo the
|
|
140
|
+
// command raw AND the shell re-echo it after the prompt (double cd).
|
|
141
|
+
let cwdSent = false
|
|
142
|
+
let shellBuf = ''
|
|
143
|
+
const trySendCwd = (): void => {
|
|
144
|
+
if (cwdSent || exited) return
|
|
145
|
+
// Prompt heuristic: the last non-empty line ends with a prompt char
|
|
146
|
+
// (`$ # > %`) OR looks like a user@host prompt (e.g. `@login01 ~`,
|
|
147
|
+
// `majie@host:~/dir$`). Either means the shell is interactive now.
|
|
148
|
+
const lines = shellBuf.split('\n').map(line => line.replace(/\r$/, '')).filter(line => line.trim() !== '')
|
|
149
|
+
const last = lines[lines.length - 1]
|
|
150
|
+
if (last !== undefined
|
|
151
|
+
&& ( /[#$>%]\s*$/.test(last) || /^\S+@\S+/.test(last) )
|
|
152
|
+
&& !last.trim().endsWith('cd')) {
|
|
153
|
+
cwdSent = true
|
|
154
|
+
session.send(`cd ${quote(remoteRoot)}\r`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
session.onData = (data: Buffer) => {
|
|
158
|
+
const text = enc.decode(data)
|
|
159
|
+
shellBuf = (shellBuf + text).slice(-4096)
|
|
160
|
+
trySendCwd()
|
|
161
|
+
for (const handler of onDataHandlers) handler(text)
|
|
162
|
+
}
|
|
163
|
+
session.onExit = (code: number | null) => {
|
|
164
|
+
exited = true
|
|
165
|
+
for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
|
|
166
|
+
}
|
|
167
|
+
// Fallback: if no prompt heuristic matched (odd PS1), send cd after 4s.
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (!cwdSent && !exited) {
|
|
170
|
+
cwdSent = true
|
|
171
|
+
session.send(`cd ${quote(remoteRoot)}\r`)
|
|
172
|
+
}
|
|
173
|
+
}, 4000)
|
|
174
|
+
return {
|
|
175
|
+
shell: 'ssh',
|
|
176
|
+
write(data: string) { if (!exited) session.send(data) },
|
|
177
|
+
resize(c: number, r: number) { session.resize(c, r) },
|
|
178
|
+
kill() {
|
|
179
|
+
try { session.signal('KILL') } catch { /* closed */ }
|
|
180
|
+
try { session.close() } catch { /* closed */ }
|
|
181
|
+
},
|
|
182
|
+
onData(handler: (data: string) => void) {
|
|
183
|
+
onDataHandlers.add(handler)
|
|
184
|
+
return { dispose: () => { onDataHandlers.delete(handler) } }
|
|
185
|
+
},
|
|
186
|
+
onExit(handler: (event: { exitCode: number }) => void) {
|
|
187
|
+
exitHandlers.add(handler)
|
|
188
|
+
return { dispose: () => { exitHandlers.delete(handler) } }
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { alias, remoteRoot, git, run, list, stat, readFile, writeFile, mkdir, rm, rename, openTerminal }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** POSIX single-quote a shell argument. */
|
|
197
|
+
function quote(arg: string): string {
|
|
198
|
+
if (arg === '') return "''"
|
|
199
|
+
if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
|
|
200
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`
|
|
201
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote search semantics shared by the UI file-name search (backend) and the
|
|
3
|
+
* remote_search agent tool (glob / grep modes). Centralizes command building,
|
|
4
|
+
* literal escaping, NUL-delimited parsing, and truncation rules (P1-11) so
|
|
5
|
+
* the three former hand-rolled implementations cannot drift again.
|
|
6
|
+
*
|
|
7
|
+
* Command output is never treated as authorization evidence: this service
|
|
8
|
+
* returns raw absolute paths; callers (backend) re-authorize each hit against
|
|
9
|
+
* the workspace root before exposing it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SshEngine } from './ssh/engine.ts'
|
|
13
|
+
import { shellQuote } from './shell.ts'
|
|
14
|
+
|
|
15
|
+
/** One SSH workspace to search on. */
|
|
16
|
+
export interface SearchTarget {
|
|
17
|
+
alias: string
|
|
18
|
+
/** Remote root; every path is reported absolute under it. */
|
|
19
|
+
root: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One raw file-name hit (absolute path, type). */
|
|
23
|
+
export interface RemoteNameHit {
|
|
24
|
+
path: string
|
|
25
|
+
isDir: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemoteNameSearch {
|
|
29
|
+
query: string
|
|
30
|
+
hits: RemoteNameHit[]
|
|
31
|
+
truncated: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoteGlobResult {
|
|
35
|
+
hits: string[]
|
|
36
|
+
truncated: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RemoteGrepResult {
|
|
40
|
+
lines: string[]
|
|
41
|
+
truncated: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** File-name search depth (matches the pre-existing backend budget). */
|
|
45
|
+
const SEARCH_MAX_DEPTH = 4
|
|
46
|
+
/** Glob search depth (matches the pre-existing glob-search budget). */
|
|
47
|
+
const GLOB_MAX_DEPTH = 6
|
|
48
|
+
/** Max hits returned by any search. */
|
|
49
|
+
const SEARCH_HIT_CAP = 200
|
|
50
|
+
/** Per-search engine timeout. */
|
|
51
|
+
const SEARCH_TIMEOUT_MS = 20_000
|
|
52
|
+
/** Raw-output cap for name/glob searches (defensive; parsing decides truncation). */
|
|
53
|
+
const OUTPUT_CAP_BYTES = 256 * 1024
|
|
54
|
+
/** Raw-output cap for grep (the tool contract's 200KB budget). */
|
|
55
|
+
const GREP_CAP_BYTES = 200_000
|
|
56
|
+
|
|
57
|
+
/** Strip control characters and cap the query length (wire hygiene). */
|
|
58
|
+
function sanitizeSearchQuery(query: string): string {
|
|
59
|
+
return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Escape find `-iname` metacharacters so user input matches literally. */
|
|
63
|
+
function escapeFindLiteral(value: string): string {
|
|
64
|
+
return value.replace(/[*?[\]\\]/g, '\\$&')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parse NUL-delimited `type\0path\0...` records from `find -printf`. */
|
|
68
|
+
function parseNameRecords(stdout: string): RemoteNameHit[] {
|
|
69
|
+
const parts = stdout.split('\0')
|
|
70
|
+
const hits: RemoteNameHit[] = []
|
|
71
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
72
|
+
const type = parts[i]
|
|
73
|
+
const abs = parts[i + 1]
|
|
74
|
+
if (type === undefined || abs === undefined || abs === '') continue
|
|
75
|
+
hits.push({ path: abs, isDir: type === 'd' })
|
|
76
|
+
}
|
|
77
|
+
return hits
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One search implementation shared by the UI and the agent tools.
|
|
82
|
+
* Stateless apart from the engine; safe to construct per call or share.
|
|
83
|
+
*/
|
|
84
|
+
export class RemoteSearchService {
|
|
85
|
+
constructor(private readonly engine: SshEngine) {}
|
|
86
|
+
|
|
87
|
+
/** Literal file-name search under `root` (skips node_modules/.git). */
|
|
88
|
+
async searchNames(target: SearchTarget, query: string): Promise<RemoteNameSearch> {
|
|
89
|
+
const literal = sanitizeSearchQuery(query)
|
|
90
|
+
if (literal === '') return { query, hits: [], truncated: false }
|
|
91
|
+
const pattern = escapeFindLiteral(literal)
|
|
92
|
+
const command =
|
|
93
|
+
`find ${shellQuote(target.root)}`
|
|
94
|
+
+ ` -maxdepth ${SEARCH_MAX_DEPTH}`
|
|
95
|
+
+ ` -not -path ${shellQuote('*/node_modules/*')}`
|
|
96
|
+
+ ` -not -path ${shellQuote('*/.git/*')}`
|
|
97
|
+
+ ` -iname ${shellQuote(`*${pattern}*`)}`
|
|
98
|
+
+ ` -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())
|
|
104
|
+
}
|
|
105
|
+
const allHits = parseNameRecords(result.stdout)
|
|
106
|
+
const capped = Buffer.byteLength(result.stdout, 'utf8') >= OUTPUT_CAP_BYTES
|
|
107
|
+
return {
|
|
108
|
+
query,
|
|
109
|
+
hits: allHits.slice(0, SEARCH_HIT_CAP),
|
|
110
|
+
truncated: allHits.length > SEARCH_HIT_CAP || capped,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Glob search (pattern keeps glob semantics; `**` crosses directories). */
|
|
115
|
+
async glob(target: SearchTarget, pattern: string): Promise<RemoteGlobResult> {
|
|
116
|
+
const relative = pattern.replace(/^\/+/, '')
|
|
117
|
+
const command =
|
|
118
|
+
`find ${shellQuote(target.root)}`
|
|
119
|
+
+ ` -maxdepth ${GLOB_MAX_DEPTH}`
|
|
120
|
+
+ ` -path ${shellQuote(`${target.root}/${relative}`)}`
|
|
121
|
+
+ ` -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
|
|
127
|
+
// Truncation means "there ARE more than the cap", never "== cap".
|
|
128
|
+
return { hits: hits.slice(0, SEARCH_HIT_CAP), truncated: hits.length > SEARCH_HIT_CAP || capped }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fixed-string grep (literal pattern, `-F`; NUL file boundary `-Z`). */
|
|
132
|
+
async grepFixed(target: SearchTarget, pattern: string): Promise<RemoteGrepResult> {
|
|
133
|
+
if (pattern.includes('\0')) return { lines: [], truncated: false }
|
|
134
|
+
const command =
|
|
135
|
+
`grep -rInFZ --exclude-dir=.git --exclude-dir=node_modules -m 200`
|
|
136
|
+
+ ` -- ${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)
|
|
140
|
+
// 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())
|
|
144
|
+
}
|
|
145
|
+
// -Z emits `file\0:line` then bare `:line` continuations for the same file.
|
|
146
|
+
const lines: string[] = []
|
|
147
|
+
let lastFile: string | undefined
|
|
148
|
+
for (const line of result.stdout.split('\n')) {
|
|
149
|
+
if (line === '') continue
|
|
150
|
+
const nul = line.indexOf('\0')
|
|
151
|
+
if (nul >= 0) {
|
|
152
|
+
lastFile = line.slice(0, nul)
|
|
153
|
+
lines.push(lastFile + line.slice(nul + 1))
|
|
154
|
+
} else if (lastFile !== undefined && line.startsWith(':')) {
|
|
155
|
+
lines.push(lastFile + line)
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(line)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const capped = Buffer.byteLength(result.stdout, 'utf8') >= GREP_CAP_BYTES
|
|
161
|
+
return { lines, truncated: capped }
|
|
162
|
+
}
|
|
163
|
+
}
|