feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
@@ -445,3 +445,288 @@ describe('loadConfig — project discovery (v0.6)', () => {
|
||||
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code')
|
||||
})
|
||||
})
|
||||
|
||||
// ── v0.7 Walk-away Workbench — new env vars ───────────────────────────────────
|
||||
describe('loadConfig — v0.7 A1 push notifications', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: unset → both undefined', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.vapidPublicKey).toBeUndefined()
|
||||
expect(cfg.vapidPrivateKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: reads from env', () => {
|
||||
const cfg = loadConfig({ VAPID_PUBLIC_KEY: 'pubkey123', VAPID_PRIVATE_KEY: 'privkey456' })
|
||||
expect(cfg.vapidPublicKey).toBe('pubkey123')
|
||||
expect(cfg.vapidPrivateKey).toBe('privkey456')
|
||||
})
|
||||
|
||||
it('VAPID_SUBJECT: unset → undefined, reads from env', () => {
|
||||
expect(loadConfig({}).vapidSubject).toBeUndefined()
|
||||
expect(loadConfig({ VAPID_SUBJECT: 'mailto:admin@example.com' }).vapidSubject).toBe('mailto:admin@example.com')
|
||||
})
|
||||
|
||||
it('PUSH_STORE_PATH: default is <homeDir>/.web-terminal-push-subs.json', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.pushStorePath).toBe('/home/testuser/.web-terminal-push-subs.json')
|
||||
})
|
||||
|
||||
it('PUSH_STORE_PATH: reads from env', () => {
|
||||
const cfg = loadConfig({ PUSH_STORE_PATH: '/tmp/subs.json' })
|
||||
expect(cfg.pushStorePath).toBe('/tmp/subs.json')
|
||||
})
|
||||
|
||||
it('PUSH_MAX_SUBS: default 50, reads from env', () => {
|
||||
expect(loadConfig({}).pushMaxSubs).toBe(50)
|
||||
expect(loadConfig({ PUSH_MAX_SUBS: '20' }).pushMaxSubs).toBe(20)
|
||||
})
|
||||
|
||||
it('PUSH_MAX_SUBS: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ PUSH_MAX_SUBS: 'many' })).toThrow(/PUSH_MAX_SUBS/)
|
||||
})
|
||||
|
||||
it('NOTIFY_DONE: default true (1), parses on/off', () => {
|
||||
expect(loadConfig({}).notifyDone).toBe(true)
|
||||
expect(loadConfig({ NOTIFY_DONE: '0' }).notifyDone).toBe(false)
|
||||
expect(loadConfig({ NOTIFY_DONE: 'false' }).notifyDone).toBe(false)
|
||||
expect(loadConfig({ NOTIFY_DONE: '1' }).notifyDone).toBe(true)
|
||||
})
|
||||
|
||||
it('NOTIFY_DND: default false (0), parses on/off', () => {
|
||||
expect(loadConfig({}).notifyDnd).toBe(false)
|
||||
expect(loadConfig({ NOTIFY_DND: '1' }).notifyDnd).toBe(true)
|
||||
expect(loadConfig({ NOTIFY_DND: 'on' }).notifyDnd).toBe(true)
|
||||
expect(loadConfig({ NOTIFY_DND: '0' }).notifyDnd).toBe(false)
|
||||
})
|
||||
|
||||
it('DECISION_TOKEN_TTL_MS: default = permTimeoutMs', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.decisionTokenTtlMs).toBe(cfg.permTimeoutMs)
|
||||
})
|
||||
|
||||
it('DECISION_TOKEN_TTL_MS: readable from env, overrides the default', () => {
|
||||
const cfg = loadConfig({ DECISION_TOKEN_TTL_MS: '120000' })
|
||||
expect(cfg.decisionTokenTtlMs).toBe(120000)
|
||||
})
|
||||
|
||||
it('DECISION_TOKEN_TTL_MS: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ DECISION_TOKEN_TTL_MS: 'never' })).toThrow(/DECISION_TOKEN_TTL_MS/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 A4 timeline + A5 stuck detection', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('TIMELINE_MAX: default 200, reads from env', () => {
|
||||
expect(loadConfig({}).timelineMax).toBe(200)
|
||||
expect(loadConfig({ TIMELINE_MAX: '50' }).timelineMax).toBe(50)
|
||||
})
|
||||
|
||||
it('TIMELINE_MAX: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ TIMELINE_MAX: 'lots' })).toThrow(/TIMELINE_MAX/)
|
||||
})
|
||||
|
||||
it('TIMELINE_ENABLED: default true, parses on/off', () => {
|
||||
expect(loadConfig({}).timelineEnabled).toBe(true)
|
||||
expect(loadConfig({ TIMELINE_ENABLED: '0' }).timelineEnabled).toBe(false)
|
||||
expect(loadConfig({ TIMELINE_ENABLED: 'false' }).timelineEnabled).toBe(false)
|
||||
expect(loadConfig({ TIMELINE_ENABLED: '1' }).timelineEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('STUCK_TTL: default 600000ms (600s), reads from env in seconds', () => {
|
||||
expect(loadConfig({}).stuckTtlMs).toBe(600_000)
|
||||
expect(loadConfig({ STUCK_TTL: '300' }).stuckTtlMs).toBe(300_000)
|
||||
expect(loadConfig({ STUCK_TTL: '0' }).stuckTtlMs).toBe(0)
|
||||
})
|
||||
|
||||
it('STUCK_TTL: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ STUCK_TTL: 'soon' })).toThrow(/STUCK_TTL/)
|
||||
})
|
||||
|
||||
it('STUCK_ALERT: default true, parses on/off', () => {
|
||||
expect(loadConfig({}).stuckAlert).toBe(true)
|
||||
expect(loadConfig({ STUCK_ALERT: '0' }).stuckAlert).toBe(false)
|
||||
expect(loadConfig({ STUCK_ALERT: 'off' }).stuckAlert).toBe(false)
|
||||
expect(loadConfig({ STUCK_ALERT: '1' }).stuckAlert).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 B1 diff + B2 statusline', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('DIFF_TIMEOUT_MS: default 2000, reads from env', () => {
|
||||
expect(loadConfig({}).diffTimeoutMs).toBe(2000)
|
||||
expect(loadConfig({ DIFF_TIMEOUT_MS: '5000' }).diffTimeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('DIFF_MAX_BYTES: default 2MB, reads from env', () => {
|
||||
expect(loadConfig({}).diffMaxBytes).toBe(2 * 1024 * 1024)
|
||||
expect(loadConfig({ DIFF_MAX_BYTES: '1048576' }).diffMaxBytes).toBe(1048576)
|
||||
})
|
||||
|
||||
it('DIFF_MAX_FILES: default 300, reads from env', () => {
|
||||
expect(loadConfig({}).diffMaxFiles).toBe(300)
|
||||
expect(loadConfig({ DIFF_MAX_FILES: '100' }).diffMaxFiles).toBe(100)
|
||||
})
|
||||
|
||||
it('DIFF_* vars throw for non-integer', () => {
|
||||
expect(() => loadConfig({ DIFF_TIMEOUT_MS: 'fast' })).toThrow(/DIFF_TIMEOUT_MS/)
|
||||
expect(() => loadConfig({ DIFF_MAX_BYTES: 'big' })).toThrow(/DIFF_MAX_BYTES/)
|
||||
expect(() => loadConfig({ DIFF_MAX_FILES: 'many' })).toThrow(/DIFF_MAX_FILES/)
|
||||
})
|
||||
|
||||
it('STATUSLINE_TTL_MS: default 30000, reads from env', () => {
|
||||
expect(loadConfig({}).statuslineTtlMs).toBe(30_000)
|
||||
expect(loadConfig({ STATUSLINE_TTL_MS: '60000' }).statuslineTtlMs).toBe(60_000)
|
||||
})
|
||||
|
||||
it('STATUSLINE_TTL_MS: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ STATUSLINE_TTL_MS: 'long' })).toThrow(/STATUSLINE_TTL_MS/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 B3 worktree', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('WORKTREE_ENABLED: default true, parses on/off', () => {
|
||||
expect(loadConfig({}).worktreeEnabled).toBe(true)
|
||||
expect(loadConfig({ WORKTREE_ENABLED: '0' }).worktreeEnabled).toBe(false)
|
||||
expect(loadConfig({ WORKTREE_ENABLED: 'false' }).worktreeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('WORKTREE_ROOT: default undefined, reads from env', () => {
|
||||
expect(loadConfig({}).worktreeRoot).toBeUndefined()
|
||||
expect(loadConfig({ WORKTREE_ROOT: '/data/worktrees' }).worktreeRoot).toBe('/data/worktrees')
|
||||
})
|
||||
|
||||
it('WORKTREE_TIMEOUT_MS: default 10000, reads from env', () => {
|
||||
expect(loadConfig({}).worktreeTimeoutMs).toBe(10_000)
|
||||
expect(loadConfig({ WORKTREE_TIMEOUT_MS: '20000' }).worktreeTimeoutMs).toBe(20_000)
|
||||
})
|
||||
|
||||
it('WORKTREE_TIMEOUT_MS: throws for non-integer', () => {
|
||||
expect(() => loadConfig({ WORKTREE_TIMEOUT_MS: 'slow' })).toThrow(/WORKTREE_TIMEOUT_MS/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 B4 permission mode', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('DEFAULT_PERMISSION_MODE: default "default"', () => {
|
||||
expect(loadConfig({}).defaultPermissionMode).toBe('default')
|
||||
})
|
||||
|
||||
it('DEFAULT_PERMISSION_MODE: accepts all 4 valid values', () => {
|
||||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'default' }).defaultPermissionMode).toBe('default')
|
||||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'acceptEdits' }).defaultPermissionMode).toBe('acceptEdits')
|
||||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'plan' }).defaultPermissionMode).toBe('plan')
|
||||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'auto' }).defaultPermissionMode).toBe('auto')
|
||||
})
|
||||
|
||||
it('DEFAULT_PERMISSION_MODE: throws for invalid values', () => {
|
||||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'bypassPermissions' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'dontAsk' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: '' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||||
})
|
||||
|
||||
it('ALLOW_AUTO_MODE: default false, parses on/off', () => {
|
||||
expect(loadConfig({}).allowAutoMode).toBe(false)
|
||||
expect(loadConfig({ ALLOW_AUTO_MODE: '1' }).allowAutoMode).toBe(true)
|
||||
expect(loadConfig({ ALLOW_AUTO_MODE: 'true' }).allowAutoMode).toBe(true)
|
||||
expect(loadConfig({ ALLOW_AUTO_MODE: '0' }).allowAutoMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 L5 permTimeoutMs validation', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('PERM_TIMEOUT_MS=0 throws (must be > 0 for --max-time integration)', () => {
|
||||
expect(() => loadConfig({ PERM_TIMEOUT_MS: '0' })).toThrow(/PERM_TIMEOUT_MS/)
|
||||
})
|
||||
|
||||
it('PERM_TIMEOUT_MS=1 is valid (positive integer)', () => {
|
||||
expect(loadConfig({ PERM_TIMEOUT_MS: '1' }).permTimeoutMs).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 SEC-C5 VAPID key confidentiality', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('vapidPrivateKey field name is distinct (caller must avoid logging it)', () => {
|
||||
// The private key must never appear in logs. Config stores the raw value
|
||||
// under "vapidPrivateKey" — callers that log config should skip this field.
|
||||
const cfg = loadConfig({ VAPID_PRIVATE_KEY: 'super-secret-key' })
|
||||
expect(cfg.vapidPrivateKey).toBe('super-secret-key')
|
||||
// Verify the field name so callers can explicitly skip it in log output
|
||||
expect(Object.keys(cfg)).toContain('vapidPrivateKey')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig — v0.7 all new fields present in frozen result', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('returned object is frozen and contains all 21 new v0.7 fields', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(Object.isFrozen(cfg)).toBe(true)
|
||||
|
||||
// A1 push
|
||||
expect(cfg).toHaveProperty('vapidPublicKey')
|
||||
expect(cfg).toHaveProperty('vapidPrivateKey')
|
||||
expect(cfg).toHaveProperty('vapidSubject')
|
||||
expect(cfg).toHaveProperty('pushStorePath')
|
||||
expect(cfg).toHaveProperty('pushMaxSubs')
|
||||
expect(cfg).toHaveProperty('notifyDone')
|
||||
expect(cfg).toHaveProperty('notifyDnd')
|
||||
expect(cfg).toHaveProperty('decisionTokenTtlMs')
|
||||
|
||||
// A4 timeline
|
||||
expect(cfg).toHaveProperty('timelineMax')
|
||||
expect(cfg).toHaveProperty('timelineEnabled')
|
||||
|
||||
// A5 stuck
|
||||
expect(cfg).toHaveProperty('stuckTtlMs')
|
||||
expect(cfg).toHaveProperty('stuckAlert')
|
||||
|
||||
// B1 diff
|
||||
expect(cfg).toHaveProperty('diffTimeoutMs')
|
||||
expect(cfg).toHaveProperty('diffMaxBytes')
|
||||
expect(cfg).toHaveProperty('diffMaxFiles')
|
||||
|
||||
// B2 statusline
|
||||
expect(cfg).toHaveProperty('statuslineTtlMs')
|
||||
|
||||
// B3 worktree
|
||||
expect(cfg).toHaveProperty('worktreeEnabled')
|
||||
expect(cfg).toHaveProperty('worktreeRoot')
|
||||
expect(cfg).toHaveProperty('worktreeTimeoutMs')
|
||||
|
||||
// B4 permission mode
|
||||
expect(cfg).toHaveProperty('defaultPermissionMode')
|
||||
expect(cfg).toHaveProperty('allowAutoMode')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user