@stacksjs/defaults 0.75.74 → 0.75.75

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 (28) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +42 -7
  3. package/ai/skills/stacks-payments/SKILL.md +11 -0
  4. package/app/Actions/Dashboard/DashboardHomeAction.test.ts +33 -1
  5. package/app/Actions/Dashboard/DashboardHomeAction.ts +63 -25
  6. package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +3 -0
  7. package/app/Actions/Dashboard/Remote/RemoteTerminalCloseAction.ts +25 -0
  8. package/app/Actions/Dashboard/Remote/RemoteTerminalInputAction.ts +25 -0
  9. package/app/Actions/Dashboard/Remote/RemoteTerminalOpenAction.ts +39 -0
  10. package/app/Actions/Dashboard/Remote/RemoteTerminalResizeAction.ts +25 -0
  11. package/app/Actions/Dashboard/Remote/RemoteTerminalStreamAction.ts +36 -0
  12. package/app/Actions/Dashboard/Remote/remote-commands.ts +30 -1
  13. package/app/Actions/Dashboard/Remote/remote-routes.test.ts +4 -4
  14. package/app/Actions/Dashboard/Remote/remote-terminal.test.ts +463 -0
  15. package/app/Actions/Dashboard/Remote/remote-terminal.ts +399 -0
  16. package/app/Actions/Dashboard/Remote/ssh-runner.ts +82 -1
  17. package/app/Actions/Dashboard/Remote/terminal-sessions.ts +80 -0
  18. package/functions/dashboard-api.ts +13 -0
  19. package/functions/remote-terminal-element.ts +287 -0
  20. package/functions/remote.test.ts +52 -0
  21. package/functions/remote.ts +136 -0
  22. package/functions/xterm-styles.d.ts +9 -0
  23. package/ide/vscode/package.json +1 -1
  24. package/package.json +5 -3
  25. package/resources/components/Dashboard/Remote/RemoteConsole.stx +162 -0
  26. package/resources/functions/dashboard/sidebar.ts +3 -0
  27. package/routes/dashboard-api.ts +7 -0
  28. package/views/dashboard/remote/index.stx +8 -0
@@ -0,0 +1,463 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { RemoteHost } from './remote-commands'
3
+ import type { TerminalAuditSink, TerminalEvent, TerminalProcess, TerminalSessionOptions } from './remote-terminal'
4
+ import { describe, expect, it } from 'bun:test'
5
+ import { existsSync, readdirSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { sshTerminalArgv } from './remote-commands'
8
+ import { TerminalSessions } from './remote-terminal'
9
+ import { createPtySpawner } from './ssh-runner'
10
+ import { encodeTerminalEvent, terminalEventStream } from './terminal-sessions'
11
+
12
+ /**
13
+ * Interactive terminal sessions (stacksjs/stacks#960). Every rule is asserted
14
+ * against an injected process, then the real PTY spawner is exercised with a
15
+ * local shell standing in for ssh.
16
+ */
17
+
18
+ const alice = { id: 1, email: 'alice@example.com' } as unknown as UserModel
19
+ const bob = { id: 2, email: 'bob@example.com' } as unknown as UserModel
20
+
21
+ const shellHost: RemoteHost = { key: 'app', host: 'app.example.com', user: 'deploy', knownHosts: 'app.example.com ssh-ed25519 AAAA', terminal: true }
22
+ const commandOnlyHost: RemoteHost = { key: 'db', host: 'db.example.com', user: 'deploy', knownHosts: 'db.example.com ssh-ed25519 AAAA' }
23
+ const hosts = [shellHost, commandOnlyHost]
24
+
25
+ const allowAll = () => true
26
+
27
+ interface Fake {
28
+ process: TerminalProcess
29
+ written: string[]
30
+ sizes: Array<[number, number]>
31
+ killed: boolean
32
+ emit: (text: string) => void
33
+ exit: (code: number) => void
34
+ }
35
+
36
+ function fakeProcess(): Fake {
37
+ let emit: (bytes: Uint8Array) => void = () => {}
38
+ let resolveExit!: (code: number) => void
39
+ const fake: Fake = {
40
+ written: [],
41
+ sizes: [],
42
+ killed: false,
43
+ emit: text => emit(new TextEncoder().encode(text)),
44
+ exit: code => resolveExit(code),
45
+ process: undefined as unknown as TerminalProcess,
46
+ }
47
+ fake.process = {
48
+ write: data => fake.written.push(data),
49
+ resize: (cols, rows) => fake.sizes.push([cols, rows]),
50
+ kill: () => {
51
+ fake.killed = true
52
+ resolveExit(-1)
53
+ },
54
+ exited: new Promise<number>((resolve) => { resolveExit = resolve }),
55
+ }
56
+ ;(fake as { bind: (fn: (bytes: Uint8Array) => void) => void } & Fake).bind = (fn) => { emit = fn }
57
+ return fake
58
+ }
59
+
60
+ function recordingAudit(): TerminalAuditSink & { log: string[] } {
61
+ const log: string[] = []
62
+ return {
63
+ log,
64
+ opened: entry => void log.push(`opened ${entry.user} ${entry.hostKey}`),
65
+ input: entry => void log.push(`input ${entry.line}`),
66
+ closed: entry => void log.push(`closed ${entry.reason} in=${entry.bytesIn} out=${entry.bytesOut}`),
67
+ }
68
+ }
69
+
70
+ function sessions(overrides: Partial<TerminalSessionOptions> = {}) {
71
+ const fakes: Fake[] = []
72
+ const audit = recordingAudit()
73
+ const manager = new TerminalSessions({
74
+ spawn: (_host, size, onOutput) => {
75
+ const fake = fakeProcess()
76
+ ;(fake as unknown as { bind: (fn: (bytes: Uint8Array) => void) => void }).bind(onOutput)
77
+ fake.sizes.push([size.cols, size.rows])
78
+ fakes.push(fake)
79
+ return fake.process
80
+ },
81
+ audit,
82
+ ...overrides,
83
+ })
84
+ return { manager, fakes, audit }
85
+ }
86
+
87
+ async function take(events: AsyncIterable<TerminalEvent>, count: number): Promise<TerminalEvent[]> {
88
+ const out: TerminalEvent[] = []
89
+ for await (const event of events) {
90
+ out.push(event)
91
+ if (out.length === count)
92
+ break
93
+ }
94
+ return out
95
+ }
96
+
97
+ const text = (event: TerminalEvent) => (event.type === 'output' ? new TextDecoder().decode(event.data) : `<${event.reason}>`)
98
+
99
+ describe('opening a terminal', () => {
100
+ it('refuses when the app has not defined the gate - closed, not open', async () => {
101
+ const { manager, fakes } = sessions()
102
+ await expect(manager.open({ user: alice, hosts, hostKey: 'app', cols: 80, rows: 24 })).rejects.toThrow('not authorized on this application')
103
+ expect(fakes).toHaveLength(0)
104
+ })
105
+
106
+ it('refuses a host that only allows named commands', async () => {
107
+ const { manager } = sessions()
108
+ await expect(manager.open({ user: alice, hosts, hostKey: 'db', authorizer: allowAll, cols: 80, rows: 24 })).rejects.toThrow('does not allow terminal sessions')
109
+ })
110
+
111
+ it('asks the gate about the host, and honours a refusal', async () => {
112
+ const { manager } = sessions()
113
+ const asked: string[] = []
114
+ await expect(manager.open({
115
+ user: alice,
116
+ hosts,
117
+ hostKey: 'app',
118
+ authorizer: (_user, hostKey) => {
119
+ asked.push(hostKey)
120
+ return false
121
+ },
122
+ cols: 80,
123
+ rows: 24,
124
+ })).rejects.toThrow('not permitted to open a terminal on "app"')
125
+ expect(asked).toEqual(['app'])
126
+ })
127
+
128
+ it('refuses an unauthenticated request, an unknown host and an unpinned one', async () => {
129
+ const { manager } = sessions()
130
+ await expect(manager.open({ user: null, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })).rejects.toThrow('Authentication is required')
131
+ await expect(manager.open({ user: alice, hosts, hostKey: 'nope', authorizer: allowAll, cols: 80, rows: 24 })).rejects.toThrow('No configured host')
132
+ const unpinned = [{ ...shellHost, knownHosts: '' }]
133
+ await expect(manager.open({ user: alice, hosts: unpinned, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })).rejects.toThrow('no pinned host key')
134
+ })
135
+
136
+ it('records the session before the process starts, at the size asked for', async () => {
137
+ const { manager, fakes, audit } = sessions()
138
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 120, rows: 40 })
139
+ expect(id).toMatch(/^[0-9a-f]{32}$/)
140
+ expect(audit.log[0]).toBe('opened alice@example.com app')
141
+ expect(fakes[0]!.sizes).toEqual([[120, 40]])
142
+ })
143
+
144
+ it('caps the sessions one user may hold', async () => {
145
+ const { manager } = sessions({ maxSessionsPerUser: 1 })
146
+ await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
147
+ await expect(manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })).rejects.toThrow('already have 1 terminal sessions open')
148
+ // Another user is not affected.
149
+ await manager.open({ user: bob, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
150
+ })
151
+
152
+ it('refuses a size no terminal has', async () => {
153
+ const { manager } = sessions()
154
+ await expect(manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 0, rows: 24 })).rejects.toThrow('whole columns and rows')
155
+ await expect(manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: '80; rm', rows: 24 })).rejects.toThrow('whole columns and rows')
156
+ })
157
+ })
158
+
159
+ describe('a session belongs to whoever opened it', () => {
160
+ it('answers another user exactly as it answers an id that never existed', async () => {
161
+ const { manager, fakes } = sessions()
162
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
163
+
164
+ const missing = await manager.input('0'.repeat(32), bob, 'ls\r').catch((error: Error) => error.message)
165
+ const notTheirs = await manager.input(id, bob, 'ls\r').catch((error: Error) => error.message)
166
+ expect(notTheirs).toBe(missing)
167
+ expect(() => manager.watch(id, bob)).toThrow('No such terminal session')
168
+ expect(() => manager.resize(id, bob, 100, 30)).toThrow('No such terminal session')
169
+ await expect(manager.close(id, bob)).rejects.toThrow('No such terminal session')
170
+ expect(fakes[0]!.written).toEqual([])
171
+ })
172
+ })
173
+
174
+ describe('input, output and resize', () => {
175
+ it('writes input, resizes, and records each typed line', async () => {
176
+ const { manager, fakes, audit } = sessions()
177
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
178
+
179
+ await manager.input(id, alice, 'uptime\r')
180
+ await manager.input(id, alice, 'tail -f ')
181
+ await manager.input(id, alice, 'app.log\r')
182
+ manager.resize(id, alice, 132, 40)
183
+
184
+ expect(fakes[0]!.written.join('')).toBe('uptime\rtail -f app.log\r')
185
+ expect(fakes[0]!.sizes.at(-1)).toEqual([132, 40])
186
+ expect(audit.log.filter(line => line.startsWith('input'))).toEqual(['input uptime', 'input tail -f app.log'])
187
+ })
188
+
189
+ it('refuses an empty or oversized input', async () => {
190
+ const { manager } = sessions()
191
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
192
+ await expect(manager.input(id, alice, '')).rejects.toThrow('non-empty string')
193
+ await expect(manager.input(id, alice, 'x'.repeat(64 * 1024 + 1))).rejects.toThrow('at most')
194
+ })
195
+
196
+ it('replays what a reconnecting viewer missed, and nothing it already saw', async () => {
197
+ const { manager, fakes } = sessions()
198
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
199
+ fakes[0]!.emit('one ')
200
+ fakes[0]!.emit('two ')
201
+ fakes[0]!.emit('three')
202
+
203
+ expect((await take(manager.watch(id, alice), 3)).map(text)).toEqual(['one ', 'two ', 'three'])
204
+ const resumed = await take(manager.watch(id, alice, 2), 1)
205
+ expect(resumed.map(text)).toEqual(['three'])
206
+ })
207
+
208
+ it('keeps the replay buffer bounded', async () => {
209
+ const { manager, fakes } = sessions({ replayBytes: 10 })
210
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
211
+ for (const chunk of ['aaaa', 'bbbb', 'cccc', 'dddd'])
212
+ fakes[0]!.emit(chunk)
213
+ expect((await take(manager.watch(id, alice), 2)).map(text)).toEqual(['cccc', 'dddd'])
214
+ })
215
+
216
+ it('delivers live output to a watching viewer, then the end', async () => {
217
+ const { manager, fakes } = sessions()
218
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
219
+ const watching = take(manager.watch(id, alice), 3)
220
+ fakes[0]!.emit('$ ')
221
+ fakes[0]!.emit('bye\r\n')
222
+ fakes[0]!.exit(0)
223
+ expect((await watching).map(text)).toEqual(['$ ', 'bye\r\n', '<exited>'])
224
+ })
225
+ })
226
+
227
+ describe('a session does not outlive its purpose', () => {
228
+ it('ends after the idle timeout, kills the shell, and says why', async () => {
229
+ const { manager, fakes, audit } = sessions({ idleTimeoutMs: 20, detachGraceMs: 10_000 })
230
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
231
+ const watching = take(manager.watch(id, alice), 1)
232
+ expect((await watching).map(text)).toEqual(['<idle>'])
233
+ expect(fakes[0]!.killed).toBe(true)
234
+ expect(audit.log.at(-1)).toStartWith('closed idle')
235
+ expect(manager.size).toBe(0)
236
+ })
237
+
238
+ it('input keeps it alive; the max duration ends it regardless', async () => {
239
+ const { manager, fakes, audit } = sessions({ idleTimeoutMs: 40, maxDurationMs: 120, detachGraceMs: 10_000 })
240
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
241
+ for (let i = 0; i < 4; i++) {
242
+ await Bun.sleep(25)
243
+ await manager.input(id, alice, 'x')
244
+ }
245
+ expect(fakes[0]!.killed).toBe(false)
246
+ await Bun.sleep(60)
247
+ expect(fakes[0]!.killed).toBe(true)
248
+ expect(audit.log.at(-1)).toStartWith('closed max-duration')
249
+ })
250
+
251
+ it('ends when nobody attaches, and a grace period after the last viewer leaves', async () => {
252
+ const { manager, fakes, audit } = sessions({ detachGraceMs: 30 })
253
+ await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
254
+ await Bun.sleep(60)
255
+ expect(fakes[0]!.killed).toBe(true)
256
+ expect(audit.log.at(-1)).toStartWith('closed detached')
257
+
258
+ const second = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
259
+ const viewer = manager.watch(second.id, alice)[Symbol.asyncIterator]()
260
+ await Bun.sleep(60)
261
+ expect(fakes[1]!.killed).toBe(false)
262
+ await viewer.return!()
263
+ await Bun.sleep(60)
264
+ expect(fakes[1]!.killed).toBe(true)
265
+ })
266
+
267
+ it('records the partial line still pending when the session closes, and closes once', async () => {
268
+ const { manager, audit } = sessions()
269
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
270
+ await manager.input(id, alice, 'rm -rf /tmp/build')
271
+ await manager.close(id, alice)
272
+ await expect(manager.close(id, alice)).rejects.toThrow('No such terminal session')
273
+ expect(audit.log.slice(1)).toEqual(['input rm -rf /tmp/build', 'closed closed in=17 out=0'])
274
+ })
275
+ })
276
+
277
+ describe('the event stream', () => {
278
+ it('sends output as base64 with its sequence number, so split characters survive', () => {
279
+ // "é" is two bytes; a PTY may deliver them in different chunks.
280
+ const firstHalf = new Uint8Array([0xC3])
281
+ expect(encodeTerminalEvent({ type: 'output', seq: 7, data: firstHalf })).toBe('id: 7\nevent: output\ndata: ww==\n\n')
282
+ expect(encodeTerminalEvent({ type: 'exit', reason: 'idle', exitCode: null })).toBe('event: exit\ndata: {"reason":"idle","exitCode":null}\n\n')
283
+ })
284
+
285
+ it('stops watching when the browser goes away', async () => {
286
+ const { manager, fakes } = sessions({ detachGraceMs: 20 })
287
+ const { id } = await manager.open({ user: alice, hosts, hostKey: 'app', authorizer: allowAll, cols: 80, rows: 24 })
288
+ const body = terminalEventStream(manager.watch(id, alice), 1_000)
289
+ const reader = body.getReader()
290
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe(': connected\n\n')
291
+ await reader.cancel()
292
+ await Bun.sleep(50)
293
+ expect(fakes[0]!.killed).toBe(true)
294
+ })
295
+ })
296
+
297
+ describe('the PTY spawner', () => {
298
+ it('runs ssh with a forced remote PTY and the pinned-key options before the destination', () => {
299
+ const argv = sshTerminalArgv({ ...shellHost, port: 2222 }, '/tmp/kh')
300
+ expect(argv.slice(-4)).toEqual(['-tt', '-o', 'ServerAliveInterval=30', 'deploy@app.example.com'])
301
+ expect(argv).toContain('StrictHostKeyChecking=yes')
302
+ expect(argv).toContain('UserKnownHostsFile=/tmp/kh')
303
+ expect(argv).not.toContain('--')
304
+ })
305
+
306
+ it('carries output, input and a window resize through a real PTY, and cleans up', async () => {
307
+ const before = readdirSync(tmpdir()).filter(name => name.startsWith('stacks-terminal-')).length
308
+ let knownHosts = ''
309
+ const spawn = createPtySpawner((_host, path) => {
310
+ knownHosts = path
311
+ return ['sh', '-c', 'stty size; read line; stty size; echo "got:$line"']
312
+ })
313
+
314
+ let output = ''
315
+ const process = await spawn(shellHost, { cols: 100, rows: 30 }, (bytes) => { output += new TextDecoder().decode(bytes) })
316
+ expect(existsSync(knownHosts)).toBe(true)
317
+
318
+ await Bun.sleep(150)
319
+ process.resize(132, 40)
320
+ process.write('hello\r')
321
+ expect(await process.exited).toBe(0)
322
+ await Bun.sleep(20)
323
+
324
+ expect(output).toContain('30 100')
325
+ expect(output).toContain('40 132')
326
+ expect(output).toContain('got:hello')
327
+ expect(existsSync(knownHosts)).toBe(false)
328
+ expect(readdirSync(tmpdir()).filter(name => name.startsWith('stacks-terminal-')).length).toBe(before)
329
+ })
330
+ })
331
+
332
+ /**
333
+ * End to end through a real sshd: the production spawner, ssh with the pinned
334
+ * key, a remote PTY. The test starts its own sshd on a loopback port with a
335
+ * throwaway host key and authorized key, so it needs no system SSH server and
336
+ * changes nothing outside a temp directory. Skipped where there is no sshd.
337
+ */
338
+ const SSHD = ['/usr/sbin/sshd', '/usr/bin/sshd'].find(path => existsSync(path))
339
+
340
+ describe.if(Boolean(SSHD))('a terminal session over real SSH', () => {
341
+ async function startSshd() {
342
+ const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import('node:fs')
343
+ const { join } = await import('node:path')
344
+ const { userInfo } = await import('node:os')
345
+ const dir = mkdtempSync(join(tmpdir(), 'stacks-sshd-'))
346
+ for (const name of ['host_key', 'client_key'])
347
+ Bun.spawnSync(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', join(dir, name)])
348
+ writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'client_key.pub')), { mode: 0o600 })
349
+ const port = 20000 + Math.floor(Math.random() * 20000)
350
+ writeFileSync(join(dir, 'sshd_config'), [
351
+ `Port ${port}`,
352
+ 'ListenAddress 127.0.0.1',
353
+ `HostKey ${join(dir, 'host_key')}`,
354
+ `AuthorizedKeysFile ${join(dir, 'authorized_keys')}`,
355
+ `PidFile ${join(dir, 'sshd.pid')}`,
356
+ 'UsePAM no',
357
+ 'StrictModes no',
358
+ 'PasswordAuthentication no',
359
+ 'KbdInteractiveAuthentication no',
360
+ 'LogLevel ERROR',
361
+ // The same shell whoever runs this, rather than their login shell -
362
+ // some report 0 for `exit 7`, which would make the exit status untestable.
363
+ 'ForceCommand /bin/sh -i',
364
+ ].join('\n'))
365
+ const daemon = Bun.spawn([SSHD!, '-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')])
366
+ for (let i = 0; i < 50; i++) {
367
+ if (Bun.spawnSync(['nc', '-z', '127.0.0.1', String(port)]).exitCode === 0)
368
+ break
369
+ await Bun.sleep(100)
370
+ }
371
+ const hostKey = readFileSync(join(dir, 'host_key.pub'), 'utf8').split(' ').slice(0, 2).join(' ')
372
+ const host: RemoteHost = {
373
+ key: 'local',
374
+ host: '127.0.0.1',
375
+ port,
376
+ user: userInfo().username,
377
+ identityFile: join(dir, 'client_key'),
378
+ knownHosts: `[127.0.0.1]:${port} ${hostKey}`,
379
+ terminal: true,
380
+ }
381
+ return {
382
+ host,
383
+ stop: () => {
384
+ daemon.kill()
385
+ rmSync(dir, { force: true, recursive: true })
386
+ },
387
+ }
388
+ }
389
+
390
+ async function readUntil(events: AsyncIterator<TerminalEvent>, pattern: RegExp, sink: { text: string }): Promise<TerminalEvent | undefined> {
391
+ const deadline = Date.now() + 10_000
392
+ while (Date.now() < deadline) {
393
+ const next = await Promise.race([events.next(), Bun.sleep(deadline - Date.now()).then(() => null)])
394
+ if (!next || next.done)
395
+ return undefined
396
+ if (next.value.type === 'exit')
397
+ return next.value
398
+ sink.text += new TextDecoder().decode(next.value.data)
399
+ if (pattern.test(sink.text))
400
+ return next.value
401
+ }
402
+ return undefined
403
+ }
404
+
405
+ it('runs a shell on the host, resizes it, and records what was typed', async () => {
406
+ const sshd = await startSshd()
407
+ const audit = recordingAudit()
408
+ const manager = new TerminalSessions({ spawn: createPtySpawner(), audit })
409
+ try {
410
+ const { id } = await manager.open({ user: alice, hosts: [sshd.host], hostKey: 'local', authorizer: allowAll, cols: 90, rows: 25 })
411
+ const events = manager.watch(id, alice)[Symbol.asyncIterator]()
412
+ const screen = { text: '' }
413
+
414
+ await manager.input(id, alice, 'echo answer=$((6*7)); stty size\r')
415
+ await readUntil(events, /answer=42[\s\S]*25 90/, screen)
416
+ expect(screen.text).toContain('answer=42')
417
+ expect(screen.text).toMatch(/25 90/)
418
+
419
+ manager.resize(id, alice, 120, 33)
420
+ await Bun.sleep(200)
421
+ await manager.input(id, alice, 'stty size; exit 7\r')
422
+ // Reads to the end: no pattern matches, so this returns the exit event.
423
+ const end = await readUntil(events, /(?!)/, screen)
424
+ expect(screen.text).toMatch(/33 120/)
425
+ expect(end).toEqual({ type: 'exit', reason: 'exited', exitCode: 7 })
426
+
427
+ for (let i = 0; i < 50 && manager.size > 0; i++)
428
+ await Bun.sleep(100)
429
+ expect(manager.size).toBe(0)
430
+ expect(audit.log).toContain('input echo answer=$((6*7)); stty size')
431
+ expect(audit.log.at(-1)).toMatch(/^closed exited/)
432
+ }
433
+ finally {
434
+ await manager.closeAll()
435
+ sshd.stop()
436
+ }
437
+ }, 30_000)
438
+
439
+ it('refuses a host whose key does not match the pinned one', async () => {
440
+ const { rmSync } = await import('node:fs')
441
+ const sshd = await startSshd()
442
+ const manager = new TerminalSessions({ spawn: createPtySpawner(), audit: recordingAudit() })
443
+ const forgedPath = `${tmpdir()}/stacks-forged-${process.pid}`
444
+ try {
445
+ // A key the server does not have, pinned as if it were the host's: the
446
+ // man-in-the-middle case, from the client's side.
447
+ Bun.spawnSync(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', forgedPath])
448
+ const forgedKey = (await Bun.file(`${forgedPath}.pub`).text()).split(' ').slice(0, 2).join(' ')
449
+ const pinnedWrong = { ...sshd.host, knownHosts: `[127.0.0.1]:${sshd.host.port} ${forgedKey}` }
450
+
451
+ const { id } = await manager.open({ user: alice, hosts: [pinnedWrong], hostKey: 'local', authorizer: allowAll, cols: 80, rows: 24 })
452
+ const screen = { text: '' }
453
+ await readUntil(manager.watch(id, alice)[Symbol.asyncIterator](), /Host key verification failed/, screen)
454
+ expect(screen.text).toContain('Host key verification failed')
455
+ }
456
+ finally {
457
+ await manager.closeAll()
458
+ sshd.stop()
459
+ rmSync(forgedPath, { force: true })
460
+ rmSync(`${forgedPath}.pub`, { force: true })
461
+ }
462
+ }, 30_000)
463
+ })