dsh-remote-vs 0.0.0-stage → 0.8.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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * One timeout policy for every remote round trip.
3
+ *
4
+ * Why this module exists
5
+ * ----------------------
6
+ * Large remote repositories made the explorer look broken: `git status` on a
7
+ * cold tree takes minutes, while the client gave up at a fixed 45 s and showed
8
+ * "读取超时(远端无响应)" with git still working. The number therefore has to be
9
+ * (a) bigger by default, (b) the same on both halves, and (c) editable, because
10
+ * how large "large" is differs per host.
11
+ *
12
+ * The Host owns the setting (`gitTimeoutMs` / `readTimeoutMs` in the plugin
13
+ * config, editable in the settings form) and serves the effective pair through
14
+ * `/api/dsh-remote/workspaces`; the client applies it and falls back to these
15
+ * defaults when an older Host answers without them. Nothing here may throw on a
16
+ * malformed value — a bad config must degrade to the default, not break the
17
+ * explorer.
18
+ */
19
+ import type { RemoteTimeouts } from './protocol.ts'
20
+
21
+ /** Smallest accepted timeout: below this, local work would start failing. */
22
+ export const MIN_TIMEOUT_MS = 1_000
23
+
24
+ /** Largest accepted timeout — the exec route clamps to the same ceiling. */
25
+ export const MAX_TIMEOUT_MS = 600_000
26
+
27
+ /**
28
+ * Default for one git command (status/log/branches/diff over exec).
29
+ *
30
+ * 120 s was the old internal value and 45 s the old client deadline; both were
31
+ * routinely exceeded by a cold `git status` on a large monorepo. Five minutes
32
+ * still fails eventually (a wedged host must not spin forever) but outlasts any
33
+ * plausible cold index walk.
34
+ */
35
+ export const DEFAULT_GIT_TIMEOUT_MS = 300_000
36
+
37
+ /** Default for one SFTP file read (editor, diff sides, document preview). */
38
+ export const DEFAULT_READ_TIMEOUT_MS = 120_000
39
+
40
+ /** The pair every half starts from before any Host answer arrives. */
41
+ export const DEFAULT_TIMEOUTS: RemoteTimeouts = {
42
+ gitMs: DEFAULT_GIT_TIMEOUT_MS,
43
+ readMs: DEFAULT_READ_TIMEOUT_MS,
44
+ }
45
+
46
+ /**
47
+ * Clamp one untrusted timeout into the accepted range.
48
+ *
49
+ * @param value - raw value (plugin config, JSON body, anything).
50
+ * @param fallback - used when the value is not a usable number.
51
+ * @returns a whole number of milliseconds within [MIN, MAX].
52
+ */
53
+ export function clampTimeout(value: unknown, fallback: number): number {
54
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
55
+ const whole = Math.floor(value)
56
+ if (whole < MIN_TIMEOUT_MS) return MIN_TIMEOUT_MS
57
+ if (whole > MAX_TIMEOUT_MS) return MAX_TIMEOUT_MS
58
+ return whole
59
+ }
60
+
61
+ /**
62
+ * Clamp an optional request timeout: absent/invalid stays absent, which is what
63
+ * the exec route forwards as "use the pool default".
64
+ *
65
+ * @param value - raw `timeoutMs` from a request body.
66
+ * @returns the clamped timeout, or undefined when the caller named none.
67
+ */
68
+ export function clampExecTimeout(value: unknown): number | undefined {
69
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
70
+ return clampTimeout(value, DEFAULT_GIT_TIMEOUT_MS)
71
+ }
72
+
73
+ /** Resolve the effective pair from raw config values. */
74
+ export function resolveTimeouts(config: {
75
+ gitTimeoutMs?: unknown
76
+ readTimeoutMs?: unknown
77
+ } | undefined): RemoteTimeouts {
78
+ return {
79
+ gitMs: clampTimeout(config?.gitTimeoutMs, DEFAULT_GIT_TIMEOUT_MS),
80
+ readMs: clampTimeout(config?.readTimeoutMs, DEFAULT_READ_TIMEOUT_MS),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Normalize a pair that arrived over the wire (an older Host sends nothing).
86
+ *
87
+ * @param value - the `timeouts` field of a workspaces answer.
88
+ * @returns the pair to use, always complete and clamped.
89
+ */
90
+ export function normalizeTimeouts(value: unknown): RemoteTimeouts {
91
+ const record = (typeof value === 'object' && value !== null ? value : {}) as {
92
+ gitMs?: unknown
93
+ readMs?: unknown
94
+ }
95
+ return resolveTimeouts({ gitTimeoutMs: record.gitMs, readTimeoutMs: record.readMs })
96
+ }
97
+
98
+ /** Human-facing seconds for one timeout ("300s"), used in status and errors. */
99
+ export function timeoutSeconds(ms: number): string {
100
+ return `${Math.round(ms / 1000)}s`
101
+ }
102
+
103
+ /**
104
+ * Extra wait the CLIENT allows beyond the server-side budget.
105
+ *
106
+ * Both halves enforce a limit, so without a margin they race and the loser is
107
+ * effectively random — and the loser's message is much worse: the generic
108
+ * client deadline ("读取超时(远端无响应)") instead of git's own "timed out after
109
+ * Ns" plus the hint to raise the setting. The client therefore always waits a
110
+ * little longer than the command it is watching.
111
+ */
112
+ export const CLIENT_DEADLINE_MARGIN_MS = 15_000
113
+
114
+ /**
115
+ * The client's watchdog for one operation whose server-side budget is `ms`.
116
+ *
117
+ * @param ms - the command's own budget (git or SFTP).
118
+ * @returns a deadline strictly greater than `ms`, so the server-side error wins.
119
+ */
120
+ export function clientDeadlineFor(ms: number): number {
121
+ return ms + CLIENT_DEADLINE_MARGIN_MS
122
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Agent tools: the model-facing counterpart of the remote GUI. Every tool
3
+ * talks to the same pool/files the web UI uses. Hosts must be configured in
4
+ * the GUI first (agents cannot create hosts).
5
+ */
6
+ import { defineTool } from '@deepseek-ai/dsh-tools'
7
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
8
+ import type { RemotePool } from './pool.ts'
9
+ import { RemoteFiles } from './sftp.ts'
10
+ import type { RemoteStore } from './store.ts'
11
+ import type { RemoteExecResult, RemoteHostSummary } from './protocol.ts'
12
+
13
+ function text(value: string): ContentBlock[] {
14
+ return [{ type: 'text', text: value }]
15
+ }
16
+
17
+ /** Agent-facing host row (schema-strict projection of RemoteHostSummary). */
18
+ export interface AgentHostRow {
19
+ alias: string
20
+ host: string
21
+ port: number
22
+ user: string
23
+ auth: 'key' | 'password'
24
+ keyReady: boolean
25
+ managedKey: boolean
26
+ description?: string
27
+ tags: string[]
28
+ createdAt: number
29
+ updatedAt: number
30
+ }
31
+
32
+ function toAgentHostRow(host: RemoteHostSummary): AgentHostRow {
33
+ return {
34
+ alias: host.alias,
35
+ host: host.host,
36
+ port: host.port,
37
+ user: host.user,
38
+ auth: host.auth,
39
+ keyReady: host.keyReady,
40
+ managedKey: host.managedKey,
41
+ ...(host.description !== undefined ? { description: host.description } : {}),
42
+ tags: [...host.tags],
43
+ createdAt: host.createdAt,
44
+ updatedAt: host.updatedAt,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Render the host table.
50
+ *
51
+ * An empty list has TWO very different causes and they used to share one
52
+ * message: nothing is configured, or a query filtered everything out. The
53
+ * second one claimed "no remote hosts configured" while five were sitting
54
+ * there, which reads as "stop looking" to a caller that just searched.
55
+ */
56
+ function renderHosts(hosts: RemoteHostSummary[], query?: string): string {
57
+ if (hosts.length === 0) {
58
+ return query === undefined || query.trim() === ''
59
+ ? 'no remote hosts configured (add one in the Remote sidebar tab first)'
60
+ : `no remote host matches "${query}" — hosts are configured, the query just filtered them all out`
61
+ }
62
+ const rows = hosts.map(h => [
63
+ h.alias, h.host, String(h.port), h.user, h.auth,
64
+ h.auth === 'key' ? (h.keyReady ? 'key-ok' : 'key-missing') : 'password',
65
+ h.tags.length > 0 ? h.tags.join(',') : '-',
66
+ h.description ?? '',
67
+ ].join(' | '))
68
+ return ['alias | host | port | user | auth | key | tags | description', '--- | --- | --- | --- | --- | --- | --- | ---', ...rows].join('\n')
69
+ }
70
+
71
+ /**
72
+ * `ls -l`-style type letter for one entry.
73
+ *
74
+ * A symlink is NOT a regular file, and rendering it as one made the two
75
+ * indistinguishable: `/bin`, `/lib`, `/lib64` and `/sbin` on an ordinary host
76
+ * are symlinks to directories, and they came out as `- 7 bin`, which reads as a
77
+ * seven-byte file. `isSymlink` is already in the entry, so the render simply has
78
+ * to use it. `.spike/tool-schema-check.mjs` now renders every tool and fails if
79
+ * these three kinds stop being distinguishable.
80
+ */
81
+ function entryKind(entry: { isDir: boolean; isSymlink: boolean }): string {
82
+ if (entry.isDir) return 'd'
83
+ if (entry.isSymlink) return 'l'
84
+ return '-'
85
+ }
86
+
87
+ function renderExec(result: RemoteExecResult): string {
88
+ const marker = result.timedOut ? '[timed out]' : `[exit code: ${result.exitCode ?? 'null'}]`
89
+ const parts = [marker]
90
+ if (result.stdout !== '') parts.push('stdout:\n' + result.stdout)
91
+ if (result.stderr !== '') parts.push('stderr:\n' + result.stderr)
92
+ if (result.error !== undefined) parts.push('error: ' + result.error)
93
+ parts.push(`duration: ${result.durationMs} ms`)
94
+ return parts.join('\n')
95
+ }
96
+
97
+ /** Schema-strict exec outcome (exitCode null folds to undefined). */
98
+ export interface AgentExecOut {
99
+ success: boolean
100
+ exitCode?: number
101
+ timedOut: boolean
102
+ stdout: string
103
+ stderr: string
104
+ durationMs: number
105
+ error?: string
106
+ }
107
+
108
+ export interface RemoteToolsDeps {
109
+ store: RemoteStore
110
+ pool: RemotePool
111
+ }
112
+
113
+ export function remoteHostsTool(deps: RemoteToolsDeps): ReturnType<typeof defineTool> {
114
+ return defineTool({
115
+ name: 'remote_hosts',
116
+ description: 'List configured remote SSH hosts (alias, host, user, auth). Use remote_exec/remote_read/remote_write/remote_ls with the alias.',
117
+ parameters: {
118
+ query: { type: 'string', description: 'Optional fuzzy match against alias, host, and description.' },
119
+ },
120
+ output: {
121
+ schema: {
122
+ type: 'object',
123
+ additionalProperties: false,
124
+ properties: {
125
+ hosts: {
126
+ type: 'array',
127
+ required: true,
128
+ items: {
129
+ type: 'object',
130
+ additionalProperties: false,
131
+ properties: {
132
+ alias: { type: 'string', required: true },
133
+ host: { type: 'string', required: true },
134
+ port: { type: 'integer', required: true },
135
+ user: { type: 'string', required: true },
136
+ auth: { type: 'string', enum: ['key', 'password'], required: true },
137
+ keyReady: { type: 'boolean', required: true },
138
+ managedKey: { type: 'boolean', required: true },
139
+ description: { type: 'string' },
140
+ tags: { type: 'array', items: { type: 'string' }, required: true },
141
+ createdAt: { type: 'integer', required: true },
142
+ updatedAt: { type: 'integer', required: true },
143
+ },
144
+ },
145
+ },
146
+ },
147
+ },
148
+ render: (args, value: { hosts?: AgentHostRow[] }) => text(renderHosts(value.hosts ?? [], args.query)),
149
+ },
150
+ isConcurrencySafe: () => true,
151
+ async execute(args) {
152
+ const query = typeof args.query === 'string' && args.query !== '' ? args.query.toLowerCase() : undefined
153
+ const hosts = deps.store.list()
154
+ .map(h => deps.store.summarize(h))
155
+ .filter(h => query === undefined
156
+ || h.alias.toLowerCase().includes(query)
157
+ || h.host.toLowerCase().includes(query)
158
+ || (h.description ?? '').toLowerCase().includes(query))
159
+ .map(toAgentHostRow)
160
+ return { hosts }
161
+ },
162
+ })
163
+ }
164
+
165
+ export function remoteExecTool(deps: RemoteToolsDeps): ReturnType<typeof defineTool> {
166
+ return defineTool({
167
+ name: 'remote_exec',
168
+ description: 'Run a non-interactive shell command on a remote SSH host. stdout/stderr are returned verbatim (may carry secrets).',
169
+ parameters: {
170
+ alias: { type: 'string', description: 'Host alias from remote_hosts.' },
171
+ command: { type: 'string', description: 'Shell command to run on the remote host.' },
172
+ timeoutMs: { type: 'number', description: 'Timeout in ms (default 60000, max 600000).' },
173
+ },
174
+ output: {
175
+ schema: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ properties: {
179
+ success: { type: 'boolean', required: true },
180
+ exitCode: { type: 'integer' },
181
+ timedOut: { type: 'boolean', required: true },
182
+ stdout: { type: 'string', required: true },
183
+ stderr: { type: 'string', required: true },
184
+ durationMs: { type: 'number', required: true },
185
+ error: { type: 'string' },
186
+ },
187
+ },
188
+ render: (_args, value: AgentExecOut) => text(renderExec({ ...value, exitCode: value.exitCode ?? null })),
189
+ },
190
+ async execute(args) {
191
+ const timeoutMs = typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)
192
+ ? Math.min(Math.max(Math.floor(args.timeoutMs), 1000), 600_000)
193
+ : undefined
194
+ const result = await deps.pool.exec(
195
+ args.alias as string,
196
+ args.command as string,
197
+ timeoutMs === undefined ? {} : { timeoutMs },
198
+ )
199
+ const out: AgentExecOut = {
200
+ success: result.success,
201
+ timedOut: result.timedOut,
202
+ stdout: result.stdout,
203
+ stderr: result.stderr,
204
+ durationMs: result.durationMs,
205
+ ...(result.exitCode !== null ? { exitCode: result.exitCode } : {}),
206
+ ...(result.error !== undefined ? { error: result.error } : {}),
207
+ }
208
+ return out
209
+ },
210
+ })
211
+ }
212
+
213
+ export function remoteLsTool(deps: RemoteToolsDeps): ReturnType<typeof defineTool> {
214
+ return defineTool({
215
+ name: 'remote_ls',
216
+ description: 'List one remote directory over SFTP (absolute POSIX path).',
217
+ parameters: {
218
+ alias: { type: 'string', description: 'Host alias from remote_hosts.' },
219
+ path: { type: 'string', description: 'Absolute remote directory path.' },
220
+ },
221
+ output: {
222
+ schema: {
223
+ type: 'object',
224
+ additionalProperties: false,
225
+ properties: {
226
+ entries: {
227
+ type: 'array',
228
+ required: true,
229
+ items: {
230
+ type: 'object',
231
+ additionalProperties: false,
232
+ properties: {
233
+ name: { type: 'string', required: true },
234
+ path: { type: 'string', required: true },
235
+ isDir: { type: 'boolean', required: true },
236
+ size: { type: 'integer', required: true },
237
+ mtime: { type: 'integer', required: true },
238
+ // `mode` is part of `RemoteFileEntry` and the implementation has
239
+ // always returned it, but this schema did not declare it — and
240
+ // the schema sets `additionalProperties: false`, so the tool
241
+ // failed validation on EVERY call since the first release:
242
+ // "value.entries[0].mode" is not a declared property
243
+ // A declared output schema has to describe what the tool really
244
+ // returns; `.spike/tool-schema-check.mjs` now enforces that.
245
+ mode: { type: 'integer', required: true },
246
+ isSymlink: { type: 'boolean', required: true },
247
+ },
248
+ },
249
+ },
250
+ },
251
+ },
252
+ render: (_args, value: { entries?: { name: string; isDir: boolean; isSymlink: boolean; size: number }[] }) => text(
253
+ (value.entries ?? [])
254
+ .map(e => `${entryKind(e)} ${e.size} ${e.name}`)
255
+ .join('\n') || '(empty directory)',
256
+ ),
257
+ },
258
+ async execute(args) {
259
+ const files = new RemoteFiles(deps.pool)
260
+ return { entries: await files.list(args.alias as string, args.path as string) }
261
+ },
262
+ })
263
+ }
264
+
265
+ export function remoteReadTool(deps: RemoteToolsDeps): ReturnType<typeof defineTool> {
266
+ return defineTool({
267
+ name: 'remote_read',
268
+ description: 'Read a remote text file over SFTP (absolute POSIX path, first 8 MiB window). Refuses binary files.',
269
+ parameters: {
270
+ alias: { type: 'string', description: 'Host alias from remote_hosts.' },
271
+ path: { type: 'string', description: 'Absolute remote file path.' },
272
+ },
273
+ output: {
274
+ schema: {
275
+ type: 'object',
276
+ additionalProperties: false,
277
+ properties: {
278
+ size: { type: 'integer', required: true },
279
+ truncated: { type: 'boolean', required: true },
280
+ text: { type: 'string', required: true },
281
+ },
282
+ },
283
+ render: (_args, value: { text?: string; truncated?: boolean }) => text(
284
+ (value.text ?? '') + (value.truncated === true ? '\n[... truncated: file exceeds 8 MiB ...]' : ''),
285
+ ),
286
+ },
287
+ async execute(args) {
288
+ const files = new RemoteFiles(deps.pool)
289
+ const result = await files.read(args.alias as string, args.path as string)
290
+ if (result.binary) throw new Error('binary file: remote_read serves text only')
291
+ return { size: result.size, truncated: result.truncated, text: result.text ?? '' }
292
+ },
293
+ })
294
+ }
295
+
296
+ export function remoteWriteTool(deps: RemoteToolsDeps): ReturnType<typeof defineTool> {
297
+ return defineTool({
298
+ name: 'remote_write',
299
+ description: 'Write a remote text file over SFTP (absolute POSIX path, creates parent dirs). Overwrites without confirmation.',
300
+ parameters: {
301
+ alias: { type: 'string', description: 'Host alias from remote_hosts.' },
302
+ path: { type: 'string', description: 'Absolute remote file path.' },
303
+ content: { type: 'string', description: 'UTF-8 text content to write.' },
304
+ },
305
+ output: {
306
+ schema: {
307
+ type: 'object',
308
+ additionalProperties: false,
309
+ properties: {
310
+ size: { type: 'integer', required: true },
311
+ },
312
+ },
313
+ render: (_args, value: { size?: number }) => text(`wrote ${value.size ?? 0} bytes`),
314
+ },
315
+ async execute(args) {
316
+ const files = new RemoteFiles(deps.pool)
317
+ return files.write(args.alias as string, args.path as string, args.content as string, 'utf8')
318
+ },
319
+ })
320
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "kind": "executable",
3
+ "version": "0.2.1-alpha.2",
4
+ "platform": "linux-x64",
5
+ "target": "node24-linux-x64",
6
+ "tarball": "dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz",
7
+ "tarballSha256": "241c1e96e4fec14808440ac767bce5df64d566164f9fb3f338f2474aa2c50421",
8
+ "binarySha256": "a19a508d4e0659713f88ea32307a2cec4b4c16afa62e92c0a9fb8e41bdf7c3da",
9
+ "protocol": 2,
10
+ "nodeVersion": "v24.21.0",
11
+ "glibcCeiling": "2.35",
12
+ "builtAt": "2026-10-12T14:27:00+08:00",
13
+ "builtOn": "WSL2 Ubuntu-22.04 (glibc 2.35), node v24.21.0, pnpm 11.28.5, tag dsh-v0.2.1-alpha.2 (d7432673)",
14
+ "sourcePatch": "glibc-ceiling.patch",
15
+ "notes": "Upstream gate (2.28) relaxed to 2.35 via DSH_GLIBC_CEILING: no docker for the manylinux pty recompile; fleet glibc is 2.35. Official 8-check suite passed (verify-ssh-helper-artifact --sandbox=required)."
16
+ }
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz
3
+ size: 46734323 bytes
4
+ sha256: 241c1e96e4fec14808440ac767bce5df64d566164f9fb3f338f2474aa2c50421
@@ -0,0 +1 @@
1
+ 241c1e96e4fec14808440ac767bce5df64d566164f9fb3f338f2474aa2c50421 dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz
@@ -0,0 +1,31 @@
1
+ diff --git a/scripts/ssh-helper/native.ts b/scripts/ssh-helper/native.ts
2
+ index a6f4db14..e6a7ccee 100644
3
+ --- a/scripts/ssh-helper/native.ts
4
+ +++ b/scripts/ssh-helper/native.ts
5
+ @@ -4,6 +4,15 @@ import { open } from 'node:fs/promises'
6
+ import { join } from 'node:path'
7
+ import { payloadFiles, type SshHelperTarget } from './artifact.ts'
8
+
9
+ +/** Local-build override: DSH_GLIBC_CEILING (default 2.28) relaxes the payload baseline for fleets newer than 2.28. Upstream behavior unchanged when unset. */
10
+ +function glibcCeiling(): number[] {
11
+ + const raw = (process.env.DSH_GLIBC_CEILING ?? "").trim();
12
+ + if (raw === "") return [2, 28];
13
+ + const parts = raw.split(".").map(Number);
14
+ + if (parts.length === 0 || parts.some((n) => !Number.isInteger(n) || n < 0)) throw new Error(`Invalid DSH_GLIBC_CEILING: ${raw}`);
15
+ + return parts;
16
+ +}
17
+ +
18
+ function newer(version: string, ceiling: number[]): boolean {
19
+ const components = version.split('.').map(Number)
20
+ for (let index = 0; index < Math.max(components.length, ceiling.length); index += 1) {
21
+ @@ -58,8 +67,9 @@ export async function verifyNativePayload(directory: string, target: SshHelperTa
22
+ const expected = arch === 'arm64' ? /Machine:\s+AArch64/ : /Machine:\s+Advanced Micro Devices X86-64/
23
+ if (!expected.test(elfHeader)) throw new Error(`ELF architecture differs from ${target}: ${path}`)
24
+ const versions = execFileSync('readelf', ['--version-info', filename], { encoding: 'utf8' })
25
+ + const ceiling = glibcCeiling();
26
+ for (const match of versions.matchAll(/\bGLIBC_(\d+(?:\.\d+)+)/g)) {
27
+ - if (newer(match[1] as string, [2, 28])) throw new Error(`${path} requires GLIBC_${match[1]}, exceeding 2.28`)
28
+ + if (newer(match[1] as string, ceiling)) throw new Error(`${path} requires GLIBC_${match[1]}, exceeding ${ceiling.join(".")}`)
29
+ }
30
+ } else {
31
+ execFileSync('lipo', [filename, '-verify_arch', arch === 'arm64' ? 'arm64' : 'x86_64'], { stdio: 'pipe' })