@ucsandman/legcli 0.9.0 → 0.11.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/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
package/src/redact.mjs
CHANGED
|
@@ -11,19 +11,37 @@ const PATTERNS = [
|
|
|
11
11
|
['Anthropic key (sk-ant-)', /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{8,}/g],
|
|
12
12
|
['DashClaw key (oc_live_)', /(?<![A-Za-z0-9_-])oc_live_[a-f0-9]\w*/g],
|
|
13
13
|
['bearer token', /Bearer\s+[A-Za-z0-9._-]{16,}/g],
|
|
14
|
-
['GitHub token (
|
|
15
|
-
['GitHub server token (ghs_)', /(?<![A-Za-z0-9_-])ghs_[A-Za-z0-9]{20,}/g],
|
|
14
|
+
['GitHub token (gh[pousr]_)', /(?<![A-Za-z0-9_-])gh[pousr]_[A-Za-z0-9]{20,}/g],
|
|
16
15
|
['GitHub fine-grained token (github_pat_)', /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/g],
|
|
17
16
|
['AWS key (AKIA)', /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/g],
|
|
18
|
-
['Slack token (xox)', /(?<![A-Za-z0-9_-])xox[
|
|
19
|
-
['key=value secret', /api[_-]?key\
|
|
17
|
+
['Slack token (xox/xapp)', /(?<![A-Za-z0-9_-])(?:xox[baprs]|xapp)-\S+/g],
|
|
18
|
+
['key=value secret', /api[_-]?key[ \t]*[=:][ \t]*\S+/gi],
|
|
19
|
+
// shapes a discovered transcript from another agent carries that the list
|
|
20
|
+
// above missed (measured 2026-09-16: 15 of 18 common shapes went through)
|
|
21
|
+
['Stripe key (sk_live_/rk_live_)', /(?<![A-Za-z0-9_-])[sr]k_(?:live|test)_[A-Za-z0-9]{8,}/g],
|
|
22
|
+
['Google API key (AIza)', /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{30,}/g],
|
|
23
|
+
['xAI key (xai-)', /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{16,}/g],
|
|
24
|
+
['npm token (npm_)', /(?<![A-Za-z0-9_-])npm_[A-Za-z0-9]{20,}/g],
|
|
25
|
+
['GitLab token (glpat-)', /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{16,}/g],
|
|
26
|
+
['Hugging Face token (hf_)', /(?<![A-Za-z0-9_-])hf_[A-Za-z0-9]{20,}/g],
|
|
27
|
+
['JWT', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
28
|
+
['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
|
|
29
|
+
// a digit or two capitals somewhere: base64 of any credential has one of
|
|
30
|
+
// them, "Basic authentication/authorization" in prose has neither
|
|
31
|
+
['basic auth header', /Basic\s+(?=[A-Za-z0-9+/=]*(?:[0-9]|[A-Z][A-Za-z0-9+/=]*[A-Z]))[A-Za-z0-9+/=]{16,}/g],
|
|
32
|
+
['URL with credentials', /(?<=[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@/]{4,}(?=@)/gi],
|
|
33
|
+
// an environment-style NAME (upper case): `cache_key = build(...)` and
|
|
34
|
+
// `refresh_token: string` in code are not secrets; a value never crosses a
|
|
35
|
+
// line break, so `SECRET_KEY=` at the end of a line takes nothing after it
|
|
36
|
+
['env-style secret assign', /(?:[A-Z0-9_]*_(?:KEY|TOKEN|SECRET|PASSWORD)|aws_secret_access_key)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/g],
|
|
37
|
+
['password=value', /\b(?:password|passwd)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/gi],
|
|
20
38
|
]
|
|
21
39
|
|
|
22
40
|
export const SECRET_RES = PATTERNS.map(([, re]) => re)
|
|
23
41
|
// non-global copies for `.test()` (a /g regex carries lastIndex state)
|
|
24
42
|
export const SECRET_PATTERNS = PATTERNS.map(([name, re]) => [name, new RegExp(re.source, re.flags.replace('g', ''))])
|
|
25
43
|
|
|
26
|
-
const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'BATON_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN']
|
|
44
|
+
const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'LEG_TOKEN', 'BATON_TOKEN', 'LEG_LICENSE_PRIVATE_KEY', 'BATON_LICENSE_PRIVATE_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', 'STRIPE_SECRET_KEY', 'STRIPE_TEST_SECRET_KEY', 'RESEND_API_KEY', 'NPM_TOKEN']
|
|
27
45
|
let envValues = null
|
|
28
46
|
function heldValues() {
|
|
29
47
|
if (envValues) return envValues
|
package/src/server.mjs
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
6
|
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
8
|
+
import https from 'node:https'
|
|
9
|
+
import { spawnSync, execFile } from 'node:child_process'
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
10
11
|
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
11
12
|
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
|
|
13
|
-
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
|
|
14
|
+
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
|
|
15
|
+
import { auditTrail, ACTOR_KINDS } from './audit.mjs'
|
|
14
16
|
import { createLimiter } from './ratelimit.mjs'
|
|
15
17
|
import { realPath, canonPath } from './fsx.mjs'
|
|
16
18
|
import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
|
|
@@ -29,10 +31,12 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
|
|
|
29
31
|
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
30
32
|
import { refreshPointers } from './resume.mjs'
|
|
31
33
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
32
|
-
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
34
|
+
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
|
|
33
35
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
34
36
|
import { readCodexUsage } from './taps/codex.mjs'
|
|
35
37
|
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
38
|
+
import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
|
|
39
|
+
import { listWorktrees } from './history/worktrees.mjs'
|
|
36
40
|
|
|
37
41
|
const SELF = fileURLToPath(import.meta.url)
|
|
38
42
|
export function resolveBoardDir() {
|
|
@@ -226,6 +230,64 @@ async function adaptersInfo() {
|
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
// ---- sessions (baton claude|codex|agy) ----
|
|
233
|
+
// The worktree list runs git once per known repository: cached for a short
|
|
234
|
+
// while so a board that polls does not fork fifty processes a second.
|
|
235
|
+
const WORKTREES_TTL = 20000
|
|
236
|
+
const worktreesCache = new Map()
|
|
237
|
+
// A stale index is refreshed by a child `leg history refresh`, never in this
|
|
238
|
+
// process: the scan stats thousands of files and walks every cwd, and inside
|
|
239
|
+
// the board's event loop that is seconds of no SSE frames and no clicks. The
|
|
240
|
+
// child takes the same index lock a CLI refresh would, so the two never tear
|
|
241
|
+
// one file; the next listing reads what it wrote.
|
|
242
|
+
const LEG_BIN = join(dirname(SELF), '..', 'bin', 'leg.mjs')
|
|
243
|
+
let historyRefreshing = false
|
|
244
|
+
function backgroundHistoryRefresh() {
|
|
245
|
+
if (historyRefreshing) return
|
|
246
|
+
historyRefreshing = true
|
|
247
|
+
try {
|
|
248
|
+
execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
|
|
249
|
+
} catch { historyRefreshing = false }
|
|
250
|
+
}
|
|
251
|
+
function worktreesFor({ repo = null, dirty = true } = {}) {
|
|
252
|
+
const key = `${repo ?? ''}|${dirty}`
|
|
253
|
+
const hit = worktreesCache.get(key)
|
|
254
|
+
if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
|
|
255
|
+
const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
|
|
256
|
+
worktreesCache.set(key, { at: Date.now(), data })
|
|
257
|
+
return data
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// canLand shells out to git several times for one worktree, and the view runs
|
|
261
|
+
// it for every terminal that ever had one — over a second of subprocesses on a
|
|
262
|
+
// board with a few dozen records, paid again on every SSE push. A terminal that
|
|
263
|
+
// has ended never moves, so the answer is cached against the record's own
|
|
264
|
+
// revision with the short TTL the trunk already uses, which still notices a
|
|
265
|
+
// commit made by hand in the worktree within that window.
|
|
266
|
+
// A terminal that is still running can change what it can land from one turn to
|
|
267
|
+
// the next. One that has ended only moves if someone works in its worktree by
|
|
268
|
+
// hand, and a landing clears this cache outright, so it is re-read a great deal
|
|
269
|
+
// less often.
|
|
270
|
+
const CAN_LAND_TTL = 15000
|
|
271
|
+
const CAN_LAND_TTL_ENDED = 60000
|
|
272
|
+
const canLandCache = new Map()
|
|
273
|
+
function canLandFor(s) {
|
|
274
|
+
const key = `${s.session_id}|${s.updated_at ?? ''}`
|
|
275
|
+
const hit = canLandCache.get(key)
|
|
276
|
+
if (hit && Date.now() < hit.until) return hit.data
|
|
277
|
+
const data = canLand(s)
|
|
278
|
+
// Expiries are spread across the window instead of falling together: twenty
|
|
279
|
+
// worktrees re-read in one pass is another second of git inside the event
|
|
280
|
+
// loop, which is the stall this cache exists to remove. Staggered, the board
|
|
281
|
+
// pays for about one of them per push and never blocks on the set.
|
|
282
|
+
const ttl = isActive(s) ? CAN_LAND_TTL : CAN_LAND_TTL_ENDED
|
|
283
|
+
const until = Date.now() + ttl / 2 + Math.random() * ttl
|
|
284
|
+
// the key carries updated_at, so a busy terminal leaves a dead entry per
|
|
285
|
+
// write: drop the whole map rather than grow it for the life of the process
|
|
286
|
+
if (canLandCache.size > 500) canLandCache.clear()
|
|
287
|
+
canLandCache.set(key, { until, data })
|
|
288
|
+
return data
|
|
289
|
+
}
|
|
290
|
+
|
|
229
291
|
const trunkCache = new Map()
|
|
230
292
|
function trunkFor(repo) {
|
|
231
293
|
const hit = trunkCache.get(repo)
|
|
@@ -283,6 +345,11 @@ function visibleSessionFile(file) {
|
|
|
283
345
|
|
|
284
346
|
export function sessionsView({ viewer = null, share = null } = {}) {
|
|
285
347
|
const shared = Boolean(share && shareIsOn(share))
|
|
348
|
+
// Decided before the map below, because the per-session payload has to know
|
|
349
|
+
// it: a guest owns their own terminal and may hand it off, so they get its
|
|
350
|
+
// list of destinations — but a reset time is this machine's usage data and
|
|
351
|
+
// belongs to nobody else, even on a terminal that is theirs.
|
|
352
|
+
const guest = shared && viewer && viewer.role !== 'owner'
|
|
286
353
|
const list = reapLost(listSessions())
|
|
287
354
|
const ov = overlaps(list)
|
|
288
355
|
const configuredAccounts = readAccounts()
|
|
@@ -293,13 +360,28 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
293
360
|
const preferredNext = chain[0] ?? null
|
|
294
361
|
const availabilityKnown = Boolean(s.installed)
|
|
295
362
|
const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
|
|
296
|
-
const can = s.worktree ?
|
|
363
|
+
const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
|
|
297
364
|
return {
|
|
298
365
|
...s,
|
|
299
366
|
handoff_order: handoffOrder,
|
|
300
367
|
chain,
|
|
301
368
|
preferred_next: preferredNext,
|
|
302
369
|
eligible_next: eligibleNext,
|
|
370
|
+
// every destination this terminal could be handed to, each with the
|
|
371
|
+
// reason it cannot be picked right now. The board's picker renders this
|
|
372
|
+
// list directly, so a greyed option always carries its own explanation.
|
|
373
|
+
handoff_targets: chain.map((c) => {
|
|
374
|
+
const u = readUsage(c.agent, c.account)
|
|
375
|
+
const missing = availabilityKnown && s.installed[c.agent] === false
|
|
376
|
+
const walled = !isAvailable(u)
|
|
377
|
+
return {
|
|
378
|
+
agent: c.agent,
|
|
379
|
+
account: c.account,
|
|
380
|
+
available: !missing && !walled,
|
|
381
|
+
reason: missing ? 'not installed on this machine' : walled ? 'at its usage limit' : null,
|
|
382
|
+
resets_at: walled && !guest ? (u.limited_until ?? null) : null,
|
|
383
|
+
}
|
|
384
|
+
}),
|
|
303
385
|
handoff_availability_known: availabilityKnown,
|
|
304
386
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
305
387
|
active: isActive(s),
|
|
@@ -327,7 +409,6 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
327
409
|
const canon = new Map()
|
|
328
410
|
const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
|
|
329
411
|
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
330
|
-
const guest = shared && viewer && viewer.role !== 'owner'
|
|
331
412
|
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
332
413
|
const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
|
|
333
414
|
return {
|
|
@@ -376,7 +457,7 @@ function serveStatic(res, urlPath) {
|
|
|
376
457
|
}
|
|
377
458
|
|
|
378
459
|
// ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
|
|
379
|
-
function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
|
|
460
|
+
function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounceMs = 300, sessionsMinIntervalMs = 2000, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
|
|
380
461
|
const clients = new Set() // { res, viewer, token, loopback, sig }
|
|
381
462
|
let watcher = null
|
|
382
463
|
let healthTimer = null
|
|
@@ -401,14 +482,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
401
482
|
// resets the count the first is reading from.
|
|
402
483
|
const refreshCard = (id) => {
|
|
403
484
|
const card = readCard(id)
|
|
404
|
-
// pipeline cards and their events
|
|
405
|
-
|
|
485
|
+
// pipeline cards and their events belong to the people who may run them:
|
|
486
|
+
// the owner and any operator. A guest never gets them.
|
|
487
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
406
488
|
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
407
489
|
const events = readEvents(id)
|
|
408
490
|
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
409
491
|
broadcast('card', forOwner(summarize(card)))
|
|
410
492
|
for (const c of [...clients]) {
|
|
411
|
-
if (!c.viewer || c.viewer.role
|
|
493
|
+
if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
|
|
412
494
|
const from = c.sig.get(id) ?? 0
|
|
413
495
|
c.sig.set(id, events.length)
|
|
414
496
|
for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
|
|
@@ -426,13 +508,70 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
426
508
|
}
|
|
427
509
|
let sessionsWatcher = null
|
|
428
510
|
let sessionsTimer = null
|
|
429
|
-
|
|
511
|
+
let lastSessionsPush = 0
|
|
512
|
+
const pushSessions = () => {
|
|
513
|
+
sessionsTimer = null
|
|
514
|
+
try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
|
|
515
|
+
lastSessionsPush = Date.now()
|
|
516
|
+
// the health tick pushes on its own schedule: record what it sent, so the
|
|
517
|
+
// next watcher hint is measured against the page's real contents
|
|
518
|
+
lastFingerprint = sessionsFingerprint()
|
|
519
|
+
}
|
|
520
|
+
// One live agent rewrites its record about every six seconds and takes a
|
|
521
|
+
// control lock about once a second, and every one of those touches the
|
|
522
|
+
// sessions tree. Rebuilding the whole view costs a second or more of `git`,
|
|
523
|
+
// so a watcher that answers every touch turns a single running terminal into
|
|
524
|
+
// a permanent busy loop on the one event loop this board serves every
|
|
525
|
+
// request from: the board then takes seconds to hand over a stylesheet and
|
|
526
|
+
// `leg` itself times out probing /api/health.
|
|
527
|
+
//
|
|
528
|
+
// A watcher event is only a hint. Locks and the temp files an atomic write
|
|
529
|
+
// leaves behind are dropped by name, but taking a lock inside a session
|
|
530
|
+
// directory also changes that directory's own mtime, and that event arrives
|
|
531
|
+
// carrying nothing but the directory name — no filter on the name can tell
|
|
532
|
+
// it from a real write. So the hint is checked against the data: a stat over
|
|
533
|
+
// the files the view is actually built from costs a fraction of a
|
|
534
|
+
// millisecond and answers the question the event cannot.
|
|
535
|
+
const NOISE = /(\.lock|\.tmp)$/i
|
|
536
|
+
const sessionsChangeMatters = (filename) => !filename || !NOISE.test(String(filename))
|
|
537
|
+
const sessionsFingerprint = () => {
|
|
538
|
+
const root = sessionsRoot()
|
|
539
|
+
let dirs
|
|
540
|
+
try { dirs = readdirSync(root) } catch { return '' }
|
|
541
|
+
let sig = ''
|
|
542
|
+
for (const name of dirs) {
|
|
543
|
+
if (!name.startsWith('s-')) continue
|
|
544
|
+
for (const file of ['session.json', 'land.json', 'requests.json']) {
|
|
545
|
+
try { const st = statSync(join(root, name, file)); sig += `${name}/${file}:${st.mtimeMs}:${st.size};` } catch { /* not written yet */ }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return sig
|
|
549
|
+
}
|
|
550
|
+
let lastFingerprint = null
|
|
551
|
+
const pushIfChanged = () => {
|
|
552
|
+
sessionsTimer = null
|
|
553
|
+
const sig = sessionsFingerprint()
|
|
554
|
+
// the hint was noise: the view would rebuild to exactly what the page
|
|
555
|
+
// already has, so nothing is rebuilt and nothing is sent
|
|
556
|
+
if (sig === lastFingerprint) return
|
|
557
|
+
lastFingerprint = sig
|
|
558
|
+
pushSessions()
|
|
559
|
+
}
|
|
560
|
+
const scheduleSessionsPush = () => {
|
|
561
|
+
if (sessionsTimer) return
|
|
562
|
+
const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
|
|
563
|
+
sessionsTimer = setTimeout(pushIfChanged, wait)
|
|
564
|
+
}
|
|
430
565
|
const startWatch = () => {
|
|
431
566
|
if (watcher) return
|
|
432
567
|
try {
|
|
433
568
|
const sdir = sessionsRoot()
|
|
434
569
|
mkdirSync(sdir, { recursive: true })
|
|
435
|
-
|
|
570
|
+
// the client that opened this watch was handed the current view with its
|
|
571
|
+
// hello frame, so the fingerprint starts from what it already has: an
|
|
572
|
+
// unprimed one makes the first hint of any kind look like a change
|
|
573
|
+
lastFingerprint = sessionsFingerprint()
|
|
574
|
+
sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, (_event, filename) => { if (sessionsChangeMatters(filename)) scheduleSessionsPush() })
|
|
436
575
|
} catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
|
|
437
576
|
// watch the real long path: libuv's recursive watcher asserts when the
|
|
438
577
|
// watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
|
|
@@ -496,7 +635,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
496
635
|
}
|
|
497
636
|
const limiter = createLimiter()
|
|
498
637
|
const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
|
|
499
|
-
const forOwner = (payload) => (viewer) => (viewer && viewer.role
|
|
638
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
500
639
|
// SSE re-identifies each client from the live roster on every push
|
|
501
640
|
const reauthClient = (c) => {
|
|
502
641
|
const sh = currentShare()
|
|
@@ -571,17 +710,23 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
571
710
|
const rl = limiter.request(auth.person ? viewer.name : ip)
|
|
572
711
|
if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
|
|
573
712
|
const actor = { type: 'human', id: viewer.name }
|
|
574
|
-
//
|
|
575
|
-
|
|
713
|
+
// What this viewer may reach, decided once from their role (src/share.mjs).
|
|
714
|
+
// `canCards` is the pipeline board: cards, the floor, the adapters and the
|
|
715
|
+
// leases, which an operator runs. `canMachine` is everything that describes
|
|
716
|
+
// this computer rather than the work — the settings, the trunk's repo
|
|
717
|
+
// paths, the history index, the worktree map — and stays the owner's.
|
|
718
|
+
const canCards = !shared || mayUseCards(viewer.role)
|
|
719
|
+
const canMachine = !shared || mayUseMachine(viewer.role)
|
|
576
720
|
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
577
721
|
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
578
|
-
if (
|
|
722
|
+
if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
|
|
723
|
+
if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
|
|
579
724
|
try {
|
|
580
725
|
if (req.method === 'GET' && path === '/api/health') {
|
|
581
726
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
582
|
-
if (
|
|
727
|
+
if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
|
|
583
728
|
const cards = listCards()
|
|
584
|
-
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
729
|
+
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
585
730
|
}
|
|
586
731
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
587
732
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
@@ -602,14 +747,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
602
747
|
}
|
|
603
748
|
if (req.method === 'GET' && path === '/api/events') {
|
|
604
749
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
605
|
-
const cards =
|
|
750
|
+
const cards = canCards ? listCards() : []
|
|
606
751
|
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
607
752
|
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
608
753
|
return
|
|
609
754
|
}
|
|
610
755
|
if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
|
|
611
756
|
if (path === '/api/settings') {
|
|
612
|
-
if (
|
|
757
|
+
if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
|
|
613
758
|
if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
|
|
614
759
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
615
760
|
const body = await readBody(req)
|
|
@@ -738,15 +883,38 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
738
883
|
if (why) return send(res, 409, { error: why })
|
|
739
884
|
landSession(sess, { by: actor.id, autoCommit: true })
|
|
740
885
|
.catch((err) => log(`land ${id}: ${err.message}`))
|
|
741
|
-
|
|
886
|
+
// a landing moves the branch under every worktree cut from it, so
|
|
887
|
+
// the cached land-ability goes with the cached trunk
|
|
888
|
+
.finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
|
|
742
889
|
log(`land requested for ${id} by ${actor.id}`)
|
|
743
890
|
return send(res, 202, { ok: true, requested: 'land' })
|
|
744
891
|
}
|
|
745
892
|
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
746
893
|
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
894
|
+
if (parts[3] === 'end') {
|
|
895
|
+
requestControl(id, { end: true, by: actor.id })
|
|
896
|
+
log(`end requested for ${id} by ${actor.id}`)
|
|
897
|
+
return send(res, 200, { ok: true, requested: 'end' })
|
|
898
|
+
}
|
|
899
|
+
// Hand off now, optionally to a named destination. With no body the
|
|
900
|
+
// chain decides, exactly as it did before the picker existed.
|
|
901
|
+
const body = await readBody(req)
|
|
902
|
+
let target = null
|
|
903
|
+
if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
|
|
904
|
+
const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
|
|
905
|
+
const order = normalizeHandoffOrder(sess.handoff_order)
|
|
906
|
+
const chain = candidates({ agent: sess.agent, account: sess.account, accounts: readAccounts(), order })
|
|
907
|
+
const hit = chain.find((c) => c.agent === want.agent && c.account === want.account)
|
|
908
|
+
const label = `${want.agent}${want.account !== 'default' ? '/' + want.account : ''}`
|
|
909
|
+
if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})` })
|
|
910
|
+
if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
|
|
911
|
+
const u = readUsage(want.agent, want.account)
|
|
912
|
+
if (!isAvailable(u)) return send(res, 409, { error: `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another or use Hand off now without a destination` })
|
|
913
|
+
target = hit
|
|
914
|
+
}
|
|
915
|
+
requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
|
|
916
|
+
log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${target.agent}/${target.account}` : ''}`)
|
|
917
|
+
return send(res, 200, { ok: true, requested: 'handoff', target })
|
|
750
918
|
}
|
|
751
919
|
if (req.method === 'DELETE' && parts.length === 3) {
|
|
752
920
|
if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
|
|
@@ -766,6 +934,75 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
766
934
|
return send(res, 200, { removed: id, worktree })
|
|
767
935
|
}
|
|
768
936
|
}
|
|
937
|
+
// ---- history: the read-only index over every agent's own store ----
|
|
938
|
+
if (parts[1] === 'history') {
|
|
939
|
+
const q = url.searchParams
|
|
940
|
+
const int = (v, def, max) => { const n = parseInt(v ?? '', 10); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : def }
|
|
941
|
+
if (req.method === 'GET' && parts.length === 2) {
|
|
942
|
+
const provider = q.get('provider') || null
|
|
943
|
+
if (provider && provider.split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return send(res, 400, { error: `unknown provider in "${provider}" (${PROVIDER_NAMES.join('|')})` })
|
|
944
|
+
const tri = (v) => (v === '1' || v === 'true' ? true : v === '0' || v === 'false' ? false : null)
|
|
945
|
+
const explicitRefresh = tri(q.get('refresh')) === true
|
|
946
|
+
let refreshArg = null
|
|
947
|
+
if (!explicitRefresh) {
|
|
948
|
+
const idx = readIndex()
|
|
949
|
+
if (idx) {
|
|
950
|
+
const age = idx.refreshed_at ? Date.now() - Date.parse(idx.refreshed_at) : Infinity
|
|
951
|
+
if (age > 60000) backgroundHistoryRefresh()
|
|
952
|
+
refreshArg = false
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
refreshArg = true
|
|
956
|
+
}
|
|
957
|
+
return send(res, 200, listHistory({
|
|
958
|
+
provider, repo: q.get('repo') || null, search: q.get('search') || null,
|
|
959
|
+
before: q.get('before') || null,
|
|
960
|
+
// never the whole index in one response: a page is 1 to 200 rows
|
|
961
|
+
limit: Math.max(1, int(q.get('limit'), 50, 200)), offset: int(q.get('offset'), 0, 1e6),
|
|
962
|
+
managed: tri(q.get('managed')), live: tri(q.get('live')), includeHidden: tri(q.get('hidden')) === true,
|
|
963
|
+
refresh: refreshArg,
|
|
964
|
+
}))
|
|
965
|
+
}
|
|
966
|
+
if (req.method === 'GET' && parts[2] === 'providers') return send(res, 200, { providers: providerSupport() })
|
|
967
|
+
if (req.method === 'POST' && parts[2] === 'refresh') {
|
|
968
|
+
const body = await readBody(req)
|
|
969
|
+
const t = Date.now()
|
|
970
|
+
try {
|
|
971
|
+
const r = refreshIndex({ force: body.full === true })
|
|
972
|
+
return send(res, 200, { ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats })
|
|
973
|
+
} catch (err) { return send(res, 409, { error: `refresh did not run: ${err.message}` }) }
|
|
974
|
+
}
|
|
975
|
+
if (req.method === 'GET' && parts.length === 3) {
|
|
976
|
+
// the id is a lookup key, never a path: findRecord compares strings,
|
|
977
|
+
// and the transcript it names is read only from inside a known store
|
|
978
|
+
let rec
|
|
979
|
+
let wanted
|
|
980
|
+
try { wanted = decodeURIComponent(parts[2]) } catch { return send(res, 400, { error: 'malformed id' }) }
|
|
981
|
+
try { rec = findRecord(wanted, { refresh: false }) } catch (err) {
|
|
982
|
+
if (err instanceof HistoryInputError) return send(res, 400, { error: err.message })
|
|
983
|
+
throw err
|
|
984
|
+
}
|
|
985
|
+
if (!rec) return send(res, 404, { error: `no conversation matches ${parts[2]}` })
|
|
986
|
+
return send(res, 200, recordDetail(rec, { messages: int(q.get('messages'), 8, 50) }))
|
|
987
|
+
}
|
|
988
|
+
return send(res, 404, { error: 'not found' })
|
|
989
|
+
}
|
|
990
|
+
if (req.method === 'GET' && path === '/api/worktrees') {
|
|
991
|
+
const q = url.searchParams
|
|
992
|
+
return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
|
|
993
|
+
}
|
|
994
|
+
if (req.method === 'GET' && path === '/api/audit') {
|
|
995
|
+
const q = url.searchParams
|
|
996
|
+
const kind = q.get('kind')
|
|
997
|
+
if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
|
|
998
|
+
const limit = parseInt(q.get('limit') ?? '200', 10)
|
|
999
|
+
return send(res, 200, auditTrail({
|
|
1000
|
+
limit: Number.isFinite(limit) ? limit : 200,
|
|
1001
|
+
since: q.get('since'),
|
|
1002
|
+
who: q.get('who'),
|
|
1003
|
+
kind,
|
|
1004
|
+
}))
|
|
1005
|
+
}
|
|
769
1006
|
if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
|
|
770
1007
|
if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
|
|
771
1008
|
if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
|
|
@@ -826,21 +1063,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
826
1063
|
}
|
|
827
1064
|
|
|
828
1065
|
const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
|
|
829
|
-
|
|
1066
|
+
// TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
|
|
1067
|
+
// or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
|
|
1068
|
+
// plaintext: a board told to use TLS and quietly serving http would be the
|
|
1069
|
+
// worst outcome of the three.
|
|
1070
|
+
const tls = readTls(initialShare)
|
|
1071
|
+
const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
|
|
830
1072
|
// When the board is bound to a non-loopback address (share on), also listen on
|
|
831
1073
|
// 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
|
|
832
|
-
// remote peer's address is never loopback, so it still needs a token.
|
|
1074
|
+
// remote peer's address is never loopback, so it still needs a token. That one
|
|
1075
|
+
// stays plain http even under TLS: the certificate is for the shared name, and
|
|
1076
|
+
// loopback traffic never leaves this machine.
|
|
833
1077
|
const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
|
|
834
1078
|
|
|
835
1079
|
return {
|
|
836
1080
|
server,
|
|
837
|
-
bind, port,
|
|
1081
|
+
bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
|
|
838
1082
|
start() {
|
|
839
1083
|
return new Promise((resolvePromise, reject) => {
|
|
840
1084
|
server.once('error', reject)
|
|
841
1085
|
server.listen(port, bind, () => {
|
|
842
1086
|
const addr = server.address()
|
|
843
|
-
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
1087
|
+
log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
|
|
844
1088
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
845
1089
|
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
846
1090
|
// after a crash, so it is where that gets corrected.
|
package/src/sessions.mjs
CHANGED
|
@@ -85,6 +85,15 @@ export function updateSession(id, patch, { event } = {}) {
|
|
|
85
85
|
const delta = typeof patch === 'function' ? patch(cur) : patch
|
|
86
86
|
const next = { ...cur, ...delta, updated_at: now() }
|
|
87
87
|
if (delta.limits && cur.limits) next.limits = { ...cur.limits, ...delta.limits }
|
|
88
|
+
// every agent conversation this session has been: a hand-off overwrites
|
|
89
|
+
// agent_session_id with the next agent's, and history (src/history) still
|
|
90
|
+
// needs to know the earlier legs were this session's too
|
|
91
|
+
if (delta.agent_session_id && delta.agent_session_id !== cur.agent_session_id) {
|
|
92
|
+
const seen = cur.agent_sessions ?? []
|
|
93
|
+
if (!seen.some((x) => x.agent === next.agent && x.agent_session_id === delta.agent_session_id)) {
|
|
94
|
+
next.agent_sessions = [...seen, { agent: next.agent, agent_session_id: delta.agent_session_id, transcript_path: delta.transcript_path ?? null, at: now() }].slice(-24)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
88
97
|
writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
|
|
89
98
|
if (event) appendEvent(id, event)
|
|
90
99
|
return next
|
package/src/share.mjs
CHANGED
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
// (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
|
|
7
7
|
// open and `BATON_TOKEN` is the only token.
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
9
|
-
import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, statSync } from 'node:fs'
|
|
10
10
|
import { networkInterfaces, userInfo } from 'node:os'
|
|
11
11
|
import { createSocket } from 'node:dgram'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { home } from './store.mjs'
|
|
14
14
|
import { writeJsonAtomic } from './fsx.mjs'
|
|
15
15
|
|
|
16
|
-
export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
|
|
17
|
-
|
|
16
|
+
export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [], tls: null }
|
|
17
|
+
|
|
18
|
+
// Three roles, because two were not enough to describe a second human who runs
|
|
19
|
+
// cards on this machine but has no business in its settings or its project map.
|
|
20
|
+
// owner everything: machine settings, the harness, every terminal, cards
|
|
21
|
+
// operator the pipeline board and their own terminals; not the settings,
|
|
22
|
+
// not the history index, not anyone else's terminal
|
|
23
|
+
// guest the terminals lane, read-only and redacted; may ask for a hand-off
|
|
24
|
+
export const ROLES = ['owner', 'operator', 'guest']
|
|
25
|
+
|
|
26
|
+
// One place that says what a role may reach, so no endpoint decides for itself.
|
|
27
|
+
// `cards` is the pipeline side of the board. `machine` is everything that
|
|
28
|
+
// describes this computer rather than the work: the settings, the harness
|
|
29
|
+
// policy, the history index and the worktree map.
|
|
30
|
+
export function mayUseCards(role) { return role === 'owner' || role === 'operator' }
|
|
31
|
+
export function mayUseMachine(role) { return role === 'owner' }
|
|
18
32
|
|
|
19
33
|
export function sharePath() { return join(home(), 'share.json') }
|
|
20
34
|
|
|
@@ -55,9 +69,49 @@ export function identify(share, presented) {
|
|
|
55
69
|
export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
|
|
56
70
|
export function isOwner(person) { return person?.role === 'owner' }
|
|
57
71
|
|
|
72
|
+
// ---- TLS ----
|
|
73
|
+
// Leg does not make certificates. It uses a pair you already have, which on a
|
|
74
|
+
// Tailscale network is one command (`tailscale cert <machine>.<tailnet>.ts.net`)
|
|
75
|
+
// and gives a certificate browsers already trust. A self-signed pair would
|
|
76
|
+
// teach everyone on the board to click through a warning, which is worse than
|
|
77
|
+
// no TLS at all on a network that is already private.
|
|
78
|
+
export class TlsRefused extends Error {
|
|
79
|
+
constructor(msg) { super(msg); this.name = 'TlsRefused'; this.exitCode = 3 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tlsPaths(share, env) {
|
|
83
|
+
return {
|
|
84
|
+
cert: env.LEG_TLS_CERT || env.BATON_TLS_CERT || share?.tls?.cert || null,
|
|
85
|
+
key: env.LEG_TLS_KEY || env.BATON_TLS_KEY || share?.tls?.key || null,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// → { cert, key, cert_path, key_path } | null. Throws TlsRefused when a pair is
|
|
90
|
+
// configured but unusable: a board that quietly fell back to plaintext after
|
|
91
|
+
// being told to use TLS is the one failure this must not have.
|
|
92
|
+
export function readTls(share = readShare(), env = process.env) {
|
|
93
|
+
const { cert: certPath, key: keyPath } = tlsPaths(share, env)
|
|
94
|
+
if (!certPath && !keyPath) return null
|
|
95
|
+
if (!certPath || !keyPath) throw new TlsRefused('TLS needs both a certificate and a key (--tls-cert and --tls-key, or LEG_TLS_CERT and LEG_TLS_KEY)')
|
|
96
|
+
for (const [label, file] of [['certificate', certPath], ['key', keyPath]]) {
|
|
97
|
+
if (!existsSync(file)) throw new TlsRefused(`TLS ${label} not found: ${file}`)
|
|
98
|
+
try { statSync(file) } catch (err) { throw new TlsRefused(`TLS ${label} ${file}: ${err.message}`) }
|
|
99
|
+
}
|
|
100
|
+
let cert
|
|
101
|
+
let key
|
|
102
|
+
try { cert = readFileSync(certPath) } catch (err) { throw new TlsRefused(`TLS certificate ${certPath}: ${err.message}`) }
|
|
103
|
+
try { key = readFileSync(keyPath) } catch (err) { throw new TlsRefused(`TLS key ${keyPath}: ${err.message}`) }
|
|
104
|
+
if (!cert.length || !key.length) throw new TlsRefused('the TLS certificate or key is empty')
|
|
105
|
+
return { cert, key, cert_path: certPath, key_path: keyPath }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function tlsConfigured(share = readShare(), env = process.env) { return Boolean(tlsPaths(share, env).cert) }
|
|
109
|
+
|
|
110
|
+
export function scheme(share = readShare(), env = process.env) { return tlsConfigured(share, env) ? 'https' : 'http' }
|
|
111
|
+
|
|
58
112
|
export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
|
|
59
113
|
if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
|
|
60
|
-
if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (
|
|
114
|
+
if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (${ROLES.join('|')})`)
|
|
61
115
|
if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
|
|
62
116
|
const token = newToken()
|
|
63
117
|
const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
|
|
@@ -122,7 +176,7 @@ export async function resolveBind(kind = 'tailscale') {
|
|
|
122
176
|
return lan.address
|
|
123
177
|
}
|
|
124
178
|
|
|
125
|
-
export function linkFor(share, token) { return
|
|
179
|
+
export function linkFor(share, token) { return `${scheme(share)}://${share.bind}:${share.port}/?token=${token}` }
|
|
126
180
|
|
|
127
181
|
// Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
|
|
128
182
|
export function whoami(share = readShare()) {
|
|
@@ -131,9 +185,15 @@ export function whoami(share = readShare()) {
|
|
|
131
185
|
return share.owner || 'local'
|
|
132
186
|
}
|
|
133
187
|
|
|
134
|
-
export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
|
|
188
|
+
export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner, tlsCert = null, tlsKey = null } = {}) {
|
|
135
189
|
const share = readShare()
|
|
136
190
|
const address = await resolveBind(bind)
|
|
191
|
+
if (tlsCert || tlsKey) {
|
|
192
|
+
if (!tlsCert || !tlsKey) throw new TlsRefused('TLS needs both --tls-cert and --tls-key')
|
|
193
|
+
share.tls = { cert: tlsCert, key: tlsKey }
|
|
194
|
+
// read the pair now, so a bad one fails here and not at the next board start
|
|
195
|
+
readTls(share, {})
|
|
196
|
+
}
|
|
137
197
|
share.on = true
|
|
138
198
|
share.bind = address
|
|
139
199
|
share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
|