dsh-foreman-node 0.1.1 → 0.2.0

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/src/index.ts CHANGED
@@ -5,38 +5,24 @@
5
5
  * It imports no domain core, opens no database, and reads no policy, because a device holds no ledger — the hub is
6
6
  * the only place work is recorded, and this side's job is to do it and say what happened.
7
7
  */
8
- import type { Context } from '@deepseek-ai/cordis'
8
+ import type { Context, Volatile } from '@deepseek-ai/cordis'
9
9
  import s from '@deepseek-ai/schemastery'
10
10
  import type {} from '@deepseek-ai/dsh-api-session-controller'
11
11
  import type {} from '@deepseek-ai/dsh-session-persistence'
12
12
  import type {} from '@deepseek-ai/dsh-workspace'
13
13
  import type {} from '@deepseek-ai/dsh-permission-presets'
14
14
  import type {} from '@deepseek-ai/dsh-tools'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
15
17
  import { NodeClient } from './client.ts'
18
+ import { configured, SSH_DEFAULTS, type NodeConfig, type NodeRoot, type SshConfig } from './config.ts'
19
+ import { registerNodeRoutes, type RouteHost } from './routes.ts'
20
+ import { Tunnel } from './tunnel.ts'
16
21
  import { JobSessions } from './session.ts'
17
22
  import type { Frame } from './protocol.ts'
18
23
 
19
24
  /** Version reported in the hello, so the hub can tell which device build is talking. */
20
- export const VERSION = '0.1.0'
21
-
22
- /** One project this device can work on: the hub's logical name and this machine's own absolute path. */
23
- export interface NodeRoot { project: string; path: string }
24
-
25
- /** What the `dsh-foreman-node` entry in a profile's patch configures. */
26
- export interface NodeConfig {
27
- /** The hub's stable address; the device dials out to it. Blank until someone fills it in. */
28
- url: string
29
- /** This device's id, as registered on the hub with `Devices.add`. Blank until someone fills it in. */
30
- deviceId: string
31
- /** This device's token; sent in the hello and nowhere else. Blank until someone fills it in. */
32
- token: string
33
- /** The projects this device can work on. */
34
- roots: NodeRoot[]
35
- /** How often to tell the hub this device is alive, in milliseconds. */
36
- heartbeatMs: number
37
- /** Delay before the first reconnect attempt, in milliseconds. */
38
- reconnectMs: number
39
- }
25
+ export const VERSION = '0.2.0'
40
26
 
41
27
  /**
42
28
  * Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
@@ -49,15 +35,61 @@ export interface NodeConfig {
49
35
  * The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
50
36
  * an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
51
37
  */
52
- export const Config: Schemastery<NodeConfig> = s.object({
53
- url: s.string().default('').description('The hub\'s address, such as wss://foreman.example.com. The device dials out to it.'),
54
- deviceId: s.string().default('').description('This device\'s id, as registered on the hub with `Devices.add`.'),
55
- token: s.string().default('').role('secret').description('This device\'s token; it is sent in the hello frame and nowhere else.'),
56
- roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([])
38
+ const ConfigSchema = s.object({
39
+ url: s.string().default('').volatile().description('The hub\'s address, such as wss://foreman.example.com. The device dials out to it.'),
40
+ deviceId: s.string().default('').volatile().description('This device\'s id, as registered on the hub with `Devices.add`.'),
41
+ token: s.string().default('').role('secret').volatile().description('This device\'s token; it is sent in the hello frame and nowhere else.'),
42
+ roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([]).volatile()
57
43
  .description('Projects this device can work on: a logical name and this machine\'s own absolute path, kept verbatim.'),
58
- heartbeatMs: s.natural().default(15_000).description('How often to tell the hub this device is alive, in milliseconds.'),
59
- reconnectMs: s.natural().default(1_000).description('Delay before the first reconnect attempt, in milliseconds.'),
44
+ heartbeatMs: s.natural().default(15_000).volatile().description('How often to tell the hub this device is alive, in milliseconds.'),
45
+ reconnectMs: s.natural().default(1_000).volatile().description('Delay before the first reconnect attempt, in milliseconds.'),
46
+ // Every field carries a default, and the object itself does too: a `cordis.patch.yml` written before this block
47
+ // existed has no `ssh` key, and a schema that demanded one would report the whole profile invalid on upgrade.
48
+ // Every field here is volatile for the same reason as the top level: `isVolatilePath` walks into this object, so a
49
+ // non-volatile child makes the whole section unwritable and the page reports "could not save". Marked per field
50
+ // rather than on the object, which would replace this block's default with `{}`.
51
+ ssh: s.object({
52
+ enabled: s.boolean().default(false).volatile().description('Reach the hub through an SSH tunnel this device starts itself.'),
53
+ host: s.string().default('').volatile().description('The hub machine, such as 203.0.113.10 or its ~/.ssh/config alias.'),
54
+ // min(1), not just natural(): an empty port box becomes 0, and 0 is a port the tunnel would happily dial.
55
+ port: s.natural().min(1).max(65535).default(22).volatile().description('The hub machine\'s SSH port.'),
56
+ user: s.string().default('').volatile().description('The SSH user on the hub machine.'),
57
+ keyPath: s.string().default('').volatile().description('The private key this device authenticates with; the page lists these from ~/.ssh.'),
58
+ remotePort: s.natural().min(1).max(65535).default(8790).volatile().description('The port the hub listens on, on the hub machine.'),
59
+ localPort: s.natural().min(1).max(65535).default(8790).volatile().description('The local port the tunnel listens on; the device dials this.'),
60
+ }).default({ enabled: false, host: '', port: 22, user: '', keyPath: '', remotePort: 8790, localPort: 8790 })
61
+ .description('How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.'),
60
62
  })
63
+ /**
64
+ * Why every field is volatile.
65
+ *
66
+ * Not for hot reload: it is the switch that makes the entry visible to DSH's settings system at all. `dsh-settings`
67
+ * skips every plugin entry whose `volatileForm(schema)` is undefined, and that only finds fields reachable through a
68
+ * volatile ancestor. Without it the configuration page has no form to render and sits on "reading configuration"
69
+ * forever — no fields, no test button, no save.
70
+ *
71
+ * A volatile field arrives wrapped, so its value is read with `.get()`; `configured()` is what unwraps a whole
72
+ * `NodeConfig` for the code that would rather not know. Marking the object instead of each field is not an option:
73
+ * `.volatile()` on the object replaces every default with `{}`, so `Config({})` loses all of them.
74
+ */
75
+
76
+ /**
77
+ * What `apply` actually receives: every field wrapped, since each one is volatile.
78
+ *
79
+ * Declared as an alias rather than inferred so the plugin's public signature does not name a `schemastery` symbol the
80
+ * emitted declaration cannot reference portably.
81
+ */
82
+ export type ConfigInput = { [K in keyof NodeConfig]: NodeConfig[K] | Volatile<NodeConfig[K]> }
83
+
84
+ /**
85
+ * The schema, asserted rather than annotated.
86
+ *
87
+ * An annotation makes the declaration portable but cannot express what a volatile field does to a field's type, so the
88
+ * mismatch moves into the annotation. The assertion keeps the published `.d.ts` free of `schemastery` internals and
89
+ * puts the shape in one place; `configured()` is what re-establishes the real types at every read.
90
+ */
91
+ const Typed = ConfigSchema as unknown as Schemastery<NodeConfig>
92
+ export { Typed as Config }
61
93
 
62
94
  /**
63
95
  * What this configuration still needs before the device can dial its hub, or undefined when it is complete.
@@ -65,9 +97,25 @@ export const Config: Schemastery<NodeConfig> = s.object({
65
97
  * A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
66
98
  * whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
67
99
  */
100
+ /**
101
+ * The address this device dials.
102
+ *
103
+ * With the tunnel on, that is the local end of it: `url` names the hub at its own address, which the tunnel exists to
104
+ * make reachable, so dialling `url` would bypass the tunnel entirely and look like an offline device. Keeping the two
105
+ * cases in one function is what stops `apply` from picking the wrong one.
106
+ */
107
+ export function hubUrlOf(config: NodeConfig): string {
108
+ return config.ssh.enabled ? `ws://127.0.0.1:${config.ssh.localPort}` : config.url
109
+ }
110
+
68
111
  export function missingConfiguration(config: NodeConfig): string | undefined {
69
112
  const missing: string[] = []
70
- if (config.url.trim() === '') missing.push('url')
113
+ // The address is only required when there is no tunnel: with one, the device dials its local end and `url` is unused.
114
+ if (config.ssh.enabled) {
115
+ if (config.ssh.host.trim() === '') missing.push('ssh.host')
116
+ if (config.ssh.user.trim() === '') missing.push('ssh.user')
117
+ if (config.ssh.keyPath.trim() === '') missing.push('ssh.keyPath')
118
+ } else if (config.url.trim() === '') missing.push('url')
71
119
  if (config.deviceId.trim() === '') missing.push('deviceId')
72
120
  if (config.token.trim() === '') missing.push('token')
73
121
  if (missing.length === 0) return undefined
@@ -89,7 +137,18 @@ export const inject = ['sessionController', 'sessionPersistence', 'workspaceRegi
89
137
  * The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
90
138
  * while its hub is unreachable, and the client keeps retrying in the background.
91
139
  */
92
- export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
140
+ export async function apply(ctx: Context, raw: ConfigInput): Promise<void> {
141
+ // Every field is volatile, so `raw` holds wrappers; everything below wants plain values.
142
+ const config = configured(raw)
143
+ // The configuration page has to work on a device that is not configured yet — that is precisely when someone opens
144
+ // it — so the routes go up before the check below, not after it. `webServer` is injected optionally rather than
145
+ // listed in `inject`: a headless or CLI DSH has no web server and must still take work, losing only the page.
146
+ ctx.inject(['webServer'], child => {
147
+ // The service shape is declared locally rather than imported from `@deepseek-ai/dsh-host-webserver`: the device
148
+ // package stays thin, and this plugin needs two members of that service, not its whole surface.
149
+ const host = (child as unknown as { webServer: RouteHost }).webServer
150
+ child.effect(() => registerNodeRoutes(host, sshDirectory()))
151
+ })
93
152
  const missing = missingConfiguration(config)
94
153
  if (missing !== undefined) {
95
154
  ctx.logger.info('%s', missing)
@@ -97,7 +156,7 @@ export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
97
156
  }
98
157
  const jobs = new JobSessions(ctx)
99
158
  const client = new NodeClient({
100
- url: config.url,
159
+ url: hubUrlOf(config),
101
160
  deviceId: config.deviceId,
102
161
  token: config.token,
103
162
  version: VERSION,
@@ -108,9 +167,24 @@ export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
108
167
  log: text => ctx.logger.warn('%s', text),
109
168
  onFrame: frame => { void receive(ctx, jobs, client, frame) },
110
169
  })
111
- client.start()
112
- ctx.effect(() => () => { client.stop() })
113
- ctx.logger.info('dsh-foreman-node %s is dialling %s as %s', VERSION, config.url, config.deviceId)
170
+ // With a tunnel, the client waits for the local port to answer before dialling it: a client that starts early spends
171
+ // its first attempts on a port nothing is listening on, which reads in the log like the hub being down.
172
+ const tunnel = config.ssh.enabled
173
+ ? new Tunnel({ ssh: config.ssh, log: text => ctx.logger.warn('%s', text),
174
+ onState: (state, failure) => {
175
+ ctx.logger.info('dsh-foreman-node tunnel: %s', failure === undefined ? state : `${state} (${failure})`)
176
+ if (state === 'connected') client.start()
177
+ } })
178
+ : undefined
179
+ if (tunnel === undefined) client.start()
180
+ else tunnel.start()
181
+ ctx.effect(() => () => { tunnel?.stop(); client.stop() })
182
+ ctx.logger.info('dsh-foreman-node %s is dialling %s as %s', VERSION, hubUrlOf(config), config.deviceId)
183
+ }
184
+
185
+ /** The directory the key list reads; `~/.ssh` on a machine where the current user is the one configuring it. */
186
+ export function sshDirectory(): string {
187
+ return join(homedir(), '.ssh')
114
188
  }
115
189
 
116
190
  /**
@@ -153,7 +227,11 @@ async function receive(ctx: Context, jobs: JobSessions, client: NodeClient, fram
153
227
  }
154
228
  }
155
229
 
230
+ export { configured, SSH_DEFAULTS, type NodeConfig, type NodeRoot, type SshConfig } from './config.ts'
156
231
  export { NodeClient } from './client.ts'
232
+ export { Tunnel, tunnelArgs } from './tunnel.ts'
233
+ export { listKeys } from './keys.ts'
234
+ export { registerNodeRoutes, testArgs } from './routes.ts'
157
235
  export { JobSessions } from './session.ts'
158
236
  export { foremanReportTool, REPORT_STATES } from './report-tool.ts'
159
237
  export type { Frame, Root, FrameAttachment } from './protocol.ts'
package/src/keys.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The SSH keys this machine already has, named so a person can pick one.
3
+ *
4
+ * A browser cannot list `~/.ssh`: the page runs in the DSH web UI, which has no filesystem. The device process does,
5
+ * so the listing happens here and the page renders it. Nothing on this path reads a private key: the answer is a
6
+ * filename, a path, and the comment from the public half, which is enough to tell one key from another and is all a
7
+ * page needs.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
+ import { basename, join } from 'node:path'
11
+
12
+ /** One key a person may choose: never its contents, only what tells it apart from the others. */
13
+ export interface SshKey {
14
+ /** The filename, such as `id_ed25519`. */
15
+ name: string
16
+ /** Its absolute path, which is what the configuration stores. */
17
+ path: string
18
+ /** The trailing comment of the public half, usually `user@machine`; empty when it has none. */
19
+ comment: string
20
+ }
21
+
22
+ /** The `.pub` file that sits beside a private key. */
23
+ const publicOf = (path: string): string => `${path}.pub`
24
+
25
+ /**
26
+ * Lists the usable private keys in a directory.
27
+ *
28
+ * A key counts as usable only when its public half is beside it: that is how `ssh-keygen` writes them, and a private
29
+ * key without one is either half-deleted or was never finished, so offering it would only fail later at the tunnel.
30
+ * Anything unreadable is skipped rather than thrown, because a permissions problem on one file must not empty the list.
31
+ */
32
+ export function listKeys(directory: string): SshKey[] {
33
+ let names: string[]
34
+ try { names = readdirSync(directory) } catch { return [] /* No ~/.ssh on this machine is a normal answer, not a failure. */ }
35
+ const keys: SshKey[] = []
36
+ for (const name of names.sort()) {
37
+ if (!name.startsWith('id_') || name.endsWith('.pub')) continue
38
+ const path = join(directory, name)
39
+ if (!existsSync(publicOf(path))) continue
40
+ let comment = ''
41
+ try {
42
+ // `ssh-ed25519 AAAAC3... user@host`: the comment is the third field, and it may be absent.
43
+ const fields = readFileSync(publicOf(path), 'utf8').trim().split(/\s+/)
44
+ comment = fields.slice(2).join(' ')
45
+ } catch { comment = '' /* An unreadable comment costs a label, not the key. */ }
46
+ keys.push({ name: basename(name), path, comment })
47
+ }
48
+ return keys
49
+ }
package/src/protocol.ts CHANGED
File without changes
File without changes
package/src/routes.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The device's two loopback routes: what keys exist, and whether a given one reaches a given host.
3
+ *
4
+ * These exist because the configuration page is a web page: it cannot read `~/.ssh` and cannot start ssh. The device
5
+ * process can, so it answers both and the page renders the answers. Nothing here writes configuration — the page
6
+ * saves through DSH's own configuration form — and nothing here reads a private key or keeps a session open.
7
+ *
8
+ * They are registered by the caller through `ctx.inject(['webServer'], …)` rather than listed in `inject`, and that is
9
+ * deliberate: a device with no web UI (a headless or CLI DSH) must still take work. It simply has no configuration
10
+ * page, which is a missing convenience, not a broken device.
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { IncomingMessage, ServerResponse } from 'node:http'
14
+ import { spawn } from 'node:child_process'
15
+ import { listKeys, type SshKey } from './keys.ts'
16
+
17
+ /** What one authentication test found. `ok` false is a normal answer, not an error. */
18
+ export interface TestResult { ok: boolean; output: string }
19
+
20
+ /** Runs one command and reports its exit status with its output; injectable so a test never starts ssh. */
21
+ export type RunCommand = (argv: readonly string[]) => Promise<{ code: number | null; output: string }>
22
+
23
+ /** The connection the page is testing, exactly the four fields it collects. */
24
+ export interface TestInput { host: string; port: number; user: string; keyPath: string }
25
+
26
+ /** How long one authentication test may take before it is reported as a timeout. */
27
+ const TEST_TIMEOUT_MS = 15_000
28
+ /** How much of ssh's output to keep: the reason is at the end, and a page shows a few lines. */
29
+ const OUTPUT_KEEP = 2_048
30
+
31
+ /**
32
+ * The argv of an authentication test.
33
+ *
34
+ * It asks for a command that does nothing (`true`) rather than opening a forward, because the question is only
35
+ * "does this key authenticate", and `BatchMode=yes` keeps it from stopping to ask for a passphrase nobody can type.
36
+ */
37
+ export function testArgs({ host, port, user, keyPath }: TestInput): string[] {
38
+ return ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-o', 'StrictHostKeyChecking=accept-new',
39
+ '-p', String(port), '-i', keyPath, `${user}@${host}`, 'true']
40
+ }
41
+
42
+ /** The default runner: a real ssh, resolving to its exit status either way. */
43
+ const realRun: RunCommand = argv => new Promise(resolve => {
44
+ const child = spawn(argv[0]!, argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
45
+ let output = ''
46
+ const collect = (chunk: Buffer) => { output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEEP) }
47
+ child.stdout.on('data', collect)
48
+ child.stderr.on('data', collect)
49
+ const timer = setTimeout(() => { output = `${output}\n(timed out after ${TEST_TIMEOUT_MS / 1000}s)`.trim(); child.kill('SIGKILL') }, TEST_TIMEOUT_MS)
50
+ child.on('error', error => { clearTimeout(timer); resolve({ code: -1, output: `could not start ssh: ${error.message}` }) })
51
+ child.on('exit', code => { clearTimeout(timer); resolve({ code, output: output.trim() }) })
52
+ })
53
+
54
+ /** Writes one JSON answer. */
55
+ function json(res: ServerResponse, status: number, value: unknown): void {
56
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
57
+ 'x-content-type-options': 'nosniff' })
58
+ res.end(JSON.stringify(value))
59
+ }
60
+
61
+ /** Reads a JSON body, refusing anything larger than a form would send. */
62
+ async function body(req: IncomingMessage, maxBytes = 16 * 1024): Promise<unknown> {
63
+ const chunks: Buffer[] = []
64
+ let size = 0
65
+ for await (const chunk of req) {
66
+ const bytes = Buffer.from(chunk)
67
+ size += bytes.length
68
+ if (size > maxBytes) throw new Error(`request exceeds ${maxBytes} bytes`)
69
+ chunks.push(bytes)
70
+ }
71
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown }
72
+ catch { throw new Error('the request body is not JSON') }
73
+ }
74
+
75
+ /**
76
+ * The same-origin guard the hub uses, for the same reason.
77
+ *
78
+ * The marker header alone proves nothing: any local process can send it. What the browser will not let another origin
79
+ * do is send it *with* its own `Origin` intact, so an `Origin` that is present must be this page's. A request with no
80
+ * `Origin` at all (a local tool, a test) is allowed and the marker still has to be there.
81
+ */
82
+ /** The loopback names a host header may carry; DSH's desktop build normalizes`::1` and `localhost` to each other. */
83
+ const LOOPBACK = ['127.0.0.1', 'localhost', '::1', '[::1]']
84
+
85
+ export function sameOrigin(req: Pick<IncomingMessage, 'headers'>, port: number, protocol = 'http:'): boolean {
86
+ const host = req.headers.host
87
+ if (host === undefined) return false
88
+ // `host:port` for IPv4 and names, `[::1]:port` for a bracketed IPv6 literal.
89
+ const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(host)
90
+ if (match === null) return false
91
+ const [, name, seen] = match
92
+ // A request addressed to another host is a DNS-rebinding attempt, whatever its Origin says.
93
+ if (!LOOPBACK.includes(name!) || Number(seen) !== port) return false
94
+ // The scheme comes from the service: a TLS-terminated DSH sends `https://` and would otherwise be refused here.
95
+ return req.headers['x-orchestrator-request'] === '1'
96
+ && (req.headers.origin === undefined || req.headers.origin === `${protocol}//${host}`)
97
+ }
98
+
99
+ /** The prefix both routes live under; it never collides with the hub's `/orchestrator`. */
100
+ export const NODE_PREFIX = '/foreman-node'
101
+
102
+ export interface RouteHost {
103
+ /** The web server service: the only thing this module needs from the host. */
104
+ register(route: { kind: 'prefix'; path: string
105
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }): () => void
106
+ /** The port the web server listens on, used by the same-origin check. */
107
+ port: number
108
+ /** The scheme the web server serves, so a TLS-terminated host's `https://` origin is accepted. */
109
+ protocol?: string
110
+ }
111
+
112
+ /**
113
+ * Registers the routes and returns their disposer.
114
+ *
115
+ * `listKeys` and `run` are injectable so tests can answer without a filesystem or a child process; production passes
116
+ * a directory and the real runner.
117
+ */
118
+ export function registerNodeRoutes(host: RouteHost, directory: string, run: RunCommand = realRun): () => void {
119
+ return host.register({ kind: 'prefix', path: NODE_PREFIX, handler: async (req, res) => {
120
+ // The guard runs before routing: an unauthenticated caller learns nothing about what exists here.
121
+ if (!sameOrigin(req, host.port, host.protocol)) { json(res, 403, { error: 'Same-origin request required' }); return }
122
+ const url = new URL(req.url ?? '/', 'http://localhost')
123
+ const method = req.method ?? 'GET'
124
+ try {
125
+ if (url.pathname === `${NODE_PREFIX}/api/keys` && method === 'GET') {
126
+ const keys: SshKey[] = listKeys(directory)
127
+ json(res, 200, { keys, directory })
128
+ return
129
+ }
130
+ if (url.pathname === `${NODE_PREFIX}/api/test` && method === 'POST') {
131
+ const input = (await body(req)) as Partial<TestInput>
132
+ if (typeof input.host !== 'string' || input.host.trim() === '' || typeof input.user !== 'string' || input.user.trim() === ''
133
+ || typeof input.keyPath !== 'string' || input.keyPath.trim() === '') {
134
+ json(res, 400, { error: 'host, user and keyPath are required' })
135
+ return
136
+ }
137
+ const port = typeof input.port === 'number' && Number.isInteger(input.port) && input.port > 0 && input.port <= 65535 ? input.port : 22
138
+ // Only a key this machine already offers may be tested. The page can only pick from that list, so anything
139
+ // else is either a stale form or a caller asking this process to run ssh against a path of its choosing;
140
+ // neither is a reason to start a process.
141
+ const keyPath = input.keyPath.trim()
142
+ const known = listKeys(directory).find(key => key.path === keyPath)
143
+ if (known === undefined) {
144
+ json(res, 400, { error: `no key at ${keyPath}; the page lists the ones this machine has` })
145
+ return
146
+ }
147
+ const result = await run(testArgs({ host: input.host.trim(), port, user: input.user.trim(), keyPath }))
148
+ // A refused key is an answer the page shows, not a server error: 200 with `ok: false` and ssh's own words.
149
+ json(res, 200, { ok: result.code === 0, output: result.output })
150
+ return
151
+ }
152
+ json(res, 404, { error: 'Unknown operation' })
153
+ } catch (error) {
154
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) })
155
+ }
156
+ } })
157
+ }
package/src/session.ts CHANGED
File without changes