@erclx/aitk 3.45.0 → 3.47.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/REQUIREMENT.md +3 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +18 -2
- package/claude/skills/claude-teach/REQUIREMENT.md +3 -0
- package/claude/skills/claude-teach/SKILL.md +18 -1
- package/claude/skills/claude-worker/SKILL.md +41 -4
- package/docs/agents/commands.md +9 -0
- package/docs/agents/counts.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/sessions.md +27 -2
- package/docs/agents/teach.md +12 -0
- package/governance/rules/claude/561-teach.md +1 -1
- package/governance/rules/ui/400-ui.md +1 -0
- package/governance/rules/ui/410-a11y.md +2 -0
- package/governance/rules/ui/420-forms.md +1 -0
- package/governance/rules/ui/430-ux-completeness.md +2 -0
- package/governance/rules/ui/440-surface-capture.md +13 -5
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/serve.ts +159 -0
- package/src/commands/sessions.ts +83 -6
- package/src/process/harness.ts +167 -0
- package/src/serve/static.ts +322 -0
- package/src/sessions/resolve.ts +103 -0
package/src/commands/sessions.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { resolve } from 'node:path'
|
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
4
4
|
import {
|
|
5
|
+
callerIdentity,
|
|
5
6
|
repositoryOf,
|
|
6
7
|
type ResolvedSession,
|
|
7
8
|
resolveSessions,
|
|
9
|
+
type SelfReport,
|
|
8
10
|
type SessionReport,
|
|
11
|
+
selfOf,
|
|
9
12
|
} from '@/sessions/resolve'
|
|
10
13
|
import {
|
|
11
14
|
intro,
|
|
@@ -21,6 +24,7 @@ interface ListCommandOptions {
|
|
|
21
24
|
readonly json?: boolean
|
|
22
25
|
readonly branch?: string
|
|
23
26
|
readonly repository?: string
|
|
27
|
+
readonly self?: boolean
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
const REASONS: Record<string, string> = {
|
|
@@ -52,6 +56,10 @@ export function register(program: Command): void {
|
|
|
52
56
|
'--repository <path>',
|
|
53
57
|
'Answer about this project rather than the working one',
|
|
54
58
|
)
|
|
59
|
+
.option(
|
|
60
|
+
'--self',
|
|
61
|
+
"Report the caller's own row, and refuse where the roster holds none",
|
|
62
|
+
)
|
|
55
63
|
.addHelpText(
|
|
56
64
|
'after',
|
|
57
65
|
[
|
|
@@ -93,6 +101,19 @@ export function register(program: Command): void {
|
|
|
93
101
|
'The match can return more than one session. Read the count rather than',
|
|
94
102
|
'the first row, since two sessions can hold one branch.',
|
|
95
103
|
'',
|
|
104
|
+
"--self narrows the report to the caller's own row, which is what a",
|
|
105
|
+
'dispatcher reads to learn the sessionId it carries into a launch. It',
|
|
106
|
+
'joins on CLAUDE_CODE_SESSION_ID first, falls back to CLAUDE_PID, and',
|
|
107
|
+
'falls back again to the pid the messaging socket path spells. It never',
|
|
108
|
+
'reads CLAUDE_CODE_HOST_SESSION_ID, which holds a value from another',
|
|
109
|
+
'namespace that matches no row.',
|
|
110
|
+
'',
|
|
111
|
+
'It refuses with reason "no-self-identity" when the environment states',
|
|
112
|
+
'none of the three, and "no-self-row" when it states one and no live',
|
|
113
|
+
'row carries it. The second is the ordinary answer for a session',
|
|
114
|
+
'driving from Remote Control, which is addressable on the message',
|
|
115
|
+
'channel and holds no local process record for the roster to report.',
|
|
116
|
+
'',
|
|
96
117
|
'Each session writes its own working directory beside its own name, so a',
|
|
97
118
|
'name from a session listing joins to a branch by an exact match rather',
|
|
98
119
|
'than by ordering the roster on start time.',
|
|
@@ -108,6 +129,7 @@ export function register(program: Command): void {
|
|
|
108
129
|
' aitk sessions list --json',
|
|
109
130
|
' aitk sessions list --branch feat/parser --json',
|
|
110
131
|
' aitk sessions list --branch chore/agents --repository ../caret --json',
|
|
132
|
+
' aitk sessions list --self --json',
|
|
111
133
|
'',
|
|
112
134
|
].join('\n'),
|
|
113
135
|
)
|
|
@@ -136,6 +158,34 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
136
158
|
return 1
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
// The roster read returns every row and marks none of them as the caller, so
|
|
162
|
+
// the join runs here, ahead of any scope. Resolving it after the branch
|
|
163
|
+
// filter would answer "no row" for a caller whose row was merely filtered
|
|
164
|
+
// out, which is a different failure wearing the same reason.
|
|
165
|
+
const own = opts.self ? selfOf(report.sessions, callerIdentity()) : null
|
|
166
|
+
|
|
167
|
+
if (own?.kind === 'unresolved') {
|
|
168
|
+
intro('aitk sessions list')
|
|
169
|
+
logStep('Refused')
|
|
170
|
+
logWarn(selfRefusal(own))
|
|
171
|
+
outro()
|
|
172
|
+
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
process.stdout.write(
|
|
175
|
+
`${JSON.stringify({
|
|
176
|
+
dir: report.dir,
|
|
177
|
+
reason:
|
|
178
|
+
own.reason === 'no-identity' ? 'no-self-identity' : 'no-self-row',
|
|
179
|
+
sessions: [],
|
|
180
|
+
})}\n`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pool = own === null ? report.sessions : [own.session]
|
|
188
|
+
|
|
139
189
|
// A branch name identifies a branch inside one repository and nothing across
|
|
140
190
|
// a machine, so an unscoped match reaches a session working in a different
|
|
141
191
|
// project. `main` is the name that collides on every machine running two.
|
|
@@ -164,11 +214,11 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
const shown = opts.branch
|
|
167
|
-
?
|
|
217
|
+
? pool.filter(
|
|
168
218
|
(session) =>
|
|
169
219
|
session.branch === opts.branch && session.repository === repository,
|
|
170
220
|
)
|
|
171
|
-
:
|
|
221
|
+
: pool
|
|
172
222
|
|
|
173
223
|
const claim = opts.branch
|
|
174
224
|
? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
|
|
@@ -176,7 +226,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
176
226
|
|
|
177
227
|
intro('aitk sessions list')
|
|
178
228
|
reportConfidence(report)
|
|
179
|
-
reportSessions(shown, opts.branch, repository)
|
|
229
|
+
reportSessions(shown, opts.branch, repository, own !== null)
|
|
180
230
|
if (claim) reportClaim(claim)
|
|
181
231
|
outro()
|
|
182
232
|
|
|
@@ -200,6 +250,22 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
200
250
|
return 0
|
|
201
251
|
}
|
|
202
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Separates a client that states no identity from a roster holding no row for
|
|
255
|
+
* one it does state, since the two send a reader to different places.
|
|
256
|
+
*/
|
|
257
|
+
function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
|
|
258
|
+
if (own.reason === 'no-identity') {
|
|
259
|
+
return 'Nothing in the environment identifies this session, so --self has nothing to match against. A client setting none of CLAUDE_CODE_SESSION_ID, CLAUDE_PID, or CLAUDE_CODE_MESSAGING_SOCKET cannot be located on the roster at all.'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const held =
|
|
263
|
+
own.identity.sessionId ??
|
|
264
|
+
(own.identity.pid === null ? 'nothing' : `pid ${own.identity.pid}`)
|
|
265
|
+
|
|
266
|
+
return `The environment identifies this session as ${held}, and no live row carries it. The roster holds local process records alone, so a session driving through Remote Control never appears here, and a record whose session has ended is dropped ahead of the match.`
|
|
267
|
+
}
|
|
268
|
+
|
|
203
269
|
/**
|
|
204
270
|
* States how liveness was decided on every run, including the run that decided
|
|
205
271
|
* it the strong way.
|
|
@@ -239,6 +305,7 @@ function reportSessions(
|
|
|
239
305
|
sessions: readonly ResolvedSession[],
|
|
240
306
|
branch: string | undefined,
|
|
241
307
|
repository: string | null,
|
|
308
|
+
scoped: boolean,
|
|
242
309
|
): void {
|
|
243
310
|
logStep('Sessions')
|
|
244
311
|
|
|
@@ -248,11 +315,21 @@ function reportSessions(
|
|
|
248
315
|
)
|
|
249
316
|
}
|
|
250
317
|
|
|
318
|
+
// An empty result under --self says nothing about the roster, since the pool
|
|
319
|
+
// was narrowed to one row before the branch filter ran. Reporting the wider
|
|
320
|
+
// answer there would claim a reading this run never took.
|
|
251
321
|
if (sessions.length === 0) {
|
|
322
|
+
if (branch) {
|
|
323
|
+
logInfo(
|
|
324
|
+
scoped
|
|
325
|
+
? `This session does not hold ${branch}.`
|
|
326
|
+
: `No live session in this repository holds ${branch}.`,
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
252
331
|
logInfo(
|
|
253
|
-
|
|
254
|
-
? `No live session in this repository holds ${branch}.`
|
|
255
|
-
: 'No live session. Every record in the registry belongs to a session that has ended.',
|
|
332
|
+
'No live session. Every record in the registry belongs to a session that has ended.',
|
|
256
333
|
)
|
|
257
334
|
return
|
|
258
335
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { stateDir } from '@/targets/registry'
|
|
6
|
+
|
|
7
|
+
const CLI = join(PROJECT_ROOT, 'src/cli.ts')
|
|
8
|
+
|
|
9
|
+
/** No case has ever needed longer, and a blocked verb should fail fast. */
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
11
|
+
|
|
12
|
+
export interface ProcessRun {
|
|
13
|
+
readonly status: number | null
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
readonly json: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunCliOptions {
|
|
20
|
+
readonly cwd: string
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv
|
|
22
|
+
readonly timeoutMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a case reaches past its declared temporary directory into this
|
|
27
|
+
* machine's real toolkit state. `stateDir()` in `src/targets/registry.ts`
|
|
28
|
+
* holds both the target registry `gov install` and `gov sync` record into and
|
|
29
|
+
* the sandbox tree `aitk sandbox` provisions into, so a case that inherits the
|
|
30
|
+
* real `HOME` unmodified writes into whichever of the two a verb touches, and
|
|
31
|
+
* nothing but this check would ever say so.
|
|
32
|
+
*/
|
|
33
|
+
export class ContainmentViolation extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compares two snapshots of this machine's real toolkit state directory and
|
|
37
|
+
* reports whether a spawn changed it. A pure comparison over the two reads
|
|
38
|
+
* rather than the read itself, so the detection logic is testable without
|
|
39
|
+
* touching the filesystem or spawning anything.
|
|
40
|
+
*/
|
|
41
|
+
export function detectStateLeak(
|
|
42
|
+
before: string | undefined,
|
|
43
|
+
after: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return before !== after
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A sorted `path:size` listing of every file under this machine's real
|
|
50
|
+
* `stateDir()`, walked recursively rather than read one level deep, so a
|
|
51
|
+
* write nested inside an existing folder, such as a file the sandbox tree
|
|
52
|
+
* already holds, shows up the same as a new top-level entry. Reading a single
|
|
53
|
+
* known file, such as the target registry alone, would miss every sibling
|
|
54
|
+
* `stateDir()` grows, which is what left the sandbox tree unwatched.
|
|
55
|
+
*/
|
|
56
|
+
export function snapshotStateDir(): string {
|
|
57
|
+
const root = stateDir()
|
|
58
|
+
const rows: string[] = []
|
|
59
|
+
|
|
60
|
+
function walk(dir: string): void {
|
|
61
|
+
let names: string[]
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir)
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const name of names.sort()) {
|
|
69
|
+
const full = join(dir, name)
|
|
70
|
+
let info: ReturnType<typeof statSync>
|
|
71
|
+
try {
|
|
72
|
+
info = statSync(full)
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (info.isDirectory()) walk(full)
|
|
77
|
+
else rows.push(`${full}:${info.size}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
walk(root)
|
|
82
|
+
return rows.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Spawns the real entry point rather than calling a command's action function
|
|
87
|
+
* in-process, so a case answers whether a verb is registered, whether it
|
|
88
|
+
* exits the way its own contract states, and whether its `--json` record
|
|
89
|
+
* parses off stdout alone, none of which an in-process call can misreport.
|
|
90
|
+
*
|
|
91
|
+
* A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
|
|
92
|
+
* reads against this checkout instead of the temporary directory a case
|
|
93
|
+
* builds, so every spawn drops the `GIT_` prefix before adding the headless
|
|
94
|
+
* flag every case needs to avoid a picker blocking on stdin.
|
|
95
|
+
*
|
|
96
|
+
* `AITK_STATE_DIR` and `AITK_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
|
|
97
|
+
* each pointed at a folder under the case's own `cwd` rather than dropped,
|
|
98
|
+
* since dropping either alone would still resolve through the inherited
|
|
99
|
+
* `HOME` to this machine's real `~/.local/state/aitk`. `stateDir()` and
|
|
100
|
+
* `sandboxTree()` resolve the same three ways and share that parent, so both
|
|
101
|
+
* overrides move together. A case explicitly passing its own value through
|
|
102
|
+
* `options.env` still wins, matching `AITK_NON_INTERACTIVE` below.
|
|
103
|
+
*
|
|
104
|
+
* The `stateDir()` snapshot before and after the spawn is what actually
|
|
105
|
+
* catches an escape past that redirection, since a default can be wrong in a
|
|
106
|
+
* way a case never asserts on its own, and it is what `AITK_SANDBOX_DIR`
|
|
107
|
+
* rides for free: the sandbox tree already sits under `stateDir()`, so
|
|
108
|
+
* walking the whole directory catches a leak there with no override of its
|
|
109
|
+
* own to add. `ContainmentViolation` fails loud rather than leaving a dead
|
|
110
|
+
* row for a reviewer to find on a real machine.
|
|
111
|
+
*/
|
|
112
|
+
export function runCli(
|
|
113
|
+
args: readonly string[],
|
|
114
|
+
options: RunCliOptions,
|
|
115
|
+
): ProcessRun {
|
|
116
|
+
const inherited = Object.fromEntries(
|
|
117
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const before = snapshotStateDir()
|
|
121
|
+
|
|
122
|
+
const result = spawnSync('bun', [CLI, ...args], {
|
|
123
|
+
cwd: options.cwd,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
126
|
+
env: {
|
|
127
|
+
...inherited,
|
|
128
|
+
AITK_NON_INTERACTIVE: '1',
|
|
129
|
+
AITK_STATE_DIR: join(options.cwd, '.aitk-state'),
|
|
130
|
+
AITK_SANDBOX_DIR: join(options.cwd, '.aitk-state', 'sandbox'),
|
|
131
|
+
...options.env,
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const after = snapshotStateDir()
|
|
136
|
+
if (detectStateLeak(before, after)) {
|
|
137
|
+
throw new ContainmentViolation(
|
|
138
|
+
`A case wrote into this machine's real toolkit state at ${stateDir()}. ` +
|
|
139
|
+
'Every process-tier case must stay inside the directory it declared.',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: result.status,
|
|
145
|
+
stdout: result.stdout,
|
|
146
|
+
stderr: result.stderr,
|
|
147
|
+
json: parseJson(result.stdout),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Data goes to stdout and framing to stderr, so a harness reading `--json`
|
|
153
|
+
* off the merged output would assert against a record no verb ever wrote. A
|
|
154
|
+
* command that emits no JSON, or fails before it gets there, leaves the
|
|
155
|
+
* field `undefined` rather than throwing, so a case asserting the exit code
|
|
156
|
+
* of a refusal is not also forced to guard a parse.
|
|
157
|
+
*/
|
|
158
|
+
function parseJson(stdout: string): unknown {
|
|
159
|
+
const trimmed = stdout.trim()
|
|
160
|
+
if (trimmed === '') return undefined
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(trimmed)
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The loopback interface, never a wildcard bind. A preview serves whatever
|
|
6
|
+
* directory it is pointed at, and the folders this exists for are the
|
|
7
|
+
* gitignored record trees, so reaching the network is the one thing it must
|
|
8
|
+
* not do.
|
|
9
|
+
*/
|
|
10
|
+
export const SERVE_HOST = '127.0.0.1'
|
|
11
|
+
|
|
12
|
+
/** Tried first, then the next ports in order, so a second preview still opens. */
|
|
13
|
+
export const DEFAULT_PORT = 8787
|
|
14
|
+
|
|
15
|
+
/** How far past the requested port to look before refusing. */
|
|
16
|
+
const PORT_ATTEMPTS = 20
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ENTRY = 'index.html'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extensions a browser has to be told about. Anything absent is served as an
|
|
22
|
+
* octet stream, which downloads rather than renders, and that is the safe
|
|
23
|
+
* direction for a type this map does not claim to know.
|
|
24
|
+
*/
|
|
25
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
26
|
+
css: 'text/css; charset=utf-8',
|
|
27
|
+
gif: 'image/gif',
|
|
28
|
+
htm: 'text/html; charset=utf-8',
|
|
29
|
+
html: 'text/html; charset=utf-8',
|
|
30
|
+
ico: 'image/x-icon',
|
|
31
|
+
jpeg: 'image/jpeg',
|
|
32
|
+
jpg: 'image/jpeg',
|
|
33
|
+
js: 'text/javascript; charset=utf-8',
|
|
34
|
+
json: 'application/json; charset=utf-8',
|
|
35
|
+
/**
|
|
36
|
+
* Plain text rather than `text/markdown`, which a browser offers to save
|
|
37
|
+
* instead of showing. A reader following a link to a source page wants to
|
|
38
|
+
* read it, and the rendered sibling is a separate file.
|
|
39
|
+
*/
|
|
40
|
+
md: 'text/plain; charset=utf-8',
|
|
41
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
42
|
+
pdf: 'application/pdf',
|
|
43
|
+
png: 'image/png',
|
|
44
|
+
svg: 'image/svg+xml',
|
|
45
|
+
txt: 'text/plain; charset=utf-8',
|
|
46
|
+
webp: 'image/webp',
|
|
47
|
+
woff: 'font/woff',
|
|
48
|
+
woff2: 'font/woff2',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ServeRefusal =
|
|
52
|
+
| 'no-root'
|
|
53
|
+
| 'not-a-directory'
|
|
54
|
+
| 'no-port'
|
|
55
|
+
| 'no-entry'
|
|
56
|
+
| 'bind-failed'
|
|
57
|
+
|
|
58
|
+
export interface ServeRefused {
|
|
59
|
+
readonly ok: false
|
|
60
|
+
readonly reason: ServeRefusal
|
|
61
|
+
readonly detail: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ServeStarted {
|
|
65
|
+
readonly ok: true
|
|
66
|
+
/** Absolute, so a report names the directory rather than the caller's cwd. */
|
|
67
|
+
readonly root: string
|
|
68
|
+
readonly host: string
|
|
69
|
+
readonly port: number
|
|
70
|
+
/** The entry page relative to the root, as the URL spells it. */
|
|
71
|
+
readonly entry: string
|
|
72
|
+
/** What a reader clicks. Complete, including the entry page. */
|
|
73
|
+
readonly url: string
|
|
74
|
+
/** Whether the entry page exists. A missing one is reported, never fatal. */
|
|
75
|
+
readonly entryExists: boolean
|
|
76
|
+
readonly stop: () => Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ServeOutcome = ServeStarted | ServeRefused
|
|
80
|
+
|
|
81
|
+
export interface ServeOptions {
|
|
82
|
+
readonly port?: number
|
|
83
|
+
readonly entry?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function refuse(reason: ServeRefusal, detail: string): ServeRefused {
|
|
87
|
+
return { ok: false, reason, detail }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function contentType(path: string): string {
|
|
91
|
+
const dot = path.lastIndexOf('.')
|
|
92
|
+
if (dot === -1) return 'application/octet-stream'
|
|
93
|
+
const ext = path.slice(dot + 1).toLowerCase()
|
|
94
|
+
return CONTENT_TYPES[ext] ?? 'application/octet-stream'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves a request path inside the root, or returns undefined when it escapes.
|
|
99
|
+
* The containment test compares resolved absolute paths rather than inspecting
|
|
100
|
+
* the request for `..`, because an encoded traversal survives a textual scan and
|
|
101
|
+
* does not survive resolution.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveWithin(
|
|
104
|
+
root: string,
|
|
105
|
+
requestPath: string,
|
|
106
|
+
): string | undefined {
|
|
107
|
+
let decoded: string
|
|
108
|
+
try {
|
|
109
|
+
decoded = decodeURIComponent(requestPath)
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A NUL truncates the path at the filesystem layer, so a request carrying one
|
|
115
|
+
* asks for a different file than the one the containment test read.
|
|
116
|
+
*/
|
|
117
|
+
if (decoded.includes('\0')) return undefined
|
|
118
|
+
|
|
119
|
+
const relativePath = decoded.replace(/^\/+/, '')
|
|
120
|
+
const target = resolve(root, relativePath)
|
|
121
|
+
if (target !== root && !target.startsWith(root + sep)) return undefined
|
|
122
|
+
return target
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Re-tests containment after following symlinks. `resolveWithin` is lexical
|
|
127
|
+
* and `resolve` does not follow a link, so a link inside the root clears that
|
|
128
|
+
* test while the file it points at sits outside. This repository is a live
|
|
129
|
+
* instance, since `claude/standards` and `claude/snippets` are links out of
|
|
130
|
+
* `claude/`.
|
|
131
|
+
*
|
|
132
|
+
* Only a path that exists is checked, because a link can be followed only once
|
|
133
|
+
* there is something on the other end, and a path that resolves to nothing is
|
|
134
|
+
* a 404 rather than an escape.
|
|
135
|
+
*/
|
|
136
|
+
function escapesThroughLink(root: string, target: string): boolean {
|
|
137
|
+
if (!existsSync(target)) return false
|
|
138
|
+
try {
|
|
139
|
+
const realRoot = realpathSync(root)
|
|
140
|
+
const realTarget = realpathSync(target)
|
|
141
|
+
return realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)
|
|
142
|
+
} catch {
|
|
143
|
+
/* Unreadable resolves to no answer, and no answer is refused. */
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a bind failure is contention worth trying the next port for.
|
|
150
|
+
*
|
|
151
|
+
* Lifted out of the loop so the decision is testable. Manufacturing a real
|
|
152
|
+
* non-contention bind failure needs a privileged port or an unavailable
|
|
153
|
+
* interface and neither travels between machines, where an error value does,
|
|
154
|
+
* so this is unit-tested and the bind itself is not.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldWalkPast(error: unknown): boolean {
|
|
157
|
+
return (error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Picks a listening port, starting at the requested one and walking forward.
|
|
162
|
+
* A busy port is the ordinary case rather than a failure, since a preview of
|
|
163
|
+
* one workspace is routinely open while another is started.
|
|
164
|
+
*/
|
|
165
|
+
function listen(
|
|
166
|
+
root: string,
|
|
167
|
+
first: number,
|
|
168
|
+
): { server: ReturnType<typeof Bun.serve>; port: number } | undefined {
|
|
169
|
+
for (let port = first; port < first + PORT_ATTEMPTS; port++) {
|
|
170
|
+
try {
|
|
171
|
+
const server = Bun.serve({
|
|
172
|
+
hostname: SERVE_HOST,
|
|
173
|
+
port,
|
|
174
|
+
fetch: (request) => respond(root, request),
|
|
175
|
+
})
|
|
176
|
+
/**
|
|
177
|
+
* The bound port rather than the requested one. Port 0 asks the OS to
|
|
178
|
+
* choose, so reporting the request builds a URL pointing at nothing.
|
|
179
|
+
* The type admits undefined for a unix socket, which a bind carrying a
|
|
180
|
+
* hostname and a port never is, and the request is the honest fallback.
|
|
181
|
+
*/
|
|
182
|
+
return { server, port: server.port ?? port }
|
|
183
|
+
} catch (error) {
|
|
184
|
+
/**
|
|
185
|
+
* Contention is the one cause worth walking past. A permission failure
|
|
186
|
+
* or an unavailable interface swallowed here would be retried twenty
|
|
187
|
+
* times and then reported as a port range being full, which names a
|
|
188
|
+
* cause nothing checked. `startServer` turns the rethrow into a refusal.
|
|
189
|
+
*/
|
|
190
|
+
if (!shouldWalkPast(error)) throw error
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function respond(
|
|
197
|
+
root: string,
|
|
198
|
+
request: Request,
|
|
199
|
+
): Promise<Response> {
|
|
200
|
+
const { pathname, search } = new URL(request.url)
|
|
201
|
+
const forbidden = () =>
|
|
202
|
+
new Response('Forbidden\n', {
|
|
203
|
+
status: 403,
|
|
204
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const target = resolveWithin(root, pathname)
|
|
208
|
+
if (!target) return forbidden()
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Tested here as well as before the read, because the redirect below answers
|
|
212
|
+
* ahead of that one. A redirect firing on an out-of-root directory reports
|
|
213
|
+
* that it exists, where one that does not answers 404, and the pair is a
|
|
214
|
+
* fact about the filesystem outside the root.
|
|
215
|
+
*/
|
|
216
|
+
if (escapesThroughLink(root, target)) return forbidden()
|
|
217
|
+
|
|
218
|
+
let path = target
|
|
219
|
+
if (existsSync(path) && statSync(path).isDirectory()) {
|
|
220
|
+
/**
|
|
221
|
+
* A browser resolves a relative asset against the last slash of the URL it
|
|
222
|
+
* is on, so answering a directory in place leaves `/lesson` asking for
|
|
223
|
+
* `/course.css` rather than `/lesson/course.css` and the page renders
|
|
224
|
+
* unstyled. The redirect moves the base before the index is served.
|
|
225
|
+
*/
|
|
226
|
+
if (!pathname.endsWith('/')) {
|
|
227
|
+
return new Response(null, {
|
|
228
|
+
status: 301,
|
|
229
|
+
headers: { location: `${pathname}/${search}` },
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
path = join(path, DEFAULT_ENTRY)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Sits immediately before the read rather than beside the path that produced
|
|
237
|
+
* it, so every path reaching `Bun.file` has been tested whatever produced
|
|
238
|
+
* it. Checking the request path alone left the appended index untested, and
|
|
239
|
+
* a real directory holding a linked index was served.
|
|
240
|
+
*/
|
|
241
|
+
if (escapesThroughLink(root, path)) return forbidden()
|
|
242
|
+
|
|
243
|
+
const file = Bun.file(path)
|
|
244
|
+
if (!(await file.exists())) {
|
|
245
|
+
return new Response(`Not found: ${pathname}\n`, {
|
|
246
|
+
status: 404,
|
|
247
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return new Response(file, {
|
|
252
|
+
headers: {
|
|
253
|
+
'content-type': contentType(path),
|
|
254
|
+
/**
|
|
255
|
+
* A preview is edited and reloaded continuously, and a cached stylesheet
|
|
256
|
+
* reads as a fix that did not work. Revalidation is the whole point of
|
|
257
|
+
* the surface, so it is not negotiable per response.
|
|
258
|
+
*/
|
|
259
|
+
'cache-control': 'no-store',
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function startServer(
|
|
265
|
+
dir: string,
|
|
266
|
+
options: ServeOptions = {},
|
|
267
|
+
): ServeOutcome {
|
|
268
|
+
const root = resolve(process.cwd(), dir)
|
|
269
|
+
if (!existsSync(root)) return refuse('no-root', `${dir} does not exist`)
|
|
270
|
+
if (!statSync(root).isDirectory())
|
|
271
|
+
return refuse('not-a-directory', `${dir} is not a directory`)
|
|
272
|
+
|
|
273
|
+
const entry = (options.entry ?? DEFAULT_ENTRY).replace(/^\/+/, '')
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Checked before a port is taken, since `url` is the field a caller is told
|
|
277
|
+
* to read and hand to a reader. An entry the containment test rejects would
|
|
278
|
+
* otherwise be reported as a link the server then refuses. An entry that is
|
|
279
|
+
* merely absent is not this case and does not refuse, which `entryExists`
|
|
280
|
+
* reports instead.
|
|
281
|
+
*/
|
|
282
|
+
const entryPath = resolveWithin(root, entry)
|
|
283
|
+
if (!entryPath) return refuse('no-entry', `${entry} escapes ${dir}`)
|
|
284
|
+
|
|
285
|
+
const first = options.port ?? DEFAULT_PORT
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A bind failure that is not contention reaches here as a throw, and the
|
|
289
|
+
* command's own help promises a reason on stderr or in the record. A stack
|
|
290
|
+
* trace is neither, so it is caught and named.
|
|
291
|
+
*/
|
|
292
|
+
let bound: ReturnType<typeof listen>
|
|
293
|
+
try {
|
|
294
|
+
bound = listen(root, first)
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
297
|
+
return refuse(
|
|
298
|
+
'bind-failed',
|
|
299
|
+
`could not bind ${SERVE_HOST}:${first} (${code})`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!bound) {
|
|
304
|
+
return refuse(
|
|
305
|
+
'no-port',
|
|
306
|
+
`no free port between ${first} and ${first + PORT_ATTEMPTS - 1}`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
root,
|
|
313
|
+
host: SERVE_HOST,
|
|
314
|
+
port: bound.port,
|
|
315
|
+
entry,
|
|
316
|
+
url: `http://${SERVE_HOST}:${bound.port}/${entry}`,
|
|
317
|
+
entryExists: existsSync(entryPath),
|
|
318
|
+
stop: async () => {
|
|
319
|
+
await bound.server.stop(true)
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
}
|