-
Notifications
You must be signed in to change notification settings - Fork 292
Expand file tree
/
Copy pathindex.ts
More file actions
1360 lines (1329 loc) · 64.3 KB
/
Copy pathindex.ts
File metadata and controls
1360 lines (1329 loc) · 64.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
* and the terminal WebSocket upgrade. Every route passes the same
* browser-trust fence as the /api gateway — Host-header loopback or the
* web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
* `--trusted-host` authorities), read per request from the live service
* value so the fence tracks the same trust source the /api gateway derives
* its list from.
*
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
* processes are keyed by session.
*/
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context, SidebarHttpRequest, SidebarSessionEvent } from './context-types.ts'
import {
Config,
PrefsSchema,
resolveSidebarConfig,
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
type SidebarPrefs,
} from './config.ts'
import { parentOf, requireAbsolute, listDirectoryWith, rootLabel } from './fs-tree.ts'
import { resolveSessionPath } from './session-path.ts'
import { writeWorkspaceUpload } from './fs-operations.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import { launchExternal } from './open-external.ts'
import * as git from './git.ts'
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
depsStatus,
loadNodePty,
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
export type { SidebarConfig, ResolvedSidebarConfig }
// Re-export the Context augmentation (`declare module '@deepseek-ai/cordis'`)
// so consumers `import type {} from 'dsh-better-sidebar'` and gain
// `ctx.betterSidebar`; the Context re-export below is the vendored cordis
// Context intersected with the structural service faces.
// Also re-export the service descriptor types so consumers can type their
// registerTab / registerFileViewer arguments without reaching into /client.
export type { Context } from './context-types.ts'
export type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from './client/service.ts'
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, session store, routed filesystem, trust source, and tool registry. */
export const inject = ['webServer', 'sessions', 'fs', 'webRuntime', 'tools']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.avif': 'image/avif',
'.pdf': 'application/pdf',
'.html': 'text/html',
'.htm': 'text/html',
}
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
export function mediaTypeForPath(path: string): string {
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
}
/**
* Resolve a session's authoritative working directory. The attached session
* header wins; while the session is still hydrating from persistence (the
* web client attaches the current conversation a moment after page load, so
* the very first sidebar requests can arrive detached) the caller's own
* list-summary cwd is used; the session-persistence index is queried as a
* last resort for cold (not-yet-attached) sessions so a detached first
* request still resolves the correct project instead of the host process
* cwd (which on Windows is the DSH source root after `dsh.cmd`'s `pushd`,
* causing every user-project path to be misclassified as "outside
* workspace"). The host process cwd is the FINAL fallback for deployments
* without persistence (tests / stripped-down hosts); production always
* provides persistence, so the bug-fix path (header → client → persistence)
* always resolves the real session cwd before reaching it.
*/
async function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): Promise<string> {
const session = ctx.sessions.get(sessionId)
const headerCwd = session?.header.cwd
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
if (clientCwd !== undefined && clientCwd !== '') {
try {
return requireAbsolute(clientCwd)
} catch {
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
}
}
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const inspected = await persistence.inspect(sessionId)
const metaCwd = inspected.meta.cwd
if (metaCwd !== undefined && metaCwd !== '') {
try {
return requireAbsolute(metaCwd)
} catch {
throw new SidebarError('bad-request', `invalid working directory "${metaCwd}"`)
}
}
}
return process.cwd()
}
/** Optional repository selected by the Git panel when cwd is a container. */
function selectedRepoOf(payload: unknown): string | undefined {
const record = payload as { repoRoot?: unknown }
if (record.repoRoot === undefined) return undefined
return requireAbsolute(requireString(payload, 'repoRoot'))
}
/**
* Resolve a path that a git command reported — `git status`/`git diff`
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
* session cwd (a session inside a subdirectory of a repository). Absolute
* paths pass through; relative ones join the repo root (falling back to the
* cwd when the root cannot be resolved, e.g. a bare directory).
*/
async function resolveGitPath(cwd: string, raw: string, selected?: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(resolveSessionPath(cwd, raw))
// Prefer the session-relative interpretation when it names an existing
// path. Git status reports repository-root-relative names, but the sidebar
// security boundary is the session workspace; this preference keeps files
// inside a nested session readable without reopening the repository root.
const sessionPath = requireAbsolute(join(cwd, raw))
if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath
const root = await git.repoRoot(cwd, selected).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
/** How many leading bytes a binary read returns for client-side detect sniffing. */
const READ_HEAD_LIMIT = 4096
/** Text read of a file with the size cap; binary detection via NUL probe.
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
* so the client can re-match viewers by content (`detect`). */
async function readText(path: string, readLimit: number): Promise<{
content: string
truncated: boolean
binary: boolean
size: number
head?: string
}> {
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
if (info.isDirectory()) {
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
}
const size = info.size
const truncated = size > readLimit
const handle = await open(path, 'r').catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
try {
const buffer = Buffer.alloc(Math.min(size, readLimit))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
const head = binary
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
: undefined
return {
content: binary ? '' : slice.toString('utf8'),
truncated,
binary,
size,
head,
}
} finally {
await handle.close()
}
}
/** One API method dispatch table entry. */
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
/**
* The live face of the side card settings namespace, bound to the settings
* service when it is mounted. The DSH settings RPC domain only serves
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
* which call the seam in-process — no configuration-client gate involved.
*/
export interface SidebarSettingsFace {
/** The current resolved value + revision (undefined while the settings service is absent). */
get(): { value?: unknown; revision?: number }
/**
* Whether the dsh-web-ui family's aionui-panel has been selected as the
* right-panel provider (the `aionui-panel` settings namespace resolves
* `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
* the two right panels are mutually exclusive. False when the namespace is
* absent (no aionui installed) or the provider is anything else.
*/
externalDisable(): boolean
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
/**
* Resolve the settings-page terminal shell overrides (the terminal card's
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
* `shellArgs` (or the platform auto-resolution). The settings page is the
* runtime complement to the boot-time yaml — same contract, later binding:
* the values here win for terminals opened afterwards.
*/
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
const settings = getSettings()
const value = settings?.get().value
if (value === null || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
return {
shell: shell === '' ? undefined : shell,
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
}
}
/**
* Whether the workspace fence is armed for the sidebar's filesystem routes
* (the settings-page `workspaceFence` switch under the files card's gear).
* An absent settings service or a missing field keeps the fence ON — the
* containment default never depends on the settings surface being reachable.
*/
function fenceEnabledOf(getSettings: () => SidebarSettingsFace | undefined): boolean {
const settings = getSettings()
const value = settings?.get().value
if (value === null || typeof value !== 'object') return true
return (value as Record<string, unknown>).workspaceFence !== false
}
/**
* Parse the browser tab's `browserAllowedLoopback` allowlist into a matcher
* over host:port (same contract as the client-side helper in
* src/client/browser.ts — kept in sync). Bare hosts (`localhost`,
* `127.0.0.1`) match every port; `host:port` entries match exactly.
*/
function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean {
const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
const exact = new Set(entries)
const hosts = new Set<string>()
for (const entry of entries) {
if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, ''))
}
return (host, port) => {
const key = `${host}:${port}`
if (exact.has(key) || exact.has(host)) return true
return port !== '' && hosts.has(host)
}
}
function buildApi(
ctx: Context,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig,
terminalShell: string,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
const sessionId = requireString(payload, 'sessionId')
const record = payload as { cwd?: unknown } | null
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: await sessionCwdOf(ctx, sessionId, clientCwd) }
}
/** Resolve the optional Git-panel checkout selector against the authoritative
* session repository. Unlike `cwd`, `worktree` is never trusted directly. */
const gitCwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
const base = await cwdOf(payload)
const record = payload as { worktree?: unknown } | null
const requested = typeof record?.worktree === 'string' && record.worktree !== '' ? record.worktree : undefined
return { sessionId: base.sessionId, cwd: await git.resolveWorktree(base.cwd, requested) }
}
// Background jobs: the LIST rides the harness's `session/jobs` push
// mirror, so these routes only replay output the model has read (from the
// session's own event log — no DSH source is touched, the model's
// job_output cursor is never consumed) and kill (the registry's stock
// API). A deployment without the jobs registry downgrades kill to a 503.
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
// Subagent live previews: one batch request instead of N per-child
// `subagents.history` calls. The route degrades to a 503 when the host
// subagent runtime is absent (the page has no topology to show anyway).
const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx)
return {
'session.cwd': async (payload) => {
const { sessionId, cwd } = await cwdOf(payload)
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
},
'fs.tree': async (payload) => {
const { cwd } = await cwdOf(payload)
const record = payload as { path?: unknown }
const requested = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
try {
const target = await ctx.fs.resolve(requested)
if (fenceEnabledOf(getSettings)) {
const workspaceTarget = await ctx.fs.resolve(cwd)
if (!ctx.fs.contains(workspaceTarget, target)) {
throw new SidebarError('forbidden', `path "${requested}" is outside workspace`, 403)
}
}
return listDirectoryWith(ctx.fs, requested, resolved.listLimit, target)
} catch (error) {
if (error instanceof SidebarError) throw error
throw new SidebarError('fs-error', `cannot list "${requested}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
},
'fs.search': async (payload) => {
// The editor side panel's global name search: rooted at the session
// cwd (not caller-targetable — the walk is unbounded by design and
// must never escape the workspace), budgeted inside searchFiles.
const { cwd } = await cwdOf(payload)
const query = requireString(payload, 'query')
return searchFiles(cwd, query)
},
'fs.read': async (payload) => {
const { cwd } = await cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route). A
// child-repo path is relative to the selected repoRoot, not the session
// cwd; thread it so the path resolves inside the authorized workspace.
const selected = selectedRepoOf(payload)
const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected), fenceEnabledOf(getSettings))
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated, head }
return { kind: 'text', content, truncated }
},
'fs.write': async (payload) => {
const { cwd } = await cwdOf(payload)
const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings))
const content = requireString(payload, 'content')
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
await writeFile(tmp, content, 'utf8')
await rename(tmp, path)
} catch (error) {
await rm(tmp, { force: true }).catch(() => {})
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
return { ok: true }
},
'git.worktrees': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const selected = selectedRepoOf(payload)
// A workspace container (no repo at cwd) has child repos; the worktree
// list belongs to the SELECTED child, not the container. Thread the
// validated repoRoot so linked checkouts of a chosen child appear.
const base = selected !== undefined ? await git.repoRoot(cwd, selected).catch(() => cwd) : cwd
return git.worktrees(base)
},
'git.status': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return git.status(cwd, selectedRepoOf(payload))
},
'git.diff': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const repoRoot = selectedRepoOf(payload)
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
return { diff: await git.diff(cwd, path, record.staged === true, repoRoot) }
},
'git.stage': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.stage(cwd, path, selectedRepoOf(payload))
return { ok: true }
},
'git.unstage': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.unstage(cwd, path, selectedRepoOf(payload))
return { ok: true }
},
'git.commit': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const message = requireString(payload, 'message')
await git.commit(cwd, message, selectedRepoOf(payload))
return { ok: true }
},
'git.branch': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return git.branches(cwd, selectedRepoOf(payload))
},
'git.checkout': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'), selectedRepoOf(payload))
return { ok: true }
},
'git.log': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const record = payload as { count?: unknown; skip?: unknown }
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
? record.count
: undefined
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
? record.skip
: undefined
return git.log(cwd, count, skip, selectedRepoOf(payload))
},
'git.commit-diff': async (payload) => {
const { cwd } = await gitCwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash'), selectedRepoOf(payload)) }
},
'git.discard': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const repoRoot = selectedRepoOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot), repoRoot)
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = await gitCwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const repoRoot = selectedRepoOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path, repoRoot) }
},
// The session's file-tool events for the changes tab's session lens
// (and its badge): the CLIENT runtime's sessions face has no event-log
// access, so the events cross the wire here — live session log first,
// the persisted logical log for not-yet-hydrated sessions. Only the
// two event types the lens folds are sent, narrowed to `seq > afterSeq`
// so polling is a small delta, with the same recent-window cap the
// client accumulator applies.
'changes.ops': async (payload) => {
const sessionId = requireString(payload, 'sessionId')
const rawAfter = (payload as { afterSeq?: unknown } | null)?.afterSeq
if (rawAfter !== undefined
&& (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) {
throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer')
}
// An absent cursor means "from the very first event" — a session whose
// log opens on a tool event (subagent seeds do) carries seq 0, which a
// literal `> 0` comparison would drop, so the absent case floors at -1.
const afterSeq = rawAfter ?? -1
let events: readonly SidebarSessionEvent[] | undefined = ctx.sessions.get(sessionId)?.snapshotEvents()
if (events === undefined) {
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
try {
events = (await persistence.inspect(sessionId)).events
} catch {
// Cold read unavailable (session never persisted): an empty
// window is the honest answer, not a wire error.
}
}
}
if (events === undefined) return { events: [], lastSeq: Math.max(afterSeq, 0) }
const CHANGES_EVENTS_CAP = 4000
const filtered = events.filter(
event => (event.type === 'tool/call' || event.type === 'tool/result') && event.seq > afterSeq,
)
const window = filtered.length > CHANGES_EVENTS_CAP ? filtered.slice(filtered.length - CHANGES_EVENTS_CAP) : filtered
return { events: window, lastSeq: window.at(-1)?.seq ?? afterSeq }
},
// Release a terminal immediately. The WebSocket close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop), so a closed tab can
// never hold the per-session quota until the reconnect grace expires.
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
// Degraded mode (node-pty unavailable): no live pty can exist, so a
// no-op ok is the honest answer — never an error the client must show.
ptyManager?.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop) so a closed agent
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry?.close(uuid)
return { ok: true }
},
// Terminal dependency status (issue #140): after a WS close 1011 with
// reason `pty-deps-missing` the client fetches the full repair details
// here — the close reason itself is capped at 123 bytes, too small for
// the pasteable command.
'terminal.deps': () => depsStatus(),
// Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's
// job_output cursor is never touched, so the human pane can never steal
// the agent's bytes), and kill one job. The job LIST itself arrives
// through the harness's session/jobs push mirror, so no list route
// exists. Kill is fenced to the owning session by the jobs registry.
'jobs.output': (payload) => jobsApi.output(payload),
'jobs.kill': (payload) => jobsApi.kill(payload),
// Subagent live previews: one batch request per refresh; the route folds
// the newest text/tool activity of every running child in the tree.
'subagents.live': (payload) => subagentLiveApi.live(payload),
// The effective terminal shell and its display name. The client uses
// this to title terminal tabs with the shell name instead of a numbered
// "Terminal N" label; the shell itself is configured through
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
// is refused with settings-conflict so a concurrent change is never
// silently overwritten (mirror of the settings seam's own guard).
'settings.get': () => {
const settings = getSettings()
return settings === undefined
? { value: undefined, revision: undefined, externalDisable: false }
: { ...settings.get(), externalDisable: settings.externalDisable() }
},
'settings.update': async (payload) => {
const settings = getSettings()
if (settings === undefined) {
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
}
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
const patch = record?.patch
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
throw new SidebarError('bad-request', 'patch must be a plain object')
}
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
try {
return await settings.update(patch as Record<string, unknown>, expectedRevision)
} catch (error) {
if (error instanceof SettingsConflictError) {
throw new SidebarError('settings-conflict', error.message, 409)
}
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
}
},
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
// signals the browser enforces when it refuses to embed a site. The
// probe is display-only (headers back to the caller), restricted to
// http(s) non-loopback URLs with a hard timeout, and gated by the same
// trust fence as every other route — a cross-site page cannot reach it.
'browser.probe': async (payload) => {
const raw = requireString(payload, 'url')
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new SidebarError('bad-request', 'invalid url', 400)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
}
// Mirror the browser tab's address-bar policy: loopback stays unreachable
// from the sidebar (unless the user allowlisted it), so probing it would
// leak nothing the tab could use.
if (isLoopbackHostname(parsed.hostname)) {
const prefs = getSettings()?.get()?.value as SidebarPrefs | undefined
const allowlist = typeof prefs?.browserAllowedLoopback === 'string' ? prefs.browserAllowedLoopback : ''
const allowed = allowlist.trim() !== ''
&& parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port)
if (!allowed) {
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
}
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
// Some servers answer HEAD with 405/501; retry once as GET (the
// body is discarded — only the headers matter).
let retriedFromHeadRejection = false
if (response.status === 405 || response.status === 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
retriedFromHeadRejection = true
}
// Some servers (e.g. aliyun consoles) answer HEAD without the
// X-Frame-Options / CSP headers that only their GET response
// carries. Without those signals the embeddability check below
// would wrongly report the site as embeddable and the plain iframe
// would surface the browser's misleading "refused to connect".
// Retry once as GET when both signals are absent (body discarded).
// A 405/501 retry already fetched the GET response, so the signals
// are either there or genuinely absent — another GET adds nothing.
const hasEmbedSignals = response.headers.get('content-security-policy') !== null
|| response.headers.get('x-frame-options') !== null
if (!hasEmbedSignals && !retriedFromHeadRejection && response.status !== 405 && response.status !== 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
}
const csp = response.headers.get('content-security-policy')
const frameAncestors = extractFrameAncestors(csp)
const xFrameOptions = response.headers.get('x-frame-options')
// The GET fallbacks stream a real body that nothing reads; "body
// discarded" is not automatic with fetch, so cancel it explicitly to
// release the socket (a large/streaming response would otherwise stay
// pinned after the timer clears).
void response.body?.cancel()
return {
reachable: true,
url: response.url,
status: response.status,
...(xFrameOptions !== null ? { xFrameOptions } : {}),
...(frameAncestors !== undefined ? { frameAncestors } : {}),
}
} catch {
// DNS / TLS / connection / timeout: nothing to judge — the client
// keeps the plain iframe.
return { reachable: false }
} finally {
clearTimeout(timer)
}
},
// External open for the file tree's "open with" menu: reveal a path in
// the OS file manager, or hand a custom-scheme URL (vscode://,
// cursor://, zed://, custom editors) to its registered handler. The
// client is a browser renderer where raw scheme navigation is
// unreliable, so the launch always goes through the host — the same
// fence as every other route, argv-only (no shell interpolation).
'open.external': (payload) => {
const record = payload as { action?: unknown } | null
const action = record?.action
if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
},
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
// Every operation runs through these routes because subagent-origin
// identities are fenced from the generic session RPCs (agent-lookup
// ownership), and the thread is created with a CUSTOM seed the stock
// fork APIs cannot express.
...buildSidechatApi(ctx),
}
}
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (webServer, sessions, webRuntime).
* @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}.
*/
export function apply(ctx: Context, config?: SidebarConfig): void {
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
// configured shell fixes one surface and silently leaves the other on the
// platform default.
const terminalShell = defaultShell({ explicit: resolved.shell })
// The web runtime's bind-derived trust list (boot-sampled LAN literals
// plus --trusted-host authorities) — the authoritative source the /api
// gateway fence derives its list from. Read per request from the live
// service value; a replaced list takes effect without a plugin restart.
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
// node-pty is loaded lazily, never at module top level (issue #140): a
// missing or broken install must degrade THIS plugin — terminal tab shows
// a repair command, agent terminal tools stay unregistered — instead of
// failing the plugin load and taking the whole `dsh web` server down.
const nodePty = loadNodePty()
if (nodePty === null) {
const status = depsStatus()
const detail = status.ok
? 'unknown cause'
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null
? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty)
: null
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = nodePty !== null
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
: null
// The model-facing open-request registry: queues `sidebar_open` requests
// per session and pushes them to connected sidebar views over the
// `/sidebar/ws/agent-opens` socket. Unlike the pty registry it has no
// native dependencies — the tool works even in node-pty degraded mode.
const agentOpenRegistry = new AgentOpenRegistry()
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
// (client half) can render and persist the new-conversation defaults. The
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
// configuration clients, so the client reaches this namespace through the
// plugin's own fenced routes below ('settings.get'/'settings.update'),
// which call the seam in-process. Deployments without a settings service
// simply never fill the face and the client falls back to the defaults.
let settingsFace: SidebarSettingsFace | undefined
// The model-facing terminal tools are gated on the side-card setting
// `agentTerminalTools` (default off): nothing is injected until the user
// turns the feature on, and turning it off mid-session unregisters the
// tools and releases the agent terminals they created.
let toolsDisposers: (() => void) | null = null
// The model-facing `sidebar_open` tool is gated the same way (see
// syncOpenToolsGate below); separate disposer (no native deps, and turning
// the feature off must not release user terminals).
let openToolsDisposers: (() => void) | null = null
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
// Degraded mode (node-pty unavailable): never register the terminal
// tools — every one of them would fail at spawn time.
if (agentPtyRegistry === null) return
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace))
}
} else if (toolsDisposers !== null) {
toolsDisposers()
toolsDisposers = null
// The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry?.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
// DSH 0.1.2-alpha.2 validates namespaces at compile time
// (SettingsNamespaceInput); the 'dsh-better-sidebar' literal passes, so the
// runtime helper this used to call (settingsNamespace) is gone upstream.
const ns = SIDEBAR_PREFS_NS
// The structural settings mirror types `schema` as unknown, so the
// generic is not inferred here; the real service resolves it from the
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
const scope = sctx.settings.register(ns, PrefsSchema) as {
get(): SidebarPrefs
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
}
const viewOf = (): { value?: unknown; revision?: number } => {
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
return descriptor === undefined
? { value: undefined, revision: undefined }
: { value: descriptor.value, revision: descriptor.revision }
}
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
// While it resolves to 'aionui-panel', this sidebar must not mount. The
// namespace is read through the settings seam like any other registered
// section; absent namespace (no aionui installed) = not disabled.
const externalDisable = (): boolean => {
const descriptor = sctx.settings.describe({ redactSecrets: true })
.find(candidate => candidate.ns === 'aionui-panel')
const value = descriptor?.value as { rightPanel?: unknown } | undefined
return value?.rightPanel === 'aionui-panel'
}
settingsFace = {
get: viewOf,
externalDisable,
update: async (patch, expectedRevision) => {
await sctx.settings.update(ns, patch, expectedRevision)
return viewOf()
},
}
// Register (or unregister) the terminal tools from the current setting,
// and keep them in sync with every settings commit.
syncToolsGate(scope)
// The model-facing open tool is gated the same way on `agentOpenTools`
// (default off): nothing is injected until the user turns the feature
// on, and turning it off mid-session unregisters the tool and drops the
// queued (undelivered) open requests. Already-delivered opens keep their
// tabs — the tools' only lever is the queue, not the rendered state.
const syncOpenToolsGate = (): void => {
if (scope.get().agentOpenTools) {
if (openToolsDisposers === null) {
openToolsDisposers = registerOpenTool(
ctx,
agentOpenRegistry,
(sessionId) => sessionCwdOf(ctx, sessionId),
() => {
const view = settingsFace?.get()
const value = view?.value
return value !== null && typeof value === 'object'
? value as SidebarPrefs
: SIDEBAR_PREFS_DEFAULTS
},
)
}
} else if (openToolsDisposers !== null) {
openToolsDisposers()
openToolsDisposers = null
agentOpenRegistry.drainAll()
}
}
syncOpenToolsGate()
// ONE watch subscription drives both gates: settings commits re-evaluate
// the terminal tools AND the open tool together (each gate is idempotent
// and owns its own disposer).
scope.watch(() => { syncToolsGate(scope); syncOpenToolsGate() })
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
handler: async (req, res) => {
if (!fence(req)) {
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
return
}
if (req.method !== 'POST') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
if (method === undefined || method.includes('/')) {
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
return
}
try {
const payload = await readJsonBody(req)
const handler = api[method]
if (handler === undefined) {
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
}
writeOk(res, await handler(payload))
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Raw upload route ───────────────────────────────────────────────────
// One request writes one file without JSON/base64 inflation. Folder uploads
// send each file with a relativePath, preserving the selected directory
// tree. Bytes stream to a temp sibling and are renamed into place, so a
// failed or oversized upload never leaves a partial file (see
// fs-operations.ts for the containment and shape rules).
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: '/sidebar/upload',
handler: async (req, res) => {
if (!fence(req)) {
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
return
}
if (req.method !== 'POST') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const dir = url.searchParams.get('dir')
const relativePath = url.searchParams.get('relativePath')
if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === '') {
throw new SidebarError('bad-request', 'sessionId, dir, and relativePath are required')
}
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const { path, size } = await writeWorkspaceUpload({
cwd,
dir,
relativePath,
chunks: req,
limit: resolved.uploadLimit,
fence: fenceEnabledOf(() => settingsFace),
})
writeOk(res, { path, size })
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/upload route')
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
// Serves the client half's split bundles (lib/client-<name>.js) so the
// heavy preview/terminal libraries load on first use, not at page start
// (see bundle-route.ts / src/client/chunk-loader.ts).
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/file',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace))
const info = await stat(path)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = mediaTypeForPath(path)
const body = await readFile(path)
// Raw bytes either way (binary-safe); ?download=1 switches the
// disposition so the browser saves the file instead of showing it.
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
if (url.searchParams.get('download') === '1') {
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
}
res.writeHead(200, headers)
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
// Serves files under the session cwd for the built-in HTML previewer. The
// URL is path-encoded (see html-route.ts) so the previewed page's relative
// assets (./style.css, img/x.png) resolve back into this route with the
// session scope intact — a query-encoded URL would drop the scope when the
// browser resolves relatives. Every response carries the CSP `sandbox`
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
// boundary, this header is defense-in-depth so even a top-level load of
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
// origin with no same-origin access to the GUI.
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/html',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const decoded = decodeHtmlUrl(url.pathname)
if (!decoded.ok) {
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
return
}
const { sessionId, path } = decoded.ref
// The session's authoritative cwd (client cwd cannot ride in the URL
// — the path encoding has no query; a detached first request falls
// back to the process cwd and is normally refused by the workspace
// real-path guard, with the same semantics as the media route's